Merge pull request #22 from stirling-image/feat/edit-metadata

feat: add Edit Metadata tool
This commit is contained in:
stirling-image
2026-04-06 22:08:43 +08:00
committed by GitHub
25 changed files with 3763 additions and 253 deletions
+150
View File
@@ -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",
};
},
});
}
+2
View File
@@ -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
+10 -98
View File
@@ -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 */
+99
View File
@@ -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 "";
}
+6
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@stirling-image/shared": "workspace:*",
"exif-reader": "^2.0.3",
"sharp": "^0.33.0"
},
"devDependencies": {
+3
View File
@@ -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> = {
+1
View File
@@ -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);
}
+11
View File
@@ -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
}
+119
View File
@@ -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;
}
+8
View File
@@ -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",
+1
View File
@@ -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" },
+3
View File
@@ -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
+1
View File
@@ -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" },
+17
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

+101
View File
@@ -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);
});
});
});
+214
View File
@@ -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);
});
});