From dde70f70ad8c2f6bc4c16fe08d8a630af2e897c3 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Sun, 12 Apr 2026 08:50:19 +0800 Subject: [PATCH] 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 --- apps/api/src/index.ts | 2 +- apps/api/src/lib/exiftool.ts | 238 ++++++ apps/api/src/lib/heic-converter.ts | 22 + apps/api/src/routes/batch.ts | 8 +- apps/api/src/routes/branding.ts | 6 +- apps/api/src/routes/tools/barcode-read.ts | 4 + apps/api/src/routes/tools/collage.ts | 4 +- apps/api/src/routes/tools/color-palette.ts | 4 + apps/api/src/routes/tools/compare.ts | 5 + apps/api/src/routes/tools/compose.ts | 5 + apps/api/src/routes/tools/edit-metadata.ts | 252 ++++-- apps/api/src/routes/tools/favicon.ts | 4 + apps/api/src/routes/tools/find-duplicates.ts | 6 + apps/api/src/routes/tools/image-to-pdf.ts | 6 +- apps/api/src/routes/tools/info.ts | 7 +- apps/api/src/routes/tools/split.ts | 4 + apps/api/src/routes/tools/stitch.ts | 4 +- apps/api/src/routes/tools/vectorize.ts | 4 + apps/api/src/routes/tools/watermark-image.ts | 5 + apps/api/src/routes/user-files.ts | 7 +- apps/web/package.json | 2 + .../src/components/common/image-viewer.tsx | 31 +- .../src/components/common/metadata-grid.tsx | 4 +- .../tools/edit-metadata-settings.tsx | 755 ++++++++++++++---- .../tools/image-to-pdf-settings.tsx | 192 +++-- .../tools/strip-metadata-settings.tsx | 70 +- apps/web/src/lib/metadata-utils.ts | 74 +- docker/Dockerfile | 1 + packages/image-engine/src/types.ts | 12 + packages/shared/src/constants.ts | 2 +- packages/shared/src/i18n/en.ts | 7 +- pnpm-lock.yaml | 23 + 32 files changed, 1423 insertions(+), 347 deletions(-) create mode 100644 apps/api/src/lib/exiftool.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c3a0aae6..0e4c3532 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -56,7 +56,7 @@ app.addHook("onSend", async (_request, reply) => { reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); const csp = _request.url.startsWith("/api/docs") ? "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'" - : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; reply.header("Content-Security-Policy", csp); } }); diff --git a/apps/api/src/lib/exiftool.ts b/apps/api/src/lib/exiftool.ts new file mode 100644 index 00000000..788c2b75 --- /dev/null +++ b/apps/api/src/lib/exiftool.ts @@ -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; +} + +/** Structured inspect result for the frontend */ +export interface InspectResult { + filename: string; + fileSize: number; + exif: Record | null; + iptc: Record | null; + xmp: Record | null; + gps: Record | null; + keywords: string[]; +} + +let cachedBinary: string | null = null; + +async function findExiftool(): Promise { + 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 { + 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 = {}; + const iptc: Record = {}; + const xmp: Record = {}; + const gps: Record = {}; + 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 { + 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; +} diff --git a/apps/api/src/lib/heic-converter.ts b/apps/api/src/lib/heic-converter.ts index 7306dc88..7d4a4b75 100644 --- a/apps/api/src/lib/heic-converter.ts +++ b/apps/api/src/lib/heic-converter.ts @@ -64,6 +64,28 @@ export async function decodeHeic(buffer: Buffer): Promise { * 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 { + if (isHeifBuffer(buffer)) { + return decodeHeic(buffer); + } + return buffer; +} + export async function encodeHeic(buffer: Buffer, quality = 80): Promise { const id = randomUUID(); const inputPath = join(tmpdir(), `heic-in-${id}.png`); diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 7c6bebf0..d71ffc0d 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -146,10 +146,14 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { 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 }; diff --git a/apps/api/src/routes/branding.ts b/apps/api/src/routes/branding.ts index faf440f7..37a0a277 100644 --- a/apps/api/src/routes/branding.ts +++ b/apps/api/src/routes/branding.ts @@ -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 { .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(); diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index 799b4810..6232de2a 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -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(); diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 45417791..d0e37d15 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -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; diff --git a/apps/api/src/routes/tools/color-palette.ts b/apps/api/src/routes/tools/color-palette.ts index f753999f..816a7c7d 100644 --- a/apps/api/src/routes/tools/color-palette.ts +++ b/apps/api/src/routes/tools/color-palette.ts @@ -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" }) diff --git a/apps/api/src/routes/tools/compare.ts b/apps/api/src/routes/tools/compare.ts index 85042479..a8518a6e 100644 --- a/apps/api/src/routes/tools/compare.ts +++ b/apps/api/src/routes/tools/compare.ts @@ -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(); diff --git a/apps/api/src/routes/tools/compose.ts b/apps/api/src/routes/tools/compose.ts index 57f65350..d4ef2e41 100644 --- a/apps/api/src/routes/tools/compose.ts +++ b/apps/api/src/routes/tools/compose.ts @@ -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) { diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts index 1ec5bb1a..6c41021b 100644 --- a/apps/api/src/routes/tools/edit-metadata.ts +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -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; + +const MIME_BY_FORMAT: Record = { + 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 = { - filename, - fileSize: fileBuffer.length, - }; - - if (metadata.exif) { - try { - const parsed = parseExif(metadata.exif); - const exifData: Record = { - ...parsed.image, - ...parsed.photo, - ...parsed.iop, - }; - const gpsData: Record = { ...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 = { + // Determine content type from extension + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const extToMime: Record = { + 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", }; }, }); diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index 38709266..1721fd09 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -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(); diff --git a/apps/api/src/routes/tools/find-duplicates.ts b/apps/api/src/routes/tools/find-duplicates.ts index d029285d..8a0d446c 100644 --- a/apps/api/src/routes/tools/find-duplicates.ts +++ b/apps/api/src/routes/tools/find-duplicates.ts @@ -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) { diff --git a/apps/api/src/routes/tools/image-to-pdf.ts b/apps/api/src/routes/tools/image-to-pdf.ts index 4956c289..08a38f59 100644 --- a/apps/api/src/routes/tools/image-to-pdf.ts +++ b/apps/api/src/routes/tools/image-to-pdf.ts @@ -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; diff --git a/apps/api/src/routes/tools/info.ts b/apps/api/src/routes/tools/info.ts index 41e73e9a..c02ba6bc 100644 --- a/apps/api/src/routes/tools/info.ts +++ b/apps/api/src/routes/tools/info.ts @@ -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) => ({ diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index a0a798f6..aa608d7a 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -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; diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 590f40d0..64bd2aee 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -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; diff --git a/apps/api/src/routes/tools/vectorize.ts b/apps/api/src/routes/tools/vectorize.ts index 75d92282..0e81239a 100644 --- a/apps/api/src/routes/tools/vectorize.ts +++ b/apps/api/src/routes/tools/vectorize.ts @@ -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(); diff --git a/apps/api/src/routes/tools/watermark-image.ts b/apps/api/src/routes/tools/watermark-image.ts index d36a55da..1641a635 100644 --- a/apps/api/src/routes/tools/watermark-image.ts +++ b/apps/api/src/routes/tools/watermark-image.ts @@ -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; diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 5cf0c4d7..3a89c995 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -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 { 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(); diff --git a/apps/web/package.json b/apps/web/package.json index 5aca0b04..6ec5b5d2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@stirling-image/shared": "workspace:*", "clsx": "^2.1.0", "fflate": "^0.8.2", + "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -26,6 +27,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/leaflet": "^1.9.21", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/apps/web/src/components/common/image-viewer.tsx b/apps/web/src/components/common/image-viewer.tsx index fd6d3d4f..6ad0c741 100644 --- a/apps/web/src/components/common/image-viewer.tsx +++ b/apps/web/src/components/common/image-viewer.tsx @@ -28,18 +28,24 @@ export function ImageViewer({ const [naturalWidth, setNaturalWidth] = useState(null); const [naturalHeight, setNaturalHeight] = useState(null); const [fitMode, setFitMode] = useState<"fit" | "actual">("fit"); + const [loadError, setLoadError] = useState(false); const containerRef = useRef(null); const imgRef = useRef(null); const isSvg = filename.toLowerCase().endsWith(".svg"); const handleImageLoad = useCallback(() => { + setLoadError(false); if (imgRef.current) { setNaturalWidth(imgRef.current.naturalWidth); setNaturalHeight(imgRef.current.naturalHeight); } }, []); + const handleImageError = useCallback(() => { + setLoadError(true); + }, []); + const zoomIn = useCallback(() => { setZoom((prev) => { const next = ZOOM_STEPS.find((s) => s > prev); @@ -66,13 +72,14 @@ export function ImageViewer({ setZoom(100); }, []); - // Reset zoom on src change + // Reset state on src change useEffect(() => { setZoom(DEFAULT_ZOOM); setFitMode("fit"); setNaturalWidth(null); setNaturalHeight(null); - }, []); + setLoadError(false); + }, [src]); const previewTransform = [ cssRotate ? `rotate(${cssRotate}deg)` : "", @@ -150,23 +157,21 @@ export function ImageViewer({ ref={containerRef} className="flex-1 flex items-center justify-center overflow-auto bg-muted/20 p-4" > - {isSvg ? ( - {filename} + {loadError ? ( +
+

Preview not available

+

+ This format cannot be displayed in the browser +

+
) : ( {filename} diff --git a/apps/web/src/components/common/metadata-grid.tsx b/apps/web/src/components/common/metadata-grid.tsx index 31db3cea..bb6751be 100644 --- a/apps/web/src/components/common/metadata-grid.tsx +++ b/apps/web/src/components/common/metadata-grid.tsx @@ -1,5 +1,5 @@ import { Trash2 } from "lucide-react"; -import { formatExifValue, SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS } from "@/lib/metadata-utils"; +import { formatExifValue, SKIP_KEYS } from "@/lib/metadata-utils"; export function MetadataGrid({ data, @@ -29,7 +29,7 @@ export function MetadataGrid({ > {entries.map(([k, v]) => { const isRemoved = removedKeys?.has(k); - const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k); + const canRemove = !!onRemove; return (
| null; - exifError?: string; + iptc?: Record | null; + xmp?: Record | null; gps?: Record | null; - xmp?: Record | null; + keywords?: string[]; } interface FormFields { @@ -25,6 +35,19 @@ interface FormFields { dateTime: string; dateTimeOriginal: string; clearGps: boolean; + gpsLatitude: string; + gpsLongitude: string; + gpsAltitude: string; + dateMode: "edit" | "shift"; + dateShiftDirection: "+" | "-"; + dateShiftValue: string; + keywords: string[]; + keywordsMode: "add" | "set"; + iptcTitle: string; + iptcHeadline: string; + iptcCity: string; + iptcState: string; + iptcCountry: string; } const EMPTY_FORM: FormFields = { @@ -35,8 +58,41 @@ const EMPTY_FORM: FormFields = { dateTime: "", dateTimeOriginal: "", clearGps: false, + gpsLatitude: "", + gpsLongitude: "", + gpsAltitude: "", + dateMode: "edit", + dateShiftDirection: "+", + dateShiftValue: "", + keywords: [], + keywordsMode: "add", + iptcTitle: "", + iptcHeadline: "", + iptcCity: "", + iptcState: "", + iptcCountry: "", }; +interface Template { + name: string; + values: Partial; +} + +const TEMPLATES_KEY = "metadata-templates"; + +function loadTemplates(): Template[] { + try { + const raw = localStorage.getItem(TEMPLATES_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveTemplates(templates: Template[]) { + localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); +} + function LabeledInput({ label, id, @@ -44,6 +100,8 @@ function LabeledInput({ onChange, placeholder, hint, + type = "text", + disabled, }: { label: string; id: string; @@ -51,6 +109,8 @@ function LabeledInput({ onChange: (v: string) => void; placeholder?: string; hint?: string; + type?: string; + disabled?: boolean; }) { return (
@@ -59,11 +119,12 @@ function LabeledInput({ onChange(e.target.value)} placeholder={placeholder} - className="w-full px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + disabled={disabled} + className="w-full px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" /> {hint &&

{hint}

}
@@ -82,6 +143,9 @@ export function EditMetadataSettings() { const [inspecting, setInspecting] = useState(false); const [inspectError, setInspectError] = useState(null); const [inspectCache, setInspectCache] = useState>(new Map()); + const [keywordInput, setKeywordInput] = useState(""); + const [templates, setTemplates] = useState(loadTemplates); + const [templateName, setTemplateName] = useState(""); const currentFile = entries[selectedIndex]?.file ?? null; const fileKey = currentFile @@ -89,16 +153,32 @@ export function EditMetadataSettings() { : null; const populateForm = useCallback((data: InspectResult) => { - const exif = data.exif ?? {}; setInspectData(data); + const exif = data.exif ?? {}; + const iptc = data.iptc ?? {}; + const gps = data.gps ?? {}; + const populated: FormFields = { artist: exifStr(exif, "Artist"), copyright: exifStr(exif, "Copyright"), imageDescription: exifStr(exif, "ImageDescription"), software: exifStr(exif, "Software"), - dateTime: exifStr(exif, "DateTime"), + dateTime: exifStr(exif, "ModifyDate") || exifStr(exif, "DateTime"), dateTimeOriginal: exifStr(exif, "DateTimeOriginal"), clearGps: false, + gpsLatitude: gps.GPSLatitude != null ? String(gps.GPSLatitude) : "", + gpsLongitude: gps.GPSLongitude != null ? String(gps.GPSLongitude) : "", + gpsAltitude: gps.GPSAltitude != null ? String(gps.GPSAltitude) : "", + dateMode: "edit", + dateShiftDirection: "+", + dateShiftValue: "", + keywords: data.keywords ?? [], + keywordsMode: "add", + iptcTitle: exifStr(iptc, "ObjectName"), + iptcHeadline: exifStr(iptc, "Headline"), + iptcCity: exifStr(iptc, "City"), + iptcState: exifStr(iptc, "Province-State"), + iptcCountry: exifStr(iptc, "Country-PrimaryLocationName"), }; setForm(populated); setInitialForm(populated); @@ -167,168 +247,327 @@ export function EditMetadataSettings() { }); }; + const addKeyword = () => { + const kw = keywordInput.trim(); + if (kw && !form.keywords.includes(kw)) { + setField("keywords", [...form.keywords, kw]); + setKeywordInput(""); + } + }; + + const removeKeyword = (kw: string) => { + setField( + "keywords", + form.keywords.filter((k) => k !== kw), + ); + }; + + const saveTemplate = () => { + const name = templateName.trim(); + if (!name) return; + const { dateMode, dateShiftDirection, dateShiftValue, ...values } = form; + const tmpl: Template = { name, values }; + const updated = [...templates.filter((t) => t.name !== name), tmpl]; + setTemplates(updated); + saveTemplates(updated); + setTemplateName(""); + }; + + const loadTemplate = (name: string) => { + const tmpl = templates.find((t) => t.name === name); + if (tmpl) { + setForm((prev) => ({ ...prev, ...tmpl.values })); + } + }; + + const deleteTemplate = (name: string) => { + const updated = templates.filter((t) => t.name !== name); + setTemplates(updated); + saveTemplates(updated); + }; + + // Changes summary + const changes = useMemo(() => { + let modified = 0; + const removed = fieldsToRemove.size; + const simpleFields: (keyof FormFields)[] = [ + "artist", + "copyright", + "imageDescription", + "software", + "dateTime", + "dateTimeOriginal", + "iptcTitle", + "iptcHeadline", + "iptcCity", + "iptcState", + "iptcCountry", + ]; + for (const key of simpleFields) { + if (form[key] !== initialForm[key]) modified++; + } + const gpsAdded = + !form.clearGps && + (form.gpsLatitude !== initialForm.gpsLatitude || + form.gpsLongitude !== initialForm.gpsLongitude); + const gpsCleared = form.clearGps; + const keywordsChanged = JSON.stringify(form.keywords) !== JSON.stringify(initialForm.keywords); + const hasShift = form.dateMode === "shift" && form.dateShiftValue.trim() !== ""; + + if (gpsAdded) modified++; + if (keywordsChanged) modified++; + if (hasShift) modified++; + + const total = modified + removed + (gpsCleared ? 1 : 0); + return { modified, removed, gpsAdded, gpsCleared, keywordsChanged, hasShift, total }; + }, [form, initialForm, fieldsToRemove]); + const hasFile = files.length > 0; - const gpsLat = inspectData?.gps?._latitude as number | undefined; - const gpsLon = inspectData?.gps?._longitude as number | undefined; + const gpsLat = inspectData?.gps?.GPSLatitude as number | undefined; + const gpsLon = inspectData?.gps?.GPSLongitude as number | undefined; const gpsCoords = gpsLat != null && gpsLon != null ? { lat: gpsLat, lon: gpsLon } : null; - const exifEntryCount = inspectData?.exif - ? Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length - : 0; - const hasGps = - !!inspectData?.gps && Object.keys(inspectData.gps).filter((k) => !k.startsWith("_")).length > 0; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!hasFile || processing) return; - const settings: Record = { clearGps: form.clearGps }; + const settings: Record = {}; - const fieldMap: Array<{ - formKey: keyof FormFields; - settingsKey: string; - exifTag: string; - }> = [ - { formKey: "artist", settingsKey: "artist", exifTag: "Artist" }, - { formKey: "copyright", settingsKey: "copyright", exifTag: "Copyright" }, - { - formKey: "imageDescription", - settingsKey: "imageDescription", - exifTag: "ImageDescription", - }, - { formKey: "software", settingsKey: "software", exifTag: "Software" }, - { formKey: "dateTime", settingsKey: "dateTime", exifTag: "DateTime" }, - { - formKey: "dateTimeOriginal", - settingsKey: "dateTimeOriginal", - exifTag: "DateTimeOriginal", - }, - ]; + // Basic EXIF fields - only send if changed + if (form.artist !== initialForm.artist && form.artist.trim()) + settings.artist = form.artist.trim(); + if (form.copyright !== initialForm.copyright && form.copyright.trim()) + settings.copyright = form.copyright.trim(); + if (form.imageDescription !== initialForm.imageDescription && form.imageDescription.trim()) + settings.imageDescription = form.imageDescription.trim(); + if (form.software !== initialForm.software && form.software.trim()) + settings.software = form.software.trim(); - const removeSet = new Set(fieldsToRemove); - - for (const { formKey, settingsKey, exifTag } of fieldMap) { - const current = form[formKey] as string; - const initial = initialForm[formKey] as string; - if (current !== initial) { - if (current.trim()) { - settings[settingsKey] = current.trim(); - removeSet.delete(exifTag); - } else { - removeSet.add(exifTag); - } - } + // Date fields + if (form.dateMode === "shift" && form.dateShiftValue.trim()) { + settings.dateShift = `${form.dateShiftDirection}${form.dateShiftValue.trim()}`; + } else { + if (form.dateTime !== initialForm.dateTime && form.dateTime.trim()) + settings.dateTime = form.dateTime.trim(); + if (form.dateTimeOriginal !== initialForm.dateTimeOriginal && form.dateTimeOriginal.trim()) + settings.dateTimeOriginal = form.dateTimeOriginal.trim(); } - if (removeSet.size > 0) { - settings.fieldsToRemove = Array.from(removeSet); + // GPS + if (form.clearGps) { + settings.clearGps = true; + } else if ( + form.gpsLatitude.trim() && + form.gpsLongitude.trim() && + (form.gpsLatitude !== initialForm.gpsLatitude || + form.gpsLongitude !== initialForm.gpsLongitude || + form.gpsAltitude !== initialForm.gpsAltitude) + ) { + settings.gpsLatitude = parseFloat(form.gpsLatitude); + settings.gpsLongitude = parseFloat(form.gpsLongitude); + if (form.gpsAltitude.trim()) settings.gpsAltitude = parseFloat(form.gpsAltitude); + } + + // Keywords + if (JSON.stringify(form.keywords) !== JSON.stringify(initialForm.keywords)) { + settings.keywords = form.keywords; + settings.keywordsMode = form.keywordsMode; + } + + // IPTC fields + if (form.iptcTitle !== initialForm.iptcTitle && form.iptcTitle.trim()) + settings.iptcTitle = form.iptcTitle.trim(); + if (form.iptcHeadline !== initialForm.iptcHeadline && form.iptcHeadline.trim()) + settings.iptcHeadline = form.iptcHeadline.trim(); + if (form.iptcCity !== initialForm.iptcCity && form.iptcCity.trim()) + settings.iptcCity = form.iptcCity.trim(); + if (form.iptcState !== initialForm.iptcState && form.iptcState.trim()) + settings.iptcState = form.iptcState.trim(); + if (form.iptcCountry !== initialForm.iptcCountry && form.iptcCountry.trim()) + settings.iptcCountry = form.iptcCountry.trim(); + + // Fields to remove + if (fieldsToRemove.size > 0) { + settings.fieldsToRemove = Array.from(fieldsToRemove); } processFiles(files, settings); }; return ( -
- {/* Current Metadata */} - {hasFile && ( -
-

Current Metadata

- - {inspecting && ( -
- - Reading metadata... -
- )} - - {inspectError && !inspecting && ( -
- - Could not read metadata - fields will start empty. -
- )} - - {inspectData && ( -
- {exifEntryCount > 0 && inspectData.exif ? ( - - - - ) : ( -

No EXIF data found.

- )} - {hasGps && inspectData.gps && ( - - !k.startsWith("_")), - )} - /> - - )} -
- )} + + {/* Inspect status */} + {hasFile && inspecting && ( +
+ + Reading metadata... +
+ )} + {hasFile && inspectError && !inspecting && ( +
+ + Could not read metadata - fields will start empty.
)} - {/* Edit Fields */} + {/* Section 1: Basic Info */} {hasFile && ( -
-
-

Edit Fields

+ +
+ setField("imageDescription", v)} + placeholder="Image description" + /> + setField("artist", v)} + placeholder="Photographer / creator name" + /> + setField("copyright", v)} + placeholder="2026 Example Corp" + /> + setField("software", v)} + placeholder="e.g. Lightroom, Photoshop" + /> +
+

IPTC

+
+ setField("iptcTitle", v)} + placeholder="Image title" + /> + setField("iptcHeadline", v)} + placeholder="Short headline" + /> + setField("iptcCity", v)} + placeholder="City name" + /> + setField("iptcState", v)} + placeholder="State or province" + /> + setField("iptcCountry", v)} + placeholder="Country name" + /> +
+
+
+
+ )} - setField("imageDescription", v)} - placeholder="Image description" - /> - setField("artist", v)} - placeholder="Photographer / creator name" - /> - setField("copyright", v)} - placeholder="2026 Example" - /> - setField("software", v)} - placeholder="e.g. Lightroom, Photoshop" - /> - setField("dateTime", v)} - placeholder="YYYY:MM:DD HH:MM:SS" - hint="EXIF date format: 2026:04:06 12:00:00" - /> - setField("dateTimeOriginal", v)} - placeholder="YYYY:MM:DD HH:MM:SS" - /> + {/* Section 2: Date & Time */} + {hasFile && ( + +
+
+ + +
- {/* GPS */} -
-
- {gpsCoords ? ( + {form.dateMode === "edit" ? ( + <> + setField("dateTime", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + hint="EXIF format: 2026:04:11 12:00:00" + /> + setField("dateTimeOriginal", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + /> + + ) : ( +
+

+ Shift all date fields by an offset (useful for timezone corrections) +

+
+
+ + +
+
+ setField("dateShiftValue", v)} + placeholder="1:30" + hint="e.g. 1:30 for 1 hour 30 minutes" + /> +
+
+
+ )} +
+ + )} + + {/* Section 3: Location (GPS) */} + {hasFile && ( + +
+ {gpsCoords && (
@@ -336,26 +575,226 @@ export function EditMetadataSettings() { Location data found

- {gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)} + {gpsCoords.lat.toFixed(6)}, {gpsCoords.lon.toFixed(6)}

- ) : ( -

No GPS data in this image.

)} -
+
+ )} + + {/* Section 4: Keywords */} + {hasFile && ( + 0 ? `${form.keywords.length}` : undefined} + > +
+ {form.keywords.length > 0 && ( +
+ {form.keywords.map((kw) => ( + + {kw} + + + ))} +
+ )} +
+ setKeywordInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addKeyword(); + } + }} + placeholder="Add keyword and press Enter" + className="flex-1 px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+
+ + +
+
+
+ )} + + {/* Section 5: All Metadata (raw view) */} + {hasFile && inspectData && ( + +
+ {inspectData.exif && Object.keys(inspectData.exif).length > 0 && ( + !SKIP_KEYS.has(k)).length}`} + > + + + )} + {inspectData.iptc && Object.keys(inspectData.iptc).length > 0 && ( + + + + )} + {inspectData.xmp && Object.keys(inspectData.xmp).length > 0 && ( + + + + )} + {inspectData.gps && Object.keys(inspectData.gps).length > 0 && ( + + + + )} +
+
+ )} + + {/* Section 6: Templates */} + {hasFile && ( +
+

Templates

+ {templates.length > 0 && ( +
+ {templates.map((t) => ( +
+ + +
+ ))} +
+ )} +
+ setTemplateName(e.target.value)} + placeholder="Template name" + className="flex-1 px-2.5 py-1.5 rounded-md border border-input bg-background text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
)} + {/* No file placeholder */} {!hasFile && (
@@ -365,6 +804,18 @@ export function EditMetadataSettings() { {error &&

{error}

} + {/* Changes summary */} + {hasFile && changes.total > 0 && !processing && ( +
+ {changes.total} changes:{" "} + {changes.modified > 0 && `${changes.modified} modified`} + {changes.removed > 0 && `${changes.modified > 0 ? ", " : ""}${changes.removed} removed`} + {changes.gpsCleared && ", GPS cleared"} + {changes.gpsAdded && ", GPS added"} + {changes.hasShift && ", dates shifted"} +
+ )} + {originalSize != null && processedSize != null && (

Original: {(originalSize / 1024).toFixed(1)} KB

diff --git a/apps/web/src/components/tools/image-to-pdf-settings.tsx b/apps/web/src/components/tools/image-to-pdf-settings.tsx index 611649c2..f869f791 100644 --- a/apps/web/src/components/tools/image-to-pdf-settings.tsx +++ b/apps/web/src/components/tools/image-to-pdf-settings.tsx @@ -1,5 +1,7 @@ -import { Download, Loader2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Download } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { ProgressCard } from "@/components/common/progress-card"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; @@ -16,21 +18,14 @@ function PdfPagePreview({ pageSize, orientation, margin, - file, + imgUrl, }: { pageSize: string; orientation: "portrait" | "landscape"; margin: number; - file: File | null; + imgUrl: string | null; }) { const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null); - const imgUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]); - - useEffect(() => { - return () => { - if (imgUrl) URL.revokeObjectURL(imgUrl); - }; - }, [imgUrl]); useEffect(() => { if (!imgUrl) { @@ -39,6 +34,7 @@ function PdfPagePreview({ } const img = new Image(); img.onload = () => setImgSize({ w: img.naturalWidth, h: img.naturalHeight }); + img.onerror = () => setImgSize(null); img.src = imgUrl; }, [imgUrl]); @@ -110,45 +106,125 @@ function PdfPagePreview({ ); } export function ImageToPdfSettings() { - const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore(); + const { files, selectedIndex, entries, error, setProcessing, setError } = useFileStore(); const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4"); const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); const [margin, setMargin] = useState(20); const [downloadUrl, setDownloadUrl] = useState(null); - const handleProcess = async () => { + // Local processing state so the ProgressCard renders reliably + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState({ + phase: "idle" as "idle" | "uploading" | "processing" | "complete", + percent: 0, + elapsed: 0, + }); + const elapsedRef = useRef | null>(null); + const processingTimerRef = useRef | null>(null); + const xhrRef = useRef(null); + + useEffect(() => { + return () => { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (processingTimerRef.current) clearInterval(processingTimerRef.current); + if (xhrRef.current) xhrRef.current.abort(); + }; + }, []); + + const cleanup = () => { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (processingTimerRef.current) clearInterval(processingTimerRef.current); + elapsedRef.current = null; + processingTimerRef.current = null; + setBusy(false); + setProcessing(false); + }; + + const handleProcess = useCallback(() => { if (files.length === 0) return; - setProcessing(true); - setError(null); - setDownloadUrl(null); + // flushSync forces React to paint the ProgressCard before the XHR starts, + // so users always see feedback even if the request completes quickly. + flushSync(() => { + setBusy(true); + setProcessing(true); + setError(null); + setDownloadUrl(null); + setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); + }); - try { - const formData = new FormData(); - for (const file of files) { - formData.append("file", file); - } - formData.append("settings", JSON.stringify({ pageSize, orientation, margin })); + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) })); + }, 1000); - const res = await fetch("/api/v1/tools/image-to-pdf", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Failed: ${res.status}`); - } - - const result = await res.json(); - setDownloadUrl(result.downloadUrl); - } catch (err) { - setError(err instanceof Error ? err.message : "PDF creation failed"); - } finally { - setProcessing(false); + const formData = new FormData(); + for (const file of files) { + formData.append("file", file); } - }; + formData.append("settings", JSON.stringify({ pageSize, orientation, margin })); + + const xhr = new XMLHttpRequest(); + xhrRef.current = xhr; + xhr.timeout = 180_000; + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const uploadPercent = (event.loaded / event.total) * 40; + setProgress((prev) => + prev.phase === "uploading" ? { ...prev, percent: uploadPercent } : prev, + ); + } + }; + + xhr.upload.onload = () => { + setProgress((prev) => ({ ...prev, phase: "processing", percent: 40 })); + const step = (95 - 40) / 90; + processingTimerRef.current = setInterval(() => { + setProgress((prev) => { + if (prev.phase !== "processing") return prev; + return { ...prev, percent: Math.min(95, prev.percent + step) }; + }); + }, 500); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const result = JSON.parse(xhr.responseText); + setDownloadUrl(result.downloadUrl); + setProgress((prev) => ({ ...prev, phase: "complete", percent: 100 })); + } catch { + setError("Failed to parse server response"); + } + } else { + try { + const body = JSON.parse(xhr.responseText); + setError(body.error || `Failed: ${xhr.status}`); + } catch { + setError(`PDF creation failed: ${xhr.status}`); + } + } + cleanup(); + }; + + xhr.onerror = () => { + setError("Network error during PDF creation"); + cleanup(); + }; + + xhr.ontimeout = () => { + setError("Request timed out - the server may be overloaded"); + cleanup(); + }; + + xhr.open("POST", "/api/v1/tools/image-to-pdf"); + const headers = formatHeaders(); + for (const [key, value] of Object.entries(headers)) { + xhr.setRequestHeader(key, value as string); + } + xhr.send(formData); + }, [files, pageSize, orientation, margin, setProcessing, setError]); const hasFiles = files.length > 0; @@ -218,21 +294,35 @@ export function ImageToPdfSettings() { pageSize={pageSize} orientation={orientation} margin={margin} - file={files.length > 0 ? (files[selectedIndex] ?? files[0]) : null} + imgUrl={entries[selectedIndex]?.blobUrl ?? entries[0]?.blobUrl ?? null} /> {error &&

{error}

} - + {busy ? ( + + ) : ( + + )} {downloadUrl && ( (null); + const mapRef = useRef(null); + + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const map = L.map(containerRef.current, { + zoomControl: false, + attributionControl: false, + }).setView([lat, lon], zoom); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + }).addTo(map); + + L.circleMarker([lat, lon], { + radius: 7, + color: "#fff", + weight: 2, + fillColor: "#ef4444", + fillOpacity: 1, + }).addTo(map); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + }; + }, [lat, lon, zoom]); + + return ( +
+ ); +} + interface MetadataResult { filename: string; fileSize: number; @@ -58,7 +101,7 @@ export function StripMetadataControls({ return ( <> - {/* Strip All */} + {/* Remove All */}
@@ -256,13 +299,20 @@ export function StripMetadataSettings() { {metadata && hasAnyMetadata && (
- {/* GPS warning banner */} + {/* GPS warning banner + map */} {hasGps && gpsLat != null && gpsLon != null && ( -
- - - Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)} - +
+
+ + + Location data: {gpsLat.toFixed(6)}, {gpsLon.toFixed(6)} + +
+ +

+ This image contains your precise location. Consider removing GPS data before + sharing. +

)} @@ -342,7 +392,7 @@ export function StripMetadataSettings() { - Strip Metadata + Remove Metadata )} diff --git a/apps/web/src/lib/metadata-utils.ts b/apps/web/src/lib/metadata-utils.ts index 93db39c9..0c944452 100644 --- a/apps/web/src/lib/metadata-utils.ts +++ b/apps/web/src/lib/metadata-utils.ts @@ -4,13 +4,18 @@ export const EXIF_LABELS: Record = { Model: "Camera Model", Software: "Software", DateTime: "Date/Time", + ModifyDate: "Date Modified", DateTimeOriginal: "Date Taken", + CreateDate: "Date Created", DateTimeDigitized: "Date Digitized", ExposureTime: "Exposure Time", FNumber: "F-Number", + ISO: "ISO", ISOSpeedRatings: "ISO", FocalLength: "Focal Length", + FocalLengthIn35mmFormat: "Focal Length (35mm)", FocalLengthIn35mmFilm: "Focal Length (35mm)", + ExposureCompensation: "Exposure Bias", ExposureBiasValue: "Exposure Bias", MeteringMode: "Metering Mode", Flash: "Flash", @@ -22,12 +27,15 @@ export const EXIF_LABELS: Record = { Sharpness: "Sharpness", DigitalZoomRatio: "Digital Zoom", ImageWidth: "Width", + ImageHeight: "Height", ImageLength: "Height", Orientation: "Orientation", XResolution: "X Resolution", YResolution: "Y Resolution", ResolutionUnit: "Resolution Unit", ColorSpace: "Color Space", + ExifImageWidth: "Pixel Width", + ExifImageHeight: "Pixel Height", PixelXDimension: "Pixel Width", PixelYDimension: "Pixel Height", Artist: "Artist", @@ -35,39 +43,48 @@ export const EXIF_LABELS: Record = { ImageDescription: "Description", LensMake: "Lens Make", LensModel: "Lens Model", + LensInfo: "Lens Info", BodySerialNumber: "Body Serial", CameraOwnerName: "Camera Owner", + // IPTC + ObjectName: "Title", + Headline: "Headline", + Keywords: "Keywords", + City: "City", + "Province-State": "State/Province", + "Country-PrimaryLocationName": "Country", + CopyrightNotice: "Copyright Notice", + "By-line": "Creator", + Caption: "Caption", + // XMP + Subject: "Subject/Keywords", + Title: "Title", + Description: "Description", + Creator: "Creator", + Rights: "Rights", }; /** Keys to skip in display (internal/binary/redundant) */ export const SKIP_KEYS = new Set([ - "ExifTag", - "GPSTag", - "InteroperabilityTag", + "ExifToolVersion", + "FileName", + "Directory", + "FileSize", + "FileModifyDate", + "FileAccessDate", + "FileInodeChangeDate", + "FilePermissions", + "FileType", + "FileTypeExtension", + "MIMEType", + "SourceFile", + "ExifByteOrder", + "ThumbnailImage", + "ThumbnailOffset", + "ThumbnailLength", + "PreviewImage", "MakerNote", "PrintImageMatching", - "ComponentsConfiguration", - "FlashpixVersion", - "ExifVersion", - "FileSource", - "SceneType", - "UserComment", - "InteroperabilityIndex", - "InteroperabilityVersion", -]); - -/** Keys that are binary/complex and NOT safe for EXIF round-trip via withExif() */ -export const UNSAFE_ROUND_TRIP_KEYS = new Set([ - "MakerNote", - "PrintImageMatching", - "ComponentsConfiguration", - "FlashpixVersion", - "ExifVersion", - "FileSource", - "SceneType", - "UserComment", - "InteroperabilityIndex", - "InteroperabilityVersion", ]); export function formatExifValue(key: string, value: unknown): string { @@ -78,13 +95,12 @@ export function formatExifValue(key: string, value: unknown): string { return `1/${Math.round(1 / value)}s`; } if (key === "FNumber") return `f/${value}`; - if (key === "FocalLength") return `${value}mm`; - if (key === "FocalLengthIn35mmFilm") return `${value}mm`; + if (key === "FocalLength" || key === "FocalLengthIn35mmFormat") return `${value}mm`; return String(value); } if (Array.isArray(value)) { - if (typeof value[0] === "number" && value.length <= 4) { - return value.join(", "); + if (value.length <= 6) { + return value.map(String).join(", "); } return `[${value.length} values]`; } diff --git a/docker/Dockerfile b/docker/Dockerfile index f857a2a8..49b3f82d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -88,6 +88,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gosu \ libheif-examples \ + libimage-exiftool-perl \ python3 python3-pip python3-venv python3-dev \ tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \ build-essential \ diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 8f660748..0d9049e1 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -73,6 +73,18 @@ export interface EditMetadataOptions { 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; } export interface BrightnessOptions { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d4dcb211..f30fdc64 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -57,7 +57,7 @@ export const TOOLS: Tool[] = [ // Optimization { id: "strip-metadata", - name: "Strip Metadata", + name: "Remove Metadata", description: "Remove EXIF, GPS, and camera info", category: "optimization", icon: "ShieldOff", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 55edada9..37af1cfa 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -35,8 +35,11 @@ export const en = { rotate: { name: "Rotate & Flip", description: "Rotate, flip, and straighten images" }, convert: { name: "Convert", description: "Convert between image formats" }, compress: { name: "Compress", description: "Reduce file size by quality or target size" }, - "strip-metadata": { name: "Strip Metadata", description: "Remove EXIF, GPS, and camera info" }, - "edit-metadata": { name: "Edit Metadata", description: "Edit EXIF, GPS, and camera info" }, + "strip-metadata": { name: "Remove Metadata", description: "Remove EXIF, GPS, and camera info" }, + "edit-metadata": { + name: "Edit Metadata", + description: "Edit EXIF, IPTC, XMP, GPS, keywords, and dates", + }, "bulk-rename": { name: "Bulk Rename", description: "Rename multiple files with patterns" }, "image-to-pdf": { name: "Image to PDF", description: "Combine images into a PDF document" }, "pdf-to-image": { name: "PDF to Image", description: "Convert PDF pages to images" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edf3e730..61e447f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: fflate: specifier: ^0.8.2 version: 0.8.2 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.4) @@ -226,6 +229,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -2489,12 +2495,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -4007,6 +4019,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + leven@4.1.0: resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7888,12 +7903,18 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 '@types/js-yaml@4.0.9': {} + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -9476,6 +9497,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + leaflet@1.9.4: {} + leven@4.1.0: {} light-my-request@6.6.0: