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;
|
||||
|
||||
Reference in New Issue
Block a user