mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import sharp from "sharp";
|
|
import { z } from "zod";
|
|
import { formatZodErrors } from "../../lib/errors.js";
|
|
import {
|
|
buildTagArgs,
|
|
type EditMetadataSettings,
|
|
inspectMetadata,
|
|
writeMetadata,
|
|
} from "../../lib/exiftool.js";
|
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
|
import { sanitizeFilename } from "../../lib/filename.js";
|
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
|
import { putObject } from "../../lib/object-storage.js";
|
|
import { registerToolProcessFn } from "../tool-factory.js";
|
|
|
|
const settingsSchema = z.object({
|
|
title: z.string().optional(),
|
|
author: z.string().optional(),
|
|
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([]),
|
|
gpsLatitude: z.number().min(-90).max(90).optional(),
|
|
gpsLongitude: z.number().min(-180).max(180).optional(),
|
|
gpsAltitude: z.number().optional(),
|
|
keywords: z.array(z.string()).optional(),
|
|
keywordsMode: z.enum(["add", "set"]).default("add"),
|
|
dateShift: z
|
|
.string()
|
|
.regex(/^[+-]\d{1,2}:\d{2}$/)
|
|
.optional(),
|
|
setAllDates: z.string().optional(),
|
|
iptcTitle: z.string().optional(),
|
|
iptcHeadline: z.string().optional(),
|
|
iptcCity: z.string().optional(),
|
|
iptcState: z.string().optional(),
|
|
iptcCountry: z.string().optional(),
|
|
});
|
|
|
|
type Settings = z.infer<typeof settingsSchema>;
|
|
|
|
const MIME_BY_FORMAT: Record<string, string> = {
|
|
jpeg: "image/jpeg",
|
|
png: "image/png",
|
|
webp: "image/webp",
|
|
avif: "image/avif",
|
|
tiff: "image/tiff",
|
|
gif: "image/gif",
|
|
heif: "image/heif",
|
|
};
|
|
|
|
const BROWSER_PREVIEWABLE = new Set([
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/svg+xml",
|
|
"image/bmp",
|
|
"image/avif",
|
|
]);
|
|
|
|
export function registerEditMetadata(app: FastifyInstance) {
|
|
// Inspect endpoint - returns parsed metadata via ExifTool
|
|
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 = sanitizeFilename(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 result = await inspectMetadata(fileBuffer, filename);
|
|
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 in-place using ExifTool (no pixel re-encoding)
|
|
app.post("/api/v1/tools/edit-metadata", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
let fileBuffer: Buffer | null = null;
|
|
let filename = "image";
|
|
let settingsRaw: string | null = null;
|
|
|
|
try {
|
|
const parts = request.parts();
|
|
for await (const part of parts) {
|
|
if (part.type === "file") {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of part.file) {
|
|
chunks.push(chunk);
|
|
}
|
|
fileBuffer = Buffer.concat(chunks);
|
|
filename = sanitizeFilename(part.filename ?? "image");
|
|
} else if (part.fieldname === "settings") {
|
|
settingsRaw = part.value as string;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return reply.status(400).send({
|
|
error: "Failed to parse multipart request",
|
|
details: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
|
|
if (!fileBuffer || fileBuffer.length === 0) {
|
|
return reply.status(400).send({ error: "No image file provided" });
|
|
}
|
|
|
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
if (!validation.valid) {
|
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
}
|
|
|
|
// Parse and validate settings
|
|
let settings: Settings;
|
|
try {
|
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
const result = settingsSchema.safeParse(parsed);
|
|
if (!result.success) {
|
|
return reply.status(400).send({
|
|
error: "Invalid settings",
|
|
details: formatZodErrors(result.error.issues),
|
|
});
|
|
}
|
|
settings = result.data;
|
|
} catch {
|
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
}
|
|
|
|
try {
|
|
// Build ExifTool arguments from settings
|
|
const tags = buildTagArgs(settings as EditMetadataSettings);
|
|
|
|
// If no changes requested, return the original buffer
|
|
let outputBuffer: Buffer;
|
|
if (tags.length === 0) {
|
|
outputBuffer = fileBuffer;
|
|
} else {
|
|
outputBuffer = await writeMetadata(fileBuffer, filename, tags);
|
|
}
|
|
|
|
// Determine content type from validated format
|
|
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
|
|
|
|
// Save output to object storage
|
|
const jobId = randomUUID();
|
|
await putObject(`outputs/${jobId}/${filename}`, outputBuffer);
|
|
|
|
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
|
|
let previewUrl: string | undefined;
|
|
if (!BROWSER_PREVIEWABLE.has(contentType)) {
|
|
try {
|
|
let previewInput = outputBuffer;
|
|
if (contentType === "image/heif" || contentType === "image/heic") {
|
|
previewInput = await decodeHeic(outputBuffer);
|
|
}
|
|
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
|
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
|
|
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
|
} catch {
|
|
// Non-fatal - frontend shows fallback
|
|
}
|
|
}
|
|
|
|
return reply.send({
|
|
jobId,
|
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
|
previewUrl,
|
|
originalSize: fileBuffer.length,
|
|
processedSize: outputBuffer.length,
|
|
});
|
|
} catch (err) {
|
|
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed");
|
|
return reply.status(422).send({
|
|
error: "Metadata edit failed",
|
|
details: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
});
|
|
|
|
// Register in pipeline/batch registry
|
|
registerToolProcessFn({
|
|
toolId: "edit-metadata",
|
|
settingsSchema,
|
|
process: async (inputBuffer, settings, filename) => {
|
|
const s = settings as Settings;
|
|
const tags = buildTagArgs(s as EditMetadataSettings);
|
|
const buffer =
|
|
tags.length > 0 ? await writeMetadata(inputBuffer, filename, tags) : inputBuffer;
|
|
|
|
// Determine content type from extension
|
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
const extToMime: Record<string, string> = {
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
png: "image/png",
|
|
webp: "image/webp",
|
|
avif: "image/avif",
|
|
tiff: "image/tiff",
|
|
tif: "image/tiff",
|
|
gif: "image/gif",
|
|
heic: "image/heif",
|
|
heif: "image/heif",
|
|
};
|
|
|
|
return {
|
|
buffer,
|
|
filename,
|
|
contentType: extToMime[ext] ?? "image/jpeg",
|
|
};
|
|
},
|
|
});
|
|
}
|