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:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,18 +28,24 @@ export function ImageViewer({
|
||||
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
|
||||
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
|
||||
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(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 ? (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={filename}
|
||||
onLoad={handleImageLoad}
|
||||
className="select-none"
|
||||
style={imageStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
{loadError ? (
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">Preview not available</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
This format cannot be displayed in the browser
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={filename}
|
||||
onLoad={handleImageLoad}
|
||||
className="select-none rounded-sm"
|
||||
onError={handleImageError}
|
||||
className={`select-none${isSvg ? "" : " rounded-sm"}`}
|
||||
style={imageStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div key={k} className="contents">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { AlertTriangle, Download, Loader2, MapPin, PenLine } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
Loader2,
|
||||
MapPin,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||
import { MetadataGrid } from "@/components/common/metadata-grid";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
@@ -12,9 +21,10 @@ interface InspectResult {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
exif?: Record<string, unknown> | null;
|
||||
exifError?: string;
|
||||
iptc?: Record<string, unknown> | null;
|
||||
xmp?: Record<string, unknown> | null;
|
||||
gps?: Record<string, unknown> | null;
|
||||
xmp?: Record<string, string> | 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<FormFields>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
@@ -59,11 +119,12 @@ function LabeledInput({
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => 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 && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
@@ -82,6 +143,9 @@ export function EditMetadataSettings() {
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||
const [inspectCache, setInspectCache] = useState<Map<string, InspectResult>>(new Map());
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [templates, setTemplates] = useState<Template[]>(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<string, unknown> = { clearGps: form.clearGps };
|
||||
const settings: Record<string, unknown> = {};
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Current Metadata */}
|
||||
{hasFile && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
|
||||
|
||||
{inspecting && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Reading metadata...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inspectError && !inspecting && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-1">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
Could not read metadata - fields will start empty.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inspectData && (
|
||||
<div className="space-y-1.5">
|
||||
{exifEntryCount > 0 && inspectData.exif ? (
|
||||
<CollapsibleSection title="EXIF" badge={`${exifEntryCount} fields`}>
|
||||
<MetadataGrid
|
||||
data={inspectData.exif}
|
||||
labelMap={EXIF_LABELS}
|
||||
onRemove={toggleRemoveField}
|
||||
removedKeys={fieldsToRemove}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground italic">No EXIF data found.</p>
|
||||
)}
|
||||
{hasGps && inspectData.gps && (
|
||||
<CollapsibleSection title="GPS" warning>
|
||||
<MetadataGrid
|
||||
data={Object.fromEntries(
|
||||
Object.entries(inspectData.gps).filter(([k]) => !k.startsWith("_")),
|
||||
)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Inspect status */}
|
||||
{hasFile && inspecting && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Reading metadata...
|
||||
</div>
|
||||
)}
|
||||
{hasFile && inspectError && !inspecting && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-1">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
Could not read metadata - fields will start empty.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Fields */}
|
||||
{/* Section 1: Basic Info */}
|
||||
{hasFile && (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-border" />
|
||||
<p className="text-xs font-medium text-muted-foreground">Edit Fields</p>
|
||||
<CollapsibleSection
|
||||
title="Basic Info"
|
||||
defaultOpen
|
||||
badge={inspectData ? "EXIF/IPTC" : undefined}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
<LabeledInput
|
||||
id="em-description"
|
||||
label="Description"
|
||||
value={form.imageDescription}
|
||||
onChange={(v) => setField("imageDescription", v)}
|
||||
placeholder="Image description"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-artist"
|
||||
label="Artist"
|
||||
value={form.artist}
|
||||
onChange={(v) => setField("artist", v)}
|
||||
placeholder="Photographer / creator name"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-copyright"
|
||||
label="Copyright"
|
||||
value={form.copyright}
|
||||
onChange={(v) => setField("copyright", v)}
|
||||
placeholder="2026 Example Corp"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-software"
|
||||
label="Software"
|
||||
value={form.software}
|
||||
onChange={(v) => setField("software", v)}
|
||||
placeholder="e.g. Lightroom, Photoshop"
|
||||
/>
|
||||
<div className="border-t border-border pt-2 mt-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground mb-2">IPTC</p>
|
||||
<div className="space-y-2.5">
|
||||
<LabeledInput
|
||||
id="em-iptc-title"
|
||||
label="Title"
|
||||
value={form.iptcTitle}
|
||||
onChange={(v) => setField("iptcTitle", v)}
|
||||
placeholder="Image title"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-iptc-headline"
|
||||
label="Headline"
|
||||
value={form.iptcHeadline}
|
||||
onChange={(v) => setField("iptcHeadline", v)}
|
||||
placeholder="Short headline"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-iptc-city"
|
||||
label="City"
|
||||
value={form.iptcCity}
|
||||
onChange={(v) => setField("iptcCity", v)}
|
||||
placeholder="City name"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-iptc-state"
|
||||
label="State/Province"
|
||||
value={form.iptcState}
|
||||
onChange={(v) => setField("iptcState", v)}
|
||||
placeholder="State or province"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-iptc-country"
|
||||
label="Country"
|
||||
value={form.iptcCountry}
|
||||
onChange={(v) => setField("iptcCountry", v)}
|
||||
placeholder="Country name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
<LabeledInput
|
||||
id="em-description"
|
||||
label="Description"
|
||||
value={form.imageDescription}
|
||||
onChange={(v) => setField("imageDescription", v)}
|
||||
placeholder="Image description"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-artist"
|
||||
label="Artist"
|
||||
value={form.artist}
|
||||
onChange={(v) => setField("artist", v)}
|
||||
placeholder="Photographer / creator name"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-copyright"
|
||||
label="Copyright"
|
||||
value={form.copyright}
|
||||
onChange={(v) => setField("copyright", v)}
|
||||
placeholder="2026 Example"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-software"
|
||||
label="Software"
|
||||
value={form.software}
|
||||
onChange={(v) => setField("software", v)}
|
||||
placeholder="e.g. Lightroom, Photoshop"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-datetime"
|
||||
label="Date Modified"
|
||||
value={form.dateTime}
|
||||
onChange={(v) => setField("dateTime", v)}
|
||||
placeholder="YYYY:MM:DD HH:MM:SS"
|
||||
hint="EXIF date format: 2026:04:06 12:00:00"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-datetime-original"
|
||||
label="Date Taken"
|
||||
value={form.dateTimeOriginal}
|
||||
onChange={(v) => setField("dateTimeOriginal", v)}
|
||||
placeholder="YYYY:MM:DD HH:MM:SS"
|
||||
/>
|
||||
{/* Section 2: Date & Time */}
|
||||
{hasFile && (
|
||||
<CollapsibleSection title="Date & Time">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField("dateMode", "edit")}
|
||||
className={`flex-1 text-xs py-1.5 rounded-md border ${form.dateMode === "edit" ? "bg-primary text-primary-foreground border-primary" : "border-input text-foreground"}`}
|
||||
>
|
||||
Edit Dates
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField("dateMode", "shift")}
|
||||
className={`flex-1 text-xs py-1.5 rounded-md border ${form.dateMode === "shift" ? "bg-primary text-primary-foreground border-primary" : "border-input text-foreground"}`}
|
||||
>
|
||||
Shift All Dates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GPS */}
|
||||
<div className="space-y-2">
|
||||
<div className="border-t border-border" />
|
||||
{gpsCoords ? (
|
||||
{form.dateMode === "edit" ? (
|
||||
<>
|
||||
<LabeledInput
|
||||
id="em-datetime"
|
||||
label="Date Modified"
|
||||
value={form.dateTime}
|
||||
onChange={(v) => setField("dateTime", v)}
|
||||
placeholder="YYYY:MM:DD HH:MM:SS"
|
||||
hint="EXIF format: 2026:04:11 12:00:00"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-datetime-original"
|
||||
label="Date Taken"
|
||||
value={form.dateTimeOriginal}
|
||||
onChange={(v) => setField("dateTimeOriginal", v)}
|
||||
placeholder="YYYY:MM:DD HH:MM:SS"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Shift all date fields by an offset (useful for timezone corrections)
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Direction</label>
|
||||
<select
|
||||
value={form.dateShiftDirection}
|
||||
onChange={(e) => setField("dateShiftDirection", e.target.value as "+" | "-")}
|
||||
className="px-2.5 py-1.5 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
<option value="+">+ Forward</option>
|
||||
<option value="-">- Backward</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<LabeledInput
|
||||
id="em-date-shift"
|
||||
label="Hours:Minutes"
|
||||
value={form.dateShiftValue}
|
||||
onChange={(v) => setField("dateShiftValue", v)}
|
||||
placeholder="1:30"
|
||||
hint="e.g. 1:30 for 1 hour 30 minutes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Section 3: Location (GPS) */}
|
||||
{hasFile && (
|
||||
<CollapsibleSection title="Location (GPS)" warning={!!gpsCoords}>
|
||||
<div className="space-y-2.5">
|
||||
{gpsCoords && (
|
||||
<div className="flex items-start gap-2 px-2.5 py-2 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||
<MapPin className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -336,26 +575,226 @@ export function EditMetadataSettings() {
|
||||
Location data found
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)}
|
||||
{gpsCoords.lat.toFixed(6)}, {gpsCoords.lon.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground italic">No GPS data in this image.</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
{!gpsCoords && (
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
No GPS data. Add coordinates below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LabeledInput
|
||||
id="em-gps-lat"
|
||||
label="Latitude"
|
||||
type="number"
|
||||
value={form.gpsLatitude}
|
||||
onChange={(v) => setField("gpsLatitude", v)}
|
||||
placeholder="-90 to 90 (e.g. 51.5074)"
|
||||
disabled={form.clearGps}
|
||||
hint="Decimal degrees. Negative = South"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-gps-lon"
|
||||
label="Longitude"
|
||||
type="number"
|
||||
value={form.gpsLongitude}
|
||||
onChange={(v) => setField("gpsLongitude", v)}
|
||||
placeholder="-180 to 180 (e.g. -0.1278)"
|
||||
disabled={form.clearGps}
|
||||
hint="Decimal degrees. Negative = West"
|
||||
/>
|
||||
<LabeledInput
|
||||
id="em-gps-alt"
|
||||
label="Altitude (meters)"
|
||||
type="number"
|
||||
value={form.gpsAltitude}
|
||||
onChange={(v) => setField("gpsAltitude", v)}
|
||||
placeholder="Optional (e.g. 25)"
|
||||
disabled={form.clearGps}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-foreground pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.clearGps}
|
||||
onChange={(e) => setField("clearGps", e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Remove GPS location data
|
||||
Remove all GPS data
|
||||
</label>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Section 4: Keywords */}
|
||||
{hasFile && (
|
||||
<CollapsibleSection
|
||||
title="Keywords"
|
||||
badge={form.keywords.length > 0 ? `${form.keywords.length}` : undefined}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{form.keywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{form.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-[11px]"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(kw)}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={keywordInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyword}
|
||||
className="px-2 py-1.5 rounded-md border border-input hover:bg-muted/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="kw-mode"
|
||||
checked={form.keywordsMode === "add"}
|
||||
onChange={() => setField("keywordsMode", "add")}
|
||||
/>
|
||||
Add to existing
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="kw-mode"
|
||||
checked={form.keywordsMode === "set"}
|
||||
onChange={() => setField("keywordsMode", "set")}
|
||||
/>
|
||||
Replace all
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Section 5: All Metadata (raw view) */}
|
||||
{hasFile && inspectData && (
|
||||
<CollapsibleSection title="All Metadata">
|
||||
<div className="space-y-2">
|
||||
{inspectData.exif && Object.keys(inspectData.exif).length > 0 && (
|
||||
<CollapsibleSection
|
||||
title="EXIF"
|
||||
badge={`${Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k)).length}`}
|
||||
>
|
||||
<MetadataGrid
|
||||
data={inspectData.exif}
|
||||
labelMap={EXIF_LABELS}
|
||||
onRemove={toggleRemoveField}
|
||||
removedKeys={fieldsToRemove}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
{inspectData.iptc && Object.keys(inspectData.iptc).length > 0 && (
|
||||
<CollapsibleSection title="IPTC" badge={`${Object.keys(inspectData.iptc).length}`}>
|
||||
<MetadataGrid
|
||||
data={inspectData.iptc}
|
||||
labelMap={EXIF_LABELS}
|
||||
onRemove={toggleRemoveField}
|
||||
removedKeys={fieldsToRemove}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
{inspectData.xmp && Object.keys(inspectData.xmp).length > 0 && (
|
||||
<CollapsibleSection title="XMP" badge={`${Object.keys(inspectData.xmp).length}`}>
|
||||
<MetadataGrid
|
||||
data={inspectData.xmp}
|
||||
labelMap={EXIF_LABELS}
|
||||
onRemove={toggleRemoveField}
|
||||
removedKeys={fieldsToRemove}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
{inspectData.gps && Object.keys(inspectData.gps).length > 0 && (
|
||||
<CollapsibleSection title="GPS">
|
||||
<MetadataGrid data={inspectData.gps} labelMap={EXIF_LABELS} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Section 6: Templates */}
|
||||
{hasFile && (
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Templates</p>
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{templates.map((t) => (
|
||||
<div key={t.name} className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadTemplate(t.name)}
|
||||
className="flex-1 text-left text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate"
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTemplate(t.name)}
|
||||
className="p-1 text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTemplate}
|
||||
disabled={!templateName.trim()}
|
||||
className="px-2 py-1.5 rounded-md border border-input hover:bg-muted/50 disabled:opacity-50"
|
||||
title="Save current values as template"
|
||||
>
|
||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No file placeholder */}
|
||||
{!hasFile && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center text-muted-foreground">
|
||||
<PenLine className="h-8 w-8 opacity-30" />
|
||||
@@ -365,6 +804,18 @@ export function EditMetadataSettings() {
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Changes summary */}
|
||||
{hasFile && changes.total > 0 && !processing && (
|
||||
<div className="text-[11px] text-muted-foreground bg-muted/30 px-2.5 py-2 rounded-md">
|
||||
<span className="font-medium text-foreground">{changes.total} changes:</span>{" "}
|
||||
{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"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
|
||||
@@ -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<string | null>(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<ReturnType<typeof setInterval> | null>(null);
|
||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(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 && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="image-to-pdf-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Creating PDF..." : `Create PDF (${files.length} pages)`}
|
||||
</button>
|
||||
{busy ? (
|
||||
<ProgressCard
|
||||
active={busy}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Creating PDF"
|
||||
stage={
|
||||
progress.phase === "uploading"
|
||||
? "Uploading images..."
|
||||
: `Processing ${files.length} pages...`
|
||||
}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="image-to-pdf-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || busy}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Create PDF ({files.length} pages)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Download, Loader2, MapPin } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||
@@ -8,6 +10,47 @@ import { formatHeaders } from "@/lib/api";
|
||||
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
/** Interactive Leaflet map with a red circle marker. */
|
||||
function MiniMap({ lat, lon, zoom = 15 }: { lat: number; lon: number; zoom?: number }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-36 rounded-md overflow-hidden border border-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataResult {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
@@ -58,7 +101,7 @@ export function StripMetadataControls({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Strip All */}
|
||||
{/* Remove All */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -66,7 +109,7 @@ export function StripMetadataControls({
|
||||
onChange={(e) => handleStripAllChange(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip All Metadata
|
||||
Remove All Metadata
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
@@ -256,13 +299,20 @@ export function StripMetadataSettings() {
|
||||
|
||||
{metadata && hasAnyMetadata && (
|
||||
<div className="space-y-1.5">
|
||||
{/* GPS warning banner */}
|
||||
{/* GPS warning banner + map */}
|
||||
{hasGps && gpsLat != null && gpsLon != null && (
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||
Location data: {gpsLat.toFixed(6)}, {gpsLon.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniMap lat={gpsLat} lon={gpsLon} />
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
This image contains your precise location. Consider removing GPS data before
|
||||
sharing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -342,7 +392,7 @@ export function StripMetadataSettings() {
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Stripping metadata"
|
||||
label="Removing metadata"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
@@ -354,7 +404,7 @@ export function StripMetadataSettings() {
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Strip Metadata
|
||||
Remove Metadata
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,13 +4,18 @@ export const EXIF_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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]`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user