mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,238 @@
|
||||
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 { extname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Grouped metadata returned by ExifTool -json -G */
|
||||
export interface ExifToolMetadata {
|
||||
[group: string]: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Structured inspect result for the frontend */
|
||||
export interface InspectResult {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
exif: Record<string, unknown> | null;
|
||||
iptc: Record<string, unknown> | null;
|
||||
xmp: Record<string, unknown> | null;
|
||||
gps: Record<string, unknown> | null;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
let cachedBinary: string | null = null;
|
||||
|
||||
async function findExiftool(): Promise<string> {
|
||||
if (cachedBinary) return cachedBinary;
|
||||
try {
|
||||
await execFileAsync("exiftool", ["-ver"], { timeout: 5_000 });
|
||||
cachedBinary = "exiftool";
|
||||
return "exiftool";
|
||||
} catch {
|
||||
throw new Error(
|
||||
"ExifTool not found. Install libimage-exiftool-perl (Linux) or brew install exiftool (macOS).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all metadata from an image buffer using ExifTool.
|
||||
* Returns grouped metadata sections (EXIF, IPTC, XMP, GPS, etc.)
|
||||
*/
|
||||
export async function inspectMetadata(buffer: Buffer, filename: string): Promise<InspectResult> {
|
||||
const bin = await findExiftool();
|
||||
const ext = extname(filename) || ".jpg";
|
||||
const id = randomUUID();
|
||||
const tempPath = join(tmpdir(), `exif-inspect-${id}${ext}`);
|
||||
|
||||
try {
|
||||
await writeFile(tempPath, buffer);
|
||||
const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], {
|
||||
timeout: 30_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
const raw = parsed[0] ?? {};
|
||||
|
||||
// Group fields by their ExifTool group prefix (e.g. "EXIF:Artist", "IPTC:Keywords")
|
||||
const exif: Record<string, unknown> = {};
|
||||
const iptc: Record<string, unknown> = {};
|
||||
const xmp: Record<string, unknown> = {};
|
||||
const gps: Record<string, unknown> = {};
|
||||
const keywords: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === "SourceFile") continue;
|
||||
|
||||
const [group, field] = key.includes(":") ? key.split(":", 2) : ["File", key];
|
||||
|
||||
if (group === "EXIF") {
|
||||
exif[field] = value;
|
||||
} else if (group === "IPTC") {
|
||||
if (field === "Keywords") {
|
||||
if (Array.isArray(value)) keywords.push(...value.map(String));
|
||||
else if (value) keywords.push(String(value));
|
||||
}
|
||||
iptc[field] = value;
|
||||
} else if (group === "XMP") {
|
||||
if (field === "Subject") {
|
||||
if (Array.isArray(value)) keywords.push(...value.map(String));
|
||||
}
|
||||
xmp[field] = value;
|
||||
} else if (group === "GPS" || group === "Composite") {
|
||||
if (field.startsWith("GPS") || field === "GPSPosition") {
|
||||
gps[field] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate keywords
|
||||
const uniqueKeywords = [...new Set(keywords)];
|
||||
|
||||
return {
|
||||
filename,
|
||||
fileSize: buffer.length,
|
||||
exif: Object.keys(exif).length > 0 ? exif : null,
|
||||
iptc: Object.keys(iptc).length > 0 ? iptc : null,
|
||||
xmp: Object.keys(xmp).length > 0 ? xmp : null,
|
||||
gps: Object.keys(gps).length > 0 ? gps : null,
|
||||
keywords: uniqueKeywords,
|
||||
};
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write metadata tags to an image buffer using ExifTool.
|
||||
* Modifies metadata in-place without re-encoding pixels.
|
||||
*/
|
||||
export async function writeMetadata(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
tags: string[],
|
||||
): Promise<Buffer> {
|
||||
if (tags.length === 0) return buffer;
|
||||
|
||||
const bin = await findExiftool();
|
||||
const ext = extname(filename) || ".jpg";
|
||||
const id = randomUUID();
|
||||
const tempPath = join(tmpdir(), `exif-write-${id}${ext}`);
|
||||
|
||||
try {
|
||||
await writeFile(tempPath, buffer);
|
||||
await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], {
|
||||
timeout: 30_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return await readFile(tempPath);
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings shape that buildTagArgs accepts */
|
||||
export interface EditMetadataSettings {
|
||||
artist?: string;
|
||||
copyright?: string;
|
||||
imageDescription?: string;
|
||||
software?: string;
|
||||
dateTime?: string;
|
||||
dateTimeOriginal?: string;
|
||||
clearGps?: boolean;
|
||||
fieldsToRemove?: string[];
|
||||
gpsLatitude?: number;
|
||||
gpsLongitude?: number;
|
||||
gpsAltitude?: number;
|
||||
keywords?: string[];
|
||||
keywordsMode?: "add" | "set";
|
||||
dateShift?: string;
|
||||
setAllDates?: string;
|
||||
iptcTitle?: string;
|
||||
iptcHeadline?: string;
|
||||
iptcCity?: string;
|
||||
iptcState?: string;
|
||||
iptcCountry?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert settings object into ExifTool CLI tag arguments.
|
||||
*/
|
||||
export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Basic EXIF fields
|
||||
if (settings.artist) args.push(`-Artist=${settings.artist}`);
|
||||
if (settings.copyright) args.push(`-Copyright=${settings.copyright}`);
|
||||
if (settings.imageDescription) args.push(`-ImageDescription=${settings.imageDescription}`);
|
||||
if (settings.software) args.push(`-Software=${settings.software}`);
|
||||
|
||||
// Date fields
|
||||
if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`);
|
||||
if (settings.dateTimeOriginal) args.push(`-DateTimeOriginal=${settings.dateTimeOriginal}`);
|
||||
|
||||
// Date shift (applies to all date fields)
|
||||
if (settings.dateShift) {
|
||||
const direction = settings.dateShift.startsWith("-") ? "-" : "+";
|
||||
const value = settings.dateShift.replace(/^[+-]/, "");
|
||||
args.push(`-AllDates${direction}=0:0:0 ${value}:0`);
|
||||
}
|
||||
|
||||
// Set all dates to a specific value
|
||||
if (settings.setAllDates) {
|
||||
args.push(`-AllDates=${settings.setAllDates}`);
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
if (settings.clearGps) {
|
||||
args.push("-gps:all=");
|
||||
} else if (settings.gpsLatitude !== undefined && settings.gpsLongitude !== undefined) {
|
||||
const lat = settings.gpsLatitude;
|
||||
const lon = settings.gpsLongitude;
|
||||
args.push(`-GPSLatitude=${Math.abs(lat)}`);
|
||||
args.push(`-GPSLatitudeRef=${lat >= 0 ? "N" : "S"}`);
|
||||
args.push(`-GPSLongitude=${Math.abs(lon)}`);
|
||||
args.push(`-GPSLongitudeRef=${lon >= 0 ? "E" : "W"}`);
|
||||
if (settings.gpsAltitude !== undefined) {
|
||||
args.push(`-GPSAltitude=${Math.abs(settings.gpsAltitude)}`);
|
||||
args.push(
|
||||
`-GPSAltitudeRef=${settings.gpsAltitude >= 0 ? "Above Sea Level" : "Below Sea Level"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keywords
|
||||
if (settings.keywords && settings.keywords.length > 0) {
|
||||
if (settings.keywordsMode === "set") {
|
||||
// Clear existing first, then set new ones
|
||||
args.push("-IPTC:Keywords=");
|
||||
args.push("-XMP:Subject=");
|
||||
}
|
||||
for (const kw of settings.keywords) {
|
||||
if (kw.trim()) {
|
||||
args.push(`-IPTC:Keywords+=${kw.trim()}`);
|
||||
args.push(`-XMP:Subject+=${kw.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IPTC fields
|
||||
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${settings.iptcTitle}`);
|
||||
if (settings.iptcHeadline) args.push(`-IPTC:Headline=${settings.iptcHeadline}`);
|
||||
if (settings.iptcCity) args.push(`-IPTC:City=${settings.iptcCity}`);
|
||||
if (settings.iptcState) args.push(`-IPTC:Province-State=${settings.iptcState}`);
|
||||
if (settings.iptcCountry) args.push(`-IPTC:Country-PrimaryLocationName=${settings.iptcCountry}`);
|
||||
|
||||
// Field removal
|
||||
if (settings.fieldsToRemove && settings.fieldsToRemove.length > 0) {
|
||||
for (const field of settings.fieldsToRemove) {
|
||||
args.push(`-${field}=`);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -64,6 +64,28 @@ export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
||||
* Encode a PNG/JPEG buffer to HEIC using the system `heif-enc` CLI tool.
|
||||
* Uses x265 (HEVC) compression for true HEIC output.
|
||||
*/
|
||||
/**
|
||||
* Detect HEIC/HEIF format from magic bytes (ftyp box at offset 4, brand at offset 8).
|
||||
*/
|
||||
function isHeifBuffer(buffer: Buffer): boolean {
|
||||
if (buffer.length < 12) return false;
|
||||
const ftyp = buffer.subarray(4, 8).toString("ascii");
|
||||
if (ftyp !== "ftyp") return false;
|
||||
const brand = buffer.subarray(8, 12).toString("ascii");
|
||||
return ["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a buffer is decodable by Sharp. HEIC/HEIF buffers are decoded to
|
||||
* PNG via the system decoder; all other formats pass through unchanged.
|
||||
*/
|
||||
export async function ensureSharpCompat(buffer: Buffer): Promise<Buffer> {
|
||||
if (isHeifBuffer(buffer)) {
|
||||
return decodeHeic(buffer);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer> {
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `heic-in-${id}.png`);
|
||||
|
||||
Reference in New Issue
Block a user