mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #22 from stirling-image/feat/edit-metadata
feat: add Edit Metadata tool
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { basename } from "node:path";
|
||||
import { editMetadata, parseExif, parseGps, parseXmp } from "@stirling-image/image-engine";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
artist: z.string().optional(),
|
||||
copyright: z.string().optional(),
|
||||
imageDescription: z.string().optional(),
|
||||
software: z.string().optional(),
|
||||
dateTime: z.string().optional(),
|
||||
dateTimeOriginal: z.string().optional(),
|
||||
clearGps: z.boolean().default(false),
|
||||
fieldsToRemove: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export function registerEditMetadata(app: FastifyInstance) {
|
||||
// Inspect endpoint - returns parsed metadata as JSON
|
||||
app.post(
|
||||
"/api/v1/tools/edit-metadata/inspect",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
} 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" });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send(result);
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to read image metadata",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Edit endpoint - writes metadata and returns updated image
|
||||
createToolRoute(app, {
|
||||
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);
|
||||
|
||||
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> = {
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
tiff: "image/tiff",
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
return {
|
||||
buffer,
|
||||
filename: outFilename,
|
||||
contentType: mimeMap[format] ?? "image/jpeg",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { registerCompose } from "./compose.js";
|
||||
import { registerCompress } from "./compress.js";
|
||||
import { registerConvert } from "./convert.js";
|
||||
import { registerCrop } from "./crop.js";
|
||||
import { registerEditMetadata } from "./edit-metadata.js";
|
||||
import { registerEraseObject } from "./erase-object.js";
|
||||
import { registerFavicon } from "./favicon.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
@@ -81,6 +82,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "convert", register: registerConvert },
|
||||
{ id: "compress", register: registerCompress },
|
||||
{ id: "strip-metadata", register: registerStripMetadata },
|
||||
{ id: "edit-metadata", register: registerEditMetadata },
|
||||
{ id: "color-adjustments", register: registerColorAdjustments },
|
||||
|
||||
// Watermark & Overlay
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import { stripMetadata } from "@stirling-image/image-engine";
|
||||
import exifReader from "exif-reader";
|
||||
import { parseExif, parseGps, parseXmp, stripMetadata } from "@stirling-image/image-engine";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
@@ -14,76 +13,6 @@ const settingsSchema = z.object({
|
||||
stripAll: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize a value for JSON — convert Buffers/Dates and drop overly large blobs.
|
||||
*/
|
||||
function sanitizeValue(v: unknown): unknown {
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (Buffer.isBuffer(v)) {
|
||||
if (v.length > 256) return `<binary ${v.length} bytes>`;
|
||||
return Array.from(v);
|
||||
}
|
||||
if (Array.isArray(v)) return v.map(sanitizeValue);
|
||||
if (v !== null && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
out[k] = sanitizeValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GPS coordinates from EXIF GPSInfo into decimal degrees.
|
||||
*/
|
||||
function parseGpsCoordinates(gps: Record<string, unknown>): {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
altitude: number | null;
|
||||
} {
|
||||
let latitude: number | null = null;
|
||||
let longitude: number | null = null;
|
||||
let altitude: number | null = null;
|
||||
|
||||
const lat = gps.GPSLatitude as number[] | undefined;
|
||||
const latRef = gps.GPSLatitudeRef as string | undefined;
|
||||
if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||
if (latRef === "S") latitude = -latitude;
|
||||
}
|
||||
|
||||
const lon = gps.GPSLongitude as number[] | undefined;
|
||||
const lonRef = gps.GPSLongitudeRef as string | undefined;
|
||||
if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||
if (lonRef === "W") longitude = -longitude;
|
||||
}
|
||||
|
||||
if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) {
|
||||
altitude = gps.GPSAltitude;
|
||||
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
|
||||
}
|
||||
|
||||
return { latitude, longitude, altitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XMP XML buffer into key-value pairs.
|
||||
*/
|
||||
function parseXmp(xmpBuffer: Buffer): Record<string, string> {
|
||||
const xml = xmpBuffer.toString("utf-8");
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) {
|
||||
const key = match[1];
|
||||
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
|
||||
result[key] = match[2];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ICC profile buffer into basic info.
|
||||
*/
|
||||
@@ -206,33 +135,16 @@ export function registerStripMetadata(app: FastifyInstance) {
|
||||
// Parse EXIF
|
||||
if (metadata.exif) {
|
||||
try {
|
||||
const parsed = exifReader(metadata.exif);
|
||||
const exifData: Record<string, unknown> = {};
|
||||
const gpsData: Record<string, unknown> = {};
|
||||
const parsed = parseExif(metadata.exif);
|
||||
const exifData: Record<string, unknown> = {
|
||||
...parsed.image,
|
||||
...parsed.photo,
|
||||
...parsed.iop,
|
||||
};
|
||||
const gpsData: Record<string, unknown> = { ...parsed.gps };
|
||||
|
||||
if (parsed.Image) {
|
||||
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.Photo) {
|
||||
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.Iop) {
|
||||
for (const [k, v] of Object.entries(parsed.Iop)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.GPSInfo) {
|
||||
for (const [k, v] of Object.entries(parsed.GPSInfo)) {
|
||||
gpsData[k] = sanitizeValue(v);
|
||||
}
|
||||
const coords = parseGpsCoordinates(parsed.GPSInfo as Record<string, unknown>);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
badge,
|
||||
warning,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: string;
|
||||
warning?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-3 pb-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { formatExifValue, SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS } from "@/lib/metadata-utils";
|
||||
|
||||
export function MetadataGrid({
|
||||
data,
|
||||
labelMap,
|
||||
onRemove,
|
||||
removedKeys,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
labelMap?: Record<string, string>;
|
||||
onRemove?: (key: string) => void;
|
||||
removedKeys?: Set<string>;
|
||||
}) {
|
||||
const entries = Object.entries(data).filter(
|
||||
([k, v]) =>
|
||||
!SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "",
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
||||
}
|
||||
|
||||
const hasRemoveColumn = !!onRemove;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid ${hasRemoveColumn ? "grid-cols-[minmax(0,2fr)_minmax(0,3fr)_auto]" : "grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"} gap-x-2 gap-y-0.5`}
|
||||
>
|
||||
{entries.map(([k, v]) => {
|
||||
const isRemoved = removedKeys?.has(k);
|
||||
const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k);
|
||||
return (
|
||||
<div key={k} className="contents">
|
||||
<div
|
||||
className={`text-[10px] text-muted-foreground truncate ${isRemoved ? "line-through opacity-50" : ""}`}
|
||||
title={k}
|
||||
>
|
||||
{labelMap?.[k] ?? k}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[10px] text-foreground font-mono truncate ${isRemoved ? "line-through opacity-50" : ""}`}
|
||||
title={formatExifValue(k, v)}
|
||||
>
|
||||
{formatExifValue(k, v)}
|
||||
</div>
|
||||
{hasRemoveColumn && (
|
||||
<div className="flex items-center">
|
||||
{canRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(k)}
|
||||
className={`p-0.5 rounded hover:bg-muted/50 transition-colors ${isRemoved ? "text-red-500" : "text-muted-foreground hover:text-red-500"}`}
|
||||
title={
|
||||
isRemoved ? `Restore ${labelMap?.[k] ?? k}` : `Remove ${labelMap?.[k] ?? k}`
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { AlertTriangle, Download, Loader2, MapPin, PenLine } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||
import { MetadataGrid } from "@/components/common/metadata-grid";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { EXIF_LABELS, exifStr, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
interface InspectResult {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
exif?: Record<string, unknown> | null;
|
||||
exifError?: string;
|
||||
gps?: Record<string, unknown> | null;
|
||||
xmp?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
artist: string;
|
||||
copyright: string;
|
||||
imageDescription: string;
|
||||
software: string;
|
||||
dateTime: string;
|
||||
dateTimeOriginal: string;
|
||||
clearGps: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormFields = {
|
||||
artist: "",
|
||||
copyright: "",
|
||||
imageDescription: "",
|
||||
software: "",
|
||||
dateTime: "",
|
||||
dateTimeOriginal: "",
|
||||
clearGps: false,
|
||||
};
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("edit-metadata");
|
||||
|
||||
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
|
||||
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
|
||||
const [fieldsToRemove, setFieldsToRemove] = useState<Set<string>>(new Set());
|
||||
const [inspectData, setInspectData] = useState<InspectResult | null>(null);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||
const [inspectCache, setInspectCache] = useState<Map<string, InspectResult>>(new Map());
|
||||
|
||||
const currentFile = entries[selectedIndex]?.file ?? null;
|
||||
const fileKey = currentFile
|
||||
? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`
|
||||
: null;
|
||||
|
||||
const populateForm = useCallback((data: InspectResult) => {
|
||||
const exif = data.exif ?? {};
|
||||
setInspectData(data);
|
||||
const populated: FormFields = {
|
||||
artist: exifStr(exif, "Artist"),
|
||||
copyright: exifStr(exif, "Copyright"),
|
||||
imageDescription: exifStr(exif, "ImageDescription"),
|
||||
software: exifStr(exif, "Software"),
|
||||
dateTime: exifStr(exif, "DateTime"),
|
||||
dateTimeOriginal: exifStr(exif, "DateTimeOriginal"),
|
||||
clearGps: false,
|
||||
};
|
||||
setForm(populated);
|
||||
setInitialForm(populated);
|
||||
setFieldsToRemove(new Set());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFile || !fileKey) {
|
||||
setForm(EMPTY_FORM);
|
||||
setInitialForm(EMPTY_FORM);
|
||||
setInspectData(null);
|
||||
setInspectError(null);
|
||||
setFieldsToRemove(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = inspectCache.get(fileKey);
|
||||
if (cached) {
|
||||
populateForm(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
setInspecting(true);
|
||||
setInspectError(null);
|
||||
setInspectData(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", currentFile);
|
||||
const res = await fetch("/api/v1/tools/edit-metadata/inspect", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
const data: InspectResult = await res.json();
|
||||
setInspectCache((prev) => new Map(prev).set(fileKey, data));
|
||||
populateForm(data);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setInspectError(err instanceof Error ? err.message : "Failed to inspect file");
|
||||
setForm(EMPTY_FORM);
|
||||
setInitialForm(EMPTY_FORM);
|
||||
} finally {
|
||||
setInspecting(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [currentFile, fileKey, inspectCache, populateForm]);
|
||||
|
||||
const setField = <K extends keyof FormFields>(key: K, value: FormFields[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const toggleRemoveField = (key: string) => {
|
||||
setFieldsToRemove((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const gpsLat = inspectData?.gps?._latitude as number | undefined;
|
||||
const gpsLon = inspectData?.gps?._longitude 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 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",
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removeSet.size > 0) {
|
||||
settings.fieldsToRemove = Array.from(removeSet);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Fields */}
|
||||
{hasFile && (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-border" />
|
||||
<p className="text-xs font-medium text-muted-foreground">Edit Fields</p>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* GPS */}
|
||||
<div className="space-y-2">
|
||||
<div className="border-t border-border" />
|
||||
{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">
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
|
||||
Location data found
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)}
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.clearGps}
|
||||
onChange={(e) => setField("clearGps", e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Remove GPS location data
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-sm">Upload an image to edit its metadata.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Writing metadata"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="edit-metadata-submit"
|
||||
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"
|
||||
>
|
||||
Apply Metadata
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="edit-metadata-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } from "lucide-react";
|
||||
import { Download, Loader2, MapPin } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||
import { MetadataGrid } from "@/components/common/metadata-grid";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
interface MetadataResult {
|
||||
@@ -15,160 +18,6 @@ interface MetadataResult {
|
||||
xmp?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
/** Human-friendly labels for common EXIF keys */
|
||||
const EXIF_LABELS: Record<string, string> = {
|
||||
Make: "Camera Make",
|
||||
Model: "Camera Model",
|
||||
Software: "Software",
|
||||
DateTime: "Date/Time",
|
||||
DateTimeOriginal: "Date Taken",
|
||||
DateTimeDigitized: "Date Digitized",
|
||||
ExposureTime: "Exposure Time",
|
||||
FNumber: "F-Number",
|
||||
ISOSpeedRatings: "ISO",
|
||||
FocalLength: "Focal Length",
|
||||
FocalLengthIn35mmFilm: "Focal Length (35mm)",
|
||||
ExposureBiasValue: "Exposure Bias",
|
||||
MeteringMode: "Metering Mode",
|
||||
Flash: "Flash",
|
||||
WhiteBalance: "White Balance",
|
||||
ExposureMode: "Exposure Mode",
|
||||
SceneCaptureType: "Scene Type",
|
||||
Contrast: "Contrast",
|
||||
Saturation: "Saturation",
|
||||
Sharpness: "Sharpness",
|
||||
DigitalZoomRatio: "Digital Zoom",
|
||||
ImageWidth: "Width",
|
||||
ImageLength: "Height",
|
||||
Orientation: "Orientation",
|
||||
XResolution: "X Resolution",
|
||||
YResolution: "Y Resolution",
|
||||
ResolutionUnit: "Resolution Unit",
|
||||
ColorSpace: "Color Space",
|
||||
PixelXDimension: "Pixel Width",
|
||||
PixelYDimension: "Pixel Height",
|
||||
Artist: "Artist",
|
||||
Copyright: "Copyright",
|
||||
ImageDescription: "Description",
|
||||
LensMake: "Lens Make",
|
||||
LensModel: "Lens Model",
|
||||
BodySerialNumber: "Body Serial",
|
||||
CameraOwnerName: "Camera Owner",
|
||||
};
|
||||
|
||||
/** Keys to skip in display (internal/binary/redundant) */
|
||||
const SKIP_KEYS = new Set([
|
||||
"ExifTag",
|
||||
"GPSTag",
|
||||
"InteroperabilityTag",
|
||||
"MakerNote",
|
||||
"PrintImageMatching",
|
||||
"ComponentsConfiguration",
|
||||
"FlashpixVersion",
|
||||
"ExifVersion",
|
||||
"FileSource",
|
||||
"SceneType",
|
||||
"UserComment",
|
||||
"InteroperabilityIndex",
|
||||
"InteroperabilityVersion",
|
||||
]);
|
||||
|
||||
function formatExifValue(key: string, value: unknown): string {
|
||||
if (value === null || value === undefined) return "N/A";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") {
|
||||
if (key === "ExposureTime" && value > 0 && value < 1) {
|
||||
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`;
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (typeof value[0] === "number" && value.length <= 4) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return `[${value.length} values]`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
badge,
|
||||
warning,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: string;
|
||||
warning?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-3 pb-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataGrid({
|
||||
data,
|
||||
labelMap,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
labelMap?: Record<string, string>;
|
||||
}) {
|
||||
const entries = Object.entries(data).filter(
|
||||
([k, v]) =>
|
||||
!SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "",
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={k}>
|
||||
{labelMap?.[k] ?? k}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] text-foreground font-mono truncate"
|
||||
title={formatExifValue(k, v)}
|
||||
>
|
||||
{formatExifValue(k, v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StripMetadataControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
/** Passed from parent to preserve field-count badges in checkbox labels */
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Human-friendly labels for common EXIF keys */
|
||||
export const EXIF_LABELS: Record<string, string> = {
|
||||
Make: "Camera Make",
|
||||
Model: "Camera Model",
|
||||
Software: "Software",
|
||||
DateTime: "Date/Time",
|
||||
DateTimeOriginal: "Date Taken",
|
||||
DateTimeDigitized: "Date Digitized",
|
||||
ExposureTime: "Exposure Time",
|
||||
FNumber: "F-Number",
|
||||
ISOSpeedRatings: "ISO",
|
||||
FocalLength: "Focal Length",
|
||||
FocalLengthIn35mmFilm: "Focal Length (35mm)",
|
||||
ExposureBiasValue: "Exposure Bias",
|
||||
MeteringMode: "Metering Mode",
|
||||
Flash: "Flash",
|
||||
WhiteBalance: "White Balance",
|
||||
ExposureMode: "Exposure Mode",
|
||||
SceneCaptureType: "Scene Type",
|
||||
Contrast: "Contrast",
|
||||
Saturation: "Saturation",
|
||||
Sharpness: "Sharpness",
|
||||
DigitalZoomRatio: "Digital Zoom",
|
||||
ImageWidth: "Width",
|
||||
ImageLength: "Height",
|
||||
Orientation: "Orientation",
|
||||
XResolution: "X Resolution",
|
||||
YResolution: "Y Resolution",
|
||||
ResolutionUnit: "Resolution Unit",
|
||||
ColorSpace: "Color Space",
|
||||
PixelXDimension: "Pixel Width",
|
||||
PixelYDimension: "Pixel Height",
|
||||
Artist: "Artist",
|
||||
Copyright: "Copyright",
|
||||
ImageDescription: "Description",
|
||||
LensMake: "Lens Make",
|
||||
LensModel: "Lens Model",
|
||||
BodySerialNumber: "Body Serial",
|
||||
CameraOwnerName: "Camera Owner",
|
||||
};
|
||||
|
||||
/** Keys to skip in display (internal/binary/redundant) */
|
||||
export const SKIP_KEYS = new Set([
|
||||
"ExifTag",
|
||||
"GPSTag",
|
||||
"InteroperabilityTag",
|
||||
"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 {
|
||||
if (value === null || value === undefined) return "N/A";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") {
|
||||
if (key === "ExposureTime" && value > 0 && value < 1) {
|
||||
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`;
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (typeof value[0] === "number" && value.length <= 4) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return `[${value.length} values]`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function exifStr(exif: Record<string, unknown> | null | undefined, key: string): string {
|
||||
const v = exif?.[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number") return String(v);
|
||||
return "";
|
||||
}
|
||||
@@ -81,6 +81,11 @@ const StripMetadataSettings = lazy(() =>
|
||||
default: m.StripMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const EditMetadataSettings = lazy(() =>
|
||||
import("@/components/tools/edit-metadata-settings").then((m) => ({
|
||||
default: m.EditMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const ColorSettings = lazy(() =>
|
||||
import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })),
|
||||
);
|
||||
@@ -238,6 +243,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
["convert", { displayMode: "no-comparison", Settings: ConvertSettings }],
|
||||
["compress", { displayMode: "before-after", Settings: CompressSettings }],
|
||||
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
|
||||
["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }],
|
||||
|
||||
// Color adjustments (all share ColorSettings with different toolId)
|
||||
...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
# Edit Metadata Tool - Design Spec
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Issue:** [stirling-image/stirling-image#15](https://github.com/stirling-image/stirling-image/issues/15)
|
||||
**Approach:** Shared metadata infrastructure (Approach 2)
|
||||
|
||||
## Overview
|
||||
|
||||
A new tool for editing and selectively removing EXIF metadata from images. Covers common editable fields (description, artist, copyright, software, dates), GPS clearing, and granular per-field stripping. Builds on shared infrastructure extracted from the existing strip-metadata tool.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Edit common EXIF fields: description, artist, copyright, software, date modified, date taken
|
||||
- GPS clear via checkbox
|
||||
- Granular strip: per-field removal of any displayed EXIF tag
|
||||
- Read-only display of current metadata (EXIF, GPS, XMP)
|
||||
- Pre-population of edit form from current values
|
||||
- Dirty tracking to distinguish untouched/edited/cleared fields
|
||||
- Shared metadata parsing and UI components extracted from strip-metadata
|
||||
|
||||
**Out of scope (potential future work):**
|
||||
- Arbitrary advanced EXIF field editing (camera make/model, lens, exposure, etc.)
|
||||
- XMP/ICC profile editing
|
||||
- Batch-specific metadata (different values per file)
|
||||
|
||||
## Architecture
|
||||
|
||||
### File changes
|
||||
|
||||
```
|
||||
packages/image-engine/
|
||||
src/utils/metadata.ts EXTEND add parseExif(), parseGps(), parseXmp(), sanitizeValue()
|
||||
src/operations/edit-metadata.ts NEW editMetadata() function
|
||||
src/types.ts EXTEND add EditMetadataOptions
|
||||
src/index.ts EXTEND export new operation
|
||||
|
||||
apps/api/
|
||||
src/routes/tools/edit-metadata.ts NEW /inspect + /edit endpoints
|
||||
src/routes/tools/strip-metadata.ts REFACTOR swap local parsing helpers for shared imports
|
||||
src/routes/tools/index.ts EXTEND register new tool
|
||||
|
||||
apps/web/
|
||||
src/components/common/collapsible-section.tsx NEW extract from strip-metadata
|
||||
src/components/common/metadata-grid.tsx NEW extract from strip-metadata
|
||||
src/lib/metadata-utils.ts NEW EXIF_LABELS, SKIP_KEYS, formatExifValue, exifStr
|
||||
src/components/tools/edit-metadata-settings.tsx NEW main component
|
||||
src/components/tools/strip-metadata-settings.tsx REFACTOR use shared imports
|
||||
src/lib/tool-registry.tsx EXTEND register new tool
|
||||
|
||||
packages/shared/
|
||||
src/constants.ts EXTEND add tool entry
|
||||
src/i18n/en.ts EXTEND add i18n strings
|
||||
```
|
||||
|
||||
### Image-engine layer
|
||||
|
||||
**Extended `utils/metadata.ts`** adds four parsing functions alongside the existing `getImageInfo()`:
|
||||
|
||||
- `sanitizeValue(v)` - makes EXIF values JSON-safe (Dates to ISO strings, Buffers to arrays or `<binary N bytes>`, recursion for nested objects)
|
||||
- `parseExif(exifBuffer)` - calls `exif-reader`, returns `{ image, photo, iop }` sections with sanitized values
|
||||
- `parseGps(gpsInfo)` - extracts DMS coordinates to decimal `{ latitude, longitude, altitude }`
|
||||
- `parseXmp(xmpBuffer)` - regex extraction of key/value pairs from XMP XML
|
||||
|
||||
**New `operations/edit-metadata.ts`** - `editMetadata(image, options)`:
|
||||
|
||||
- Maps common option fields (artist, copyright, imageDescription, software, dateTime, dateTimeOriginal) to their IFD0/IFD2 EXIF tag names
|
||||
- Accepts `fieldsToRemove: string[]` for granular strip
|
||||
- Logic:
|
||||
- If `clearGps` or `fieldsToRemove` has entries: read existing EXIF, rebuild the EXIF object minus the removed fields/GPS, merge in edits, then `withExif()` (full replace)
|
||||
- If only edits (no removals): `withExifMerge()` (non-destructive merge)
|
||||
- If nothing to do: `keepMetadata()` (passthrough)
|
||||
|
||||
**New type:**
|
||||
```ts
|
||||
interface EditMetadataOptions {
|
||||
artist?: string;
|
||||
copyright?: string;
|
||||
imageDescription?: string;
|
||||
software?: string;
|
||||
dateTime?: string;
|
||||
dateTimeOriginal?: string;
|
||||
clearGps?: boolean;
|
||||
fieldsToRemove?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### API route design
|
||||
|
||||
**`POST /api/v1/tools/edit-metadata/inspect`** - custom endpoint:
|
||||
- Accepts multipart file upload
|
||||
- Calls shared parsing functions from image-engine
|
||||
- Returns:
|
||||
```json
|
||||
{
|
||||
"filename": "photo.jpg",
|
||||
"fileSize": 2048000,
|
||||
"exif": { "Artist": "John", "Software": "Lightroom", ... },
|
||||
"gps": { "GPSLatitude": [...], "_latitude": 51.5074, "_longitude": -0.1278, ... },
|
||||
"xmp": { "dc:creator": "John", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`POST /api/v1/tools/edit-metadata`** - via `createToolRoute` factory:
|
||||
- Settings schema:
|
||||
```ts
|
||||
z.object({
|
||||
artist: z.string().optional(),
|
||||
copyright: z.string().optional(),
|
||||
imageDescription: z.string().optional(),
|
||||
software: z.string().optional(),
|
||||
dateTime: z.string().optional(),
|
||||
dateTimeOriginal: z.string().optional(),
|
||||
clearGps: z.boolean().default(false),
|
||||
fieldsToRemove: z.array(z.string()).default([]),
|
||||
})
|
||||
```
|
||||
- Process function: reads format, calls `editMetadata(image, settings)`, re-encodes in original format, returns `{ buffer, filename, contentType }`
|
||||
|
||||
### UI component design
|
||||
|
||||
**Shared extractions (from strip-metadata):**
|
||||
- `CollapsibleSection` to `components/common/collapsible-section.tsx` - unchanged from strip-metadata
|
||||
- `MetadataGrid` to `components/common/metadata-grid.tsx` - extended with optional `onRemove?: (key: string) => void` and `removedKeys?: Set<string>` props. When `onRemove` is provided, each row shows a trash icon. When a key is in `removedKeys`, the row renders with strikethrough + muted styling. Strip-metadata passes neither prop (read-only behavior preserved).
|
||||
- `EXIF_LABELS`, `SKIP_KEYS`, `formatExifValue()`, `exifStr()` to `lib/metadata-utils.ts`
|
||||
|
||||
**`EditMetadataSettings` - three sections:**
|
||||
|
||||
**1. Current Metadata (read-only + granular strip)**
|
||||
- Auto-fetched via `/inspect` on file selection (per-file cache, AbortController cleanup)
|
||||
- EXIF: `CollapsibleSection` with `MetadataGrid`. String-typed and safely-serializable fields get a trash icon for granular removal. Binary blobs (MakerNote, PrintImageMatching) and complex array fields are displayed read-only without a remove option - this avoids data corruption from lossy EXIF round-trips through `withExif()`. Clicking a trash icon toggles the tag into `fieldsToRemove` set (strikethrough + muted styling).
|
||||
- GPS: `CollapsibleSection` with warning styling if GPS detected, coordinates displayed
|
||||
|
||||
**2. Edit Fields**
|
||||
- Common fields: Description, Artist, Copyright, Software, Date Modified, Date Taken as `LabeledInput` components, pre-populated from inspect data
|
||||
- Dirty tracking: store initial values from inspect. On submit, compare current to initial. Changed + has value = include in settings. Changed + empty = add to `fieldsToRemove`. Untouched = skip.
|
||||
- GPS: "Remove GPS location data" checkbox with coordinate display if present
|
||||
|
||||
**3. Submit / Download**
|
||||
- Submit via `useToolProcessor("edit-metadata")`
|
||||
- `ProgressCard` during processing, download link after
|
||||
|
||||
**Display mode:** `"no-comparison"` in tool registry.
|
||||
|
||||
**Edit + remove conflict resolution:** If a user marks a field for removal in the metadata view AND edits the same field in the edit form, the edit wins. Submit logic checks edit fields first, only adds to `fieldsToRemove` tags that aren't being written.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User drops image into dropzone
|
||||
2. Component auto-calls `/inspect`, parses response, pre-populates form, stores initial values
|
||||
3. User edits fields and/or marks tags for removal in metadata view
|
||||
4. On submit: dirty-diff builds settings object (e.g. `{ artist: "New Name", fieldsToRemove: ["Software", "MeteringMode"], clearGps: true }`)
|
||||
5. Tool factory receives file + settings, calls `editMetadata()`, re-encodes, returns download URL
|
||||
6. User downloads modified image
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Inspect fails** (corrupt file, unsupported format): inline warning "Could not read metadata", form fields start empty, user can still write new metadata
|
||||
- **No EXIF in image**: "No metadata found" in current metadata section, form fields start empty, editing still works (writes fresh EXIF)
|
||||
- **Format with limited EXIF support** (PNG): no special handling. Sharp writes what the format supports, silently drops what it doesn't. Matches strip-metadata behavior.
|
||||
- **Processing fails**: tool factory returns 422, component displays error from response
|
||||
- **No changes submitted**: `keepMetadata()` passthrough, image re-encoded with metadata preserved
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (image-engine)
|
||||
- `editMetadata` writes common fields, readable back via `exif-reader`
|
||||
- `editMetadata` with `clearGps: true` removes GPS, preserves other EXIF
|
||||
- `editMetadata` with `fieldsToRemove` drops specific tags, preserves others
|
||||
- `editMetadata` with no options preserves metadata
|
||||
- Edit + remove conflict: edit wins
|
||||
- Works through `processImage` pipeline
|
||||
|
||||
### Unit tests (web utilities)
|
||||
- Dirty tracking: detects changed fields, cleared fields, ignores untouched
|
||||
- Settings builder: correctly splits edits vs removals
|
||||
- `formatExifValue` and `exifStr` tests (moved from fork's tests to shared location)
|
||||
|
||||
### Integration tests (API)
|
||||
- `/inspect` returns parsed EXIF/GPS/XMP for test JPEG with known metadata
|
||||
- `/inspect` returns nulls for metadata-free PNG
|
||||
- `/inspect` rejects no-file and invalid-file requests
|
||||
- Edit endpoint writes metadata, returns downloadable file
|
||||
- Edit endpoint with `fieldsToRemove` strips specific tags
|
||||
- Edit endpoint with `clearGps` removes GPS
|
||||
- Edit endpoint with empty settings preserves original metadata
|
||||
|
||||
### Strip-metadata regression
|
||||
- Re-run all existing strip-metadata tests after the shared extraction refactor to confirm no behavioral changes
|
||||
|
||||
### E2e tests (Playwright)
|
||||
- Tool appears in tool list and is navigable
|
||||
- Upload image, verify metadata displays
|
||||
- Edit a field, submit, download, re-upload and verify
|
||||
- Mark a field for removal, submit, verify removal
|
||||
- Add to `tools-all.spec.ts`
|
||||
|
||||
### Docker + Playwright GUI verification
|
||||
- Docker rebuild with cache
|
||||
- Spin up container
|
||||
- Playwright headed/GUI mode against running container
|
||||
- Manual verification: navigate to tool, upload test image with known EXIF/GPS, confirm metadata displays, edit fields, mark tags for removal, submit, download, re-upload to confirm changes persisted
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@stirling-image/shared": "workspace:*",
|
||||
"exif-reader": "^2.0.3",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { compress } from "./operations/compress.js";
|
||||
import { contrast } from "./operations/contrast.js";
|
||||
import { convert } from "./operations/convert.js";
|
||||
import { crop } from "./operations/crop.js";
|
||||
import { editMetadata } from "./operations/edit-metadata.js";
|
||||
import { flip } from "./operations/flip.js";
|
||||
import { grayscale } from "./operations/grayscale.js";
|
||||
import { invert } from "./operations/invert.js";
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
ContrastOptions,
|
||||
ConvertOptions,
|
||||
CropOptions,
|
||||
EditMetadataOptions,
|
||||
FlipOptions,
|
||||
OperationResult,
|
||||
OutputFormat,
|
||||
@@ -54,6 +56,7 @@ const OPERATION_MAP: Record<
|
||||
grayscale: (img) => grayscale(img),
|
||||
sepia: (img) => sepia(img),
|
||||
invert: (img) => invert(img),
|
||||
"edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions),
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<string, string> = {
|
||||
|
||||
@@ -6,6 +6,7 @@ export { compress } from "./operations/compress.js";
|
||||
export { contrast } from "./operations/contrast.js";
|
||||
export { convert } from "./operations/convert.js";
|
||||
export { crop } from "./operations/crop.js";
|
||||
export { editMetadata } from "./operations/edit-metadata.js";
|
||||
export { flip } from "./operations/flip.js";
|
||||
export { grayscale } from "./operations/grayscale.js";
|
||||
export { invert } from "./operations/invert.js";
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import exifReader from "exif-reader";
|
||||
import type { EditMetadataOptions, Sharp } from "../types.js";
|
||||
import { sanitizeValue } from "../utils/metadata.js";
|
||||
|
||||
/**
|
||||
* Keys that are binary blobs or complex arrays - NOT safe for EXIF round-trip
|
||||
* through withExif(). Silently filtered from fieldsToRemove to prevent
|
||||
* data corruption when the API is called directly (bypassing UI guards).
|
||||
*/
|
||||
const UNSAFE_ROUND_TRIP_KEYS = new Set([
|
||||
"MakerNote",
|
||||
"PrintImageMatching",
|
||||
"ComponentsConfiguration",
|
||||
"FlashpixVersion",
|
||||
"ExifVersion",
|
||||
"FileSource",
|
||||
"SceneType",
|
||||
"UserComment",
|
||||
"InteroperabilityIndex",
|
||||
"InteroperabilityVersion",
|
||||
"ExifTag",
|
||||
"GPSTag",
|
||||
"InteroperabilityTag",
|
||||
]);
|
||||
|
||||
const COMMON_FIELD_MAP: Array<{
|
||||
option: keyof EditMetadataOptions;
|
||||
ifd: "IFD0" | "IFD2";
|
||||
tag: string;
|
||||
}> = [
|
||||
{ option: "artist", ifd: "IFD0", tag: "Artist" },
|
||||
{ option: "copyright", ifd: "IFD0", tag: "Copyright" },
|
||||
{ option: "imageDescription", ifd: "IFD0", tag: "ImageDescription" },
|
||||
{ option: "software", ifd: "IFD0", tag: "Software" },
|
||||
{ option: "dateTime", ifd: "IFD0", tag: "DateTime" },
|
||||
{ option: "dateTimeOriginal", ifd: "IFD2", tag: "DateTimeOriginal" },
|
||||
];
|
||||
|
||||
export async function editMetadata(
|
||||
image: Sharp,
|
||||
options: EditMetadataOptions = {},
|
||||
): Promise<Sharp> {
|
||||
const edits: { IFD0: Record<string, string>; IFD2: Record<string, string> } = {
|
||||
IFD0: {},
|
||||
IFD2: {},
|
||||
};
|
||||
|
||||
for (const { option, ifd, tag } of COMMON_FIELD_MAP) {
|
||||
const value = options[option];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
edits[ifd][tag] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const writtenTags = new Set([...Object.keys(edits.IFD0), ...Object.keys(edits.IFD2)]);
|
||||
const fieldsToRemove = (options.fieldsToRemove ?? []).filter(
|
||||
(f) => !writtenTags.has(f) && !UNSAFE_ROUND_TRIP_KEYS.has(f),
|
||||
);
|
||||
|
||||
const hasEdits = Object.keys(edits.IFD0).length > 0 || Object.keys(edits.IFD2).length > 0;
|
||||
const hasRemovals = fieldsToRemove.length > 0 || options.clearGps === true;
|
||||
|
||||
if (!hasEdits && !hasRemovals) {
|
||||
return image.keepMetadata();
|
||||
}
|
||||
|
||||
if (hasRemovals) {
|
||||
const metadata = await image.metadata();
|
||||
|
||||
const existingIFD0: Record<string, string> = {};
|
||||
const existingIFD2: Record<string, string> = {};
|
||||
|
||||
if (metadata.exif) {
|
||||
try {
|
||||
const parsed = exifReader(metadata.exif);
|
||||
if (parsed.Image) {
|
||||
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||
if (fieldsToRemove.includes(k)) continue;
|
||||
const sv = sanitizeValue(v);
|
||||
if (typeof sv === "string" || typeof sv === "number") {
|
||||
existingIFD0[k] = String(sv);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed.Photo) {
|
||||
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||
if (fieldsToRemove.includes(k)) continue;
|
||||
const sv = sanitizeValue(v);
|
||||
if (typeof sv === "string" || typeof sv === "number") {
|
||||
existingIFD2[k] = String(sv);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, proceed with just the edits
|
||||
}
|
||||
}
|
||||
|
||||
const finalIFD0 = { ...existingIFD0, ...edits.IFD0 };
|
||||
const finalIFD2 = { ...existingIFD2, ...edits.IFD2 };
|
||||
|
||||
const exif: Record<string, Record<string, string>> = {};
|
||||
if (Object.keys(finalIFD0).length > 0) exif.IFD0 = finalIFD0;
|
||||
if (Object.keys(finalIFD2).length > 0) exif.IFD2 = finalIFD2;
|
||||
|
||||
return image.withExif(exif);
|
||||
}
|
||||
|
||||
const exif: Record<string, Record<string, string>> = {};
|
||||
if (Object.keys(edits.IFD0).length > 0) exif.IFD0 = edits.IFD0;
|
||||
if (Object.keys(edits.IFD2).length > 0) exif.IFD2 = edits.IFD2;
|
||||
|
||||
return image.withExifMerge(exif);
|
||||
}
|
||||
@@ -63,6 +63,17 @@ export interface StripMetadataOptions {
|
||||
stripAll?: boolean;
|
||||
}
|
||||
|
||||
export interface EditMetadataOptions {
|
||||
artist?: string;
|
||||
copyright?: string;
|
||||
imageDescription?: string;
|
||||
software?: string;
|
||||
dateTime?: string;
|
||||
dateTimeOriginal?: string;
|
||||
clearGps?: boolean;
|
||||
fieldsToRemove?: string[];
|
||||
}
|
||||
|
||||
export interface BrightnessOptions {
|
||||
value: number; // -100 to +100
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import exifReader from "exif-reader";
|
||||
import sharp from "sharp";
|
||||
import type { ImageInfo } from "../types.js";
|
||||
|
||||
@@ -26,3 +27,121 @@ export async function getImageInfo(buffer: Buffer): Promise<ImageInfo> {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a value for JSON - convert Buffers/Dates and drop overly large blobs.
|
||||
*/
|
||||
export function sanitizeValue(v: unknown): unknown {
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (Buffer.isBuffer(v)) {
|
||||
if (v.length > 256) return `<binary ${v.length} bytes>`;
|
||||
return Array.from(v);
|
||||
}
|
||||
if (Array.isArray(v)) return v.map(sanitizeValue);
|
||||
if (v !== null && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
out[k] = sanitizeValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an EXIF buffer into sanitized sections.
|
||||
*/
|
||||
export function parseExif(exifBuffer: Buffer): {
|
||||
image: Record<string, unknown>;
|
||||
photo: Record<string, unknown>;
|
||||
iop: Record<string, unknown>;
|
||||
gps: Record<string, unknown>;
|
||||
} {
|
||||
const result = {
|
||||
image: {} as Record<string, unknown>,
|
||||
photo: {} as Record<string, unknown>,
|
||||
iop: {} as Record<string, unknown>,
|
||||
gps: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (!exifBuffer || exifBuffer.length === 0) return result;
|
||||
|
||||
try {
|
||||
const parsed = exifReader(exifBuffer);
|
||||
|
||||
if (parsed.Image) {
|
||||
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||
result.image[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
if (parsed.Photo) {
|
||||
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||
result.photo[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
if (parsed.Iop) {
|
||||
for (const [k, v] of Object.entries(parsed.Iop)) {
|
||||
result.iop[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
if (parsed.GPSInfo) {
|
||||
for (const [k, v] of Object.entries(parsed.GPSInfo)) {
|
||||
result.gps[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return empty sections on parse failure
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GPS coordinates from EXIF GPSInfo into decimal degrees.
|
||||
*/
|
||||
export function parseGps(gps: Record<string, unknown>): {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
altitude: number | null;
|
||||
} {
|
||||
let latitude: number | null = null;
|
||||
let longitude: number | null = null;
|
||||
let altitude: number | null = null;
|
||||
|
||||
const lat = gps.GPSLatitude as number[] | undefined;
|
||||
const latRef = gps.GPSLatitudeRef as string | undefined;
|
||||
if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||
if (latRef === "S") latitude = -latitude;
|
||||
}
|
||||
|
||||
const lon = gps.GPSLongitude as number[] | undefined;
|
||||
const lonRef = gps.GPSLongitudeRef as string | undefined;
|
||||
if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||
if (lonRef === "W") longitude = -longitude;
|
||||
}
|
||||
|
||||
if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) {
|
||||
altitude = gps.GPSAltitude;
|
||||
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
|
||||
}
|
||||
|
||||
return { latitude, longitude, altitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XMP XML buffer into key-value pairs.
|
||||
*/
|
||||
export function parseXmp(xmpBuffer: Buffer): Record<string, string> {
|
||||
const xml = xmpBuffer.toString("utf-8");
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) {
|
||||
const key = match[1];
|
||||
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
|
||||
result[key] = match[2];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@ export const TOOLS: Tool[] = [
|
||||
icon: "ShieldOff",
|
||||
route: "/strip-metadata",
|
||||
},
|
||||
{
|
||||
id: "edit-metadata",
|
||||
name: "Edit Metadata",
|
||||
description: "Edit EXIF, GPS, and camera info",
|
||||
category: "optimization",
|
||||
icon: "PenLine",
|
||||
route: "/edit-metadata",
|
||||
},
|
||||
{
|
||||
id: "bulk-rename",
|
||||
name: "Bulk Rename",
|
||||
|
||||
@@ -36,6 +36,7 @@ export const en = {
|
||||
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" },
|
||||
"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" },
|
||||
favicon: { name: "Favicon Generator", description: "Generate all favicon and app icon sizes" },
|
||||
|
||||
Generated
+3
@@ -260,6 +260,9 @@ importers:
|
||||
'@stirling-image/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
exif-reader:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
sharp:
|
||||
specifier: ^0.33.0
|
||||
version: 0.33.5
|
||||
|
||||
@@ -12,6 +12,7 @@ const TOOLS_WITH_DROPZONE = [
|
||||
{ id: "convert", name: "Convert" },
|
||||
{ id: "compress", name: "Compress" },
|
||||
{ id: "strip-metadata", name: "Strip Metadata" },
|
||||
{ id: "edit-metadata", name: "Edit Metadata" },
|
||||
{ id: "bulk-rename", name: "Bulk Rename" },
|
||||
{ id: "image-to-pdf", name: "Image to PDF" },
|
||||
{ id: "favicon", name: "Favicon" },
|
||||
|
||||
@@ -89,6 +89,23 @@ test.describe("Tool processing (core tools)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("edit-metadata processes image", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/edit-metadata");
|
||||
await uploadTestImage(page);
|
||||
|
||||
// Wait for inspect to complete and form to populate
|
||||
await page.waitForSelector('[id="em-artist"]', { timeout: 10_000 });
|
||||
|
||||
// Edit the artist field
|
||||
await page.fill('[id="em-artist"]', "E2E Test Artist");
|
||||
|
||||
await page.getByRole("button", { name: /apply metadata/i }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("brightness-contrast processes image", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/brightness-contrast");
|
||||
await uploadTestImage(page);
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 762 B |
@@ -20,6 +20,7 @@ const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
||||
const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png"));
|
||||
const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
@@ -3382,3 +3383,103 @@ describe("Workspace integrity", () => {
|
||||
expect(jobId1).not.toBe(jobId2);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EDIT METADATA
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("Edit metadata", () => {
|
||||
describe("POST /api/v1/tools/edit-metadata/inspect", () => {
|
||||
it("returns parsed EXIF for JPEG with metadata", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "exif.jpg", contentType: "image/jpeg", content: EXIF_JPG },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata/inspect",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.filename).toBe("exif.jpg");
|
||||
expect(body.exif).toBeTruthy();
|
||||
expect(body.exif.Artist).toBe("Test Artist");
|
||||
expect(body.exif.Copyright).toBe("2026 Test Copyright");
|
||||
});
|
||||
|
||||
it("returns no exif for metadata-free PNG", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "plain.png", contentType: "image/png", content: PNG_1x1 },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata/inspect",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects request with no file", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata/inspect",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "multipart/form-data; boundary=---test",
|
||||
},
|
||||
payload: Buffer.from("-----test--\r\n"),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/tools/edit-metadata", () => {
|
||||
it("writes metadata and returns downloadable file", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "edit.jpg", contentType: "image/jpeg", content: EXIF_JPG },
|
||||
{ name: "settings", content: JSON.stringify({ artist: "New Author" }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.jobId).toBeDefined();
|
||||
});
|
||||
|
||||
it("strips specific fields via fieldsToRemove", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "strip.jpg", contentType: "image/jpeg", content: EXIF_JPG },
|
||||
{ name: "settings", content: JSON.stringify({ fieldsToRemove: ["Software"] }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("preserves metadata with empty settings", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "noop.jpg", contentType: "image/jpeg", content: EXIF_JPG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/edit-metadata",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ const require = createRequire(
|
||||
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
|
||||
);
|
||||
const sharp = require("sharp") as typeof import("sharp").default;
|
||||
const exifReader = require(
|
||||
path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"),
|
||||
) as typeof import("exif-reader").default;
|
||||
|
||||
import {
|
||||
brightness,
|
||||
@@ -16,12 +19,18 @@ import {
|
||||
contrast,
|
||||
convert,
|
||||
crop,
|
||||
editMetadata,
|
||||
flip,
|
||||
getImageInfo,
|
||||
grayscale,
|
||||
invert,
|
||||
parseExif,
|
||||
parseGps,
|
||||
parseXmp,
|
||||
processImage,
|
||||
resize,
|
||||
rotate,
|
||||
sanitizeValue,
|
||||
saturation,
|
||||
sepia,
|
||||
stripMetadata,
|
||||
@@ -44,12 +53,14 @@ let png200x150: Buffer;
|
||||
let png1x1: Buffer;
|
||||
let jpg100x100: Buffer;
|
||||
let webp50x50: Buffer;
|
||||
let jpgWithExif: Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png"));
|
||||
png1x1 = readFileSync(path.join(FIXTURES_DIR, "test-1x1.png"));
|
||||
jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg"));
|
||||
webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp"));
|
||||
jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg"));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1343,3 +1354,206 @@ describe("processImage", () => {
|
||||
expect(typeof result.info.hasAlpha).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared metadata parsing utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("sanitizeValue", () => {
|
||||
it("converts Date to ISO string", () => {
|
||||
const d = new Date("2026-01-15T10:30:00Z");
|
||||
expect(sanitizeValue(d)).toBe("2026-01-15T10:30:00.000Z");
|
||||
});
|
||||
|
||||
it("converts small Buffer to number array", () => {
|
||||
const buf = Buffer.from([1, 2, 3]);
|
||||
expect(sanitizeValue(buf)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("converts large Buffer to placeholder string", () => {
|
||||
const buf = Buffer.alloc(300, 0);
|
||||
expect(sanitizeValue(buf)).toBe("<binary 300 bytes>");
|
||||
});
|
||||
|
||||
it("recursively sanitizes objects", () => {
|
||||
const d = new Date("2026-01-01T00:00:00Z");
|
||||
const result = sanitizeValue({ nested: { date: d } });
|
||||
expect(result).toEqual({ nested: { date: "2026-01-01T00:00:00.000Z" } });
|
||||
});
|
||||
|
||||
it("passes through primitives unchanged", () => {
|
||||
expect(sanitizeValue("hello")).toBe("hello");
|
||||
expect(sanitizeValue(42)).toBe(42);
|
||||
expect(sanitizeValue(null)).toBe(null);
|
||||
expect(sanitizeValue(true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseExif", () => {
|
||||
it("parses EXIF buffer from test fixture", async () => {
|
||||
const metadata = await sharp(jpgWithExif).metadata();
|
||||
expect(metadata.exif).toBeTruthy();
|
||||
const result = parseExif(metadata.exif!);
|
||||
expect(result.image.Artist).toBe("Test Artist");
|
||||
expect(result.image.Copyright).toBe("2026 Test Copyright");
|
||||
expect(result.image.Software).toBe("Stirling-Image Test");
|
||||
expect(result.image.ImageDescription).toBe("Test Description");
|
||||
});
|
||||
|
||||
it("returns empty sections for empty buffer", () => {
|
||||
const result = parseExif(Buffer.from([]));
|
||||
expect(result.image).toEqual({});
|
||||
expect(result.gps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGps", () => {
|
||||
it("parses DMS coordinates to decimal degrees", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [51, 30, 26.4],
|
||||
GPSLatitudeRef: "N",
|
||||
GPSLongitude: [0, 7, 39.6],
|
||||
GPSLongitudeRef: "W",
|
||||
GPSAltitude: 10,
|
||||
GPSAltitudeRef: 0,
|
||||
});
|
||||
expect(result.latitude).toBeCloseTo(51.5073, 3);
|
||||
expect(result.longitude).toBeCloseTo(-0.1277, 3);
|
||||
expect(result.altitude).toBe(10);
|
||||
});
|
||||
|
||||
it("returns nulls for empty GPS data", () => {
|
||||
const result = parseGps({});
|
||||
expect(result.latitude).toBeNull();
|
||||
expect(result.longitude).toBeNull();
|
||||
expect(result.altitude).toBeNull();
|
||||
});
|
||||
|
||||
it("handles southern hemisphere", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [33, 51, 54],
|
||||
GPSLatitudeRef: "S",
|
||||
GPSLongitude: [151, 12, 36],
|
||||
GPSLongitudeRef: "E",
|
||||
});
|
||||
expect(result.latitude).toBeCloseTo(-33.865, 2);
|
||||
expect(result.longitude).toBeCloseTo(151.21, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseXmp", () => {
|
||||
it("extracts key-value pairs from XMP XML", () => {
|
||||
const xml = Buffer.from(
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
||||
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
|
||||
'<rdf:Description dc:creator="Alice" dc:title="My Photo" />' +
|
||||
"</rdf:RDF></x:xmpmeta>",
|
||||
);
|
||||
const result = parseXmp(xml);
|
||||
expect(result["dc:creator"]).toBe("Alice");
|
||||
expect(result["dc:title"]).toBe("My Photo");
|
||||
});
|
||||
|
||||
it("skips xmlns and rdf namespace prefixes", () => {
|
||||
const xml = Buffer.from(
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
||||
'<rdf:Description rdf:about="" dc:format="image/jpeg" />' +
|
||||
"</x:xmpmeta>",
|
||||
);
|
||||
const result = parseXmp(xml);
|
||||
expect(result["xmlns:x"]).toBeUndefined();
|
||||
expect(result["xmlns:dc"]).toBeUndefined();
|
||||
expect(result["rdf:about"]).toBeUndefined();
|
||||
expect(result["dc:format"]).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("returns empty object for empty buffer", () => {
|
||||
const result = parseXmp(Buffer.from(""));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// editMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("editMetadata", () => {
|
||||
it("writes common fields readable via exif-reader", async () => {
|
||||
const image = sharp(jpgWithExif);
|
||||
const result = await editMetadata(image, {
|
||||
artist: "New Artist",
|
||||
copyright: "New Copyright",
|
||||
});
|
||||
const buf = await result.jpeg().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.exif).toBeTruthy();
|
||||
const parsed = exifReader(meta.exif!);
|
||||
expect(parsed.Image?.Artist).toBe("New Artist");
|
||||
expect(parsed.Image?.Copyright).toBe("New Copyright");
|
||||
// Original fields should be preserved via withExifMerge
|
||||
expect(parsed.Image?.Software).toBe("Stirling-Image Test");
|
||||
});
|
||||
|
||||
it("clears GPS while preserving other EXIF", async () => {
|
||||
// First write GPS to the image
|
||||
const withGps = sharp(jpgWithExif).withExif({
|
||||
IFD0: { Artist: "GPS Test" },
|
||||
IFD3: { GPSLatitudeRef: "N" },
|
||||
});
|
||||
const gpsBuf = await withGps.jpeg().toBuffer();
|
||||
|
||||
const image = sharp(gpsBuf);
|
||||
const result = await editMetadata(image, { clearGps: true });
|
||||
const buf = await result.jpeg().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
const parsed = exifReader(meta.exif!);
|
||||
// GPS should be gone
|
||||
expect(parsed.GPSInfo).toBeUndefined();
|
||||
// Other EXIF should still be present
|
||||
expect(parsed.Image?.Artist).toBe("GPS Test");
|
||||
});
|
||||
|
||||
it("removes specific fields via fieldsToRemove", async () => {
|
||||
const image = sharp(jpgWithExif);
|
||||
const result = await editMetadata(image, {
|
||||
fieldsToRemove: ["Software"],
|
||||
});
|
||||
const buf = await result.jpeg().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
const parsed = exifReader(meta.exif!);
|
||||
expect(parsed.Image?.Software).toBeUndefined();
|
||||
// Other fields preserved
|
||||
expect(parsed.Image?.Artist).toBe("Test Artist");
|
||||
});
|
||||
|
||||
it("preserves metadata with no options", async () => {
|
||||
const image = sharp(jpgWithExif);
|
||||
const result = await editMetadata(image, {});
|
||||
const buf = await result.jpeg().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
expect(meta.exif).toBeTruthy();
|
||||
const parsed = exifReader(meta.exif!);
|
||||
expect(parsed.Image?.Artist).toBe("Test Artist");
|
||||
});
|
||||
|
||||
it("edit wins over remove for same field", async () => {
|
||||
const image = sharp(jpgWithExif);
|
||||
const result = await editMetadata(image, {
|
||||
artist: "Override Artist",
|
||||
fieldsToRemove: ["Artist"],
|
||||
});
|
||||
const buf = await result.jpeg().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
const parsed = exifReader(meta.exif!);
|
||||
expect(parsed.Image?.Artist).toBe("Override Artist");
|
||||
});
|
||||
|
||||
it("writes fresh EXIF to image without existing metadata", async () => {
|
||||
const image = sharp(png1x1);
|
||||
const result = await editMetadata(image, {
|
||||
artist: "Fresh Artist",
|
||||
copyright: "Fresh Copyright",
|
||||
});
|
||||
const buf = await result.png().toBuffer();
|
||||
// The operation should not throw
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user