2026-05-12 21:55:17 +08:00
|
|
|
import { execFile } from "node:child_process";
|
|
|
|
|
import { rm, writeFile } from "node:fs/promises";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { promisify } from "node:util";
|
2026-03-25 09:27:12 +08:00
|
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
|
|
|
import sharp from "sharp";
|
2026-04-21 10:34:10 +08:00
|
|
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
2026-05-01 18:11:49 +08:00
|
|
|
import { sanitizeFilename } from "../../lib/filename.js";
|
2026-04-21 10:34:10 +08:00
|
|
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
2026-05-13 10:39:17 +08:00
|
|
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-05-12 21:55:17 +08:00
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
2026-03-22 04:20:35 +08:00
|
|
|
/**
|
|
|
|
|
* Image info route - read-only, returns JSON metadata.
|
|
|
|
|
* Does NOT use createToolRoute since it doesn't produce a processed file.
|
|
|
|
|
*/
|
|
|
|
|
export function registerInfo(app: FastifyInstance) {
|
2026-03-25 09:27:12 +08:00
|
|
|
app.post("/api/v1/tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
|
|
|
let fileBuffer: Buffer | null = null;
|
|
|
|
|
let filename = "image";
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
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);
|
2026-03-22 04:20:35 +08:00
|
|
|
}
|
2026-03-25 09:27:12 +08:00
|
|
|
fileBuffer = Buffer.concat(chunks);
|
2026-05-01 18:11:49 +08:00
|
|
|
filename = sanitizeFilename(part.filename ?? "image");
|
2026-03-22 04:20:35 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-25 09:27:12 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
return reply.status(400).send({
|
|
|
|
|
error: "Failed to parse multipart request",
|
|
|
|
|
details: err instanceof Error ? err.message : String(err),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
if (!fileBuffer || fileBuffer.length === 0) {
|
|
|
|
|
return reply.status(400).send({ error: "No image file provided" });
|
|
|
|
|
}
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
try {
|
2026-04-21 10:34:10 +08:00
|
|
|
// Detect format for CLI-decoded formats (PSD, TGA, EXR, HDR, ICO, RAW)
|
|
|
|
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
|
|
|
const detectedFormat = validation.valid ? validation.format : null;
|
2026-04-12 08:50:19 +08:00
|
|
|
|
2026-05-12 21:55:17 +08:00
|
|
|
// For metadata reading, try Sharp on the raw buffer first -- it handles
|
|
|
|
|
// TIFF-based formats (DNG, CR2, NEF) without needing a full decode via
|
|
|
|
|
// ImageMagick/darktable. Only fall back to the decode pipeline for
|
|
|
|
|
// formats Sharp can't open at all (PSD, ICO, TGA, etc.).
|
2026-04-21 10:34:10 +08:00
|
|
|
let metaBuffer = fileBuffer;
|
2026-05-12 21:02:35 +08:00
|
|
|
const ext = filename.split(".").pop()?.toLowerCase();
|
2026-05-12 21:55:17 +08:00
|
|
|
let sharpDirectFailed = false;
|
|
|
|
|
try {
|
|
|
|
|
await sharp(fileBuffer).metadata();
|
|
|
|
|
} catch {
|
|
|
|
|
sharpDirectFailed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sharpDirectFailed) {
|
|
|
|
|
if (detectedFormat && needsCliDecode(detectedFormat)) {
|
|
|
|
|
metaBuffer = await decodeToSharpCompat(fileBuffer, detectedFormat, ext);
|
|
|
|
|
} else {
|
2026-05-13 10:39:17 +08:00
|
|
|
metaBuffer = await decodeHeic(fileBuffer);
|
2026-05-12 21:55:17 +08:00
|
|
|
}
|
2026-04-21 10:34:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metadata = await sharp(metaBuffer).metadata();
|
|
|
|
|
const stats = await sharp(metaBuffer).stats();
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
// Build histogram data from stats
|
|
|
|
|
const histogram = stats.channels.map((ch, i) => ({
|
|
|
|
|
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
|
|
|
|
|
min: ch.min,
|
|
|
|
|
max: ch.max,
|
|
|
|
|
mean: Math.round(ch.mean * 100) / 100,
|
|
|
|
|
stdev: Math.round(ch.stdev * 100) / 100,
|
|
|
|
|
}));
|
2026-03-22 04:20:35 +08:00
|
|
|
|
2026-05-12 21:55:17 +08:00
|
|
|
// For RAW formats, Sharp reads the embedded thumbnail -- enrich with
|
|
|
|
|
// ExifTool to get the real sensor dimensions and EXIF/ICC/XMP flags.
|
|
|
|
|
const exif = detectedFormat === "raw" ? await readExifToolMeta(fileBuffer, ext) : null;
|
|
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
return reply.send({
|
|
|
|
|
filename,
|
|
|
|
|
fileSize: fileBuffer.length,
|
2026-05-12 21:55:17 +08:00
|
|
|
width: exif?.width ?? metadata.width ?? 0,
|
|
|
|
|
height: exif?.height ?? metadata.height ?? 0,
|
|
|
|
|
format: exif?.format ?? metadata.format ?? "unknown",
|
2026-03-25 09:27:12 +08:00
|
|
|
channels: metadata.channels ?? 0,
|
|
|
|
|
hasAlpha: metadata.hasAlpha ?? false,
|
2026-05-12 21:55:17 +08:00
|
|
|
colorSpace: exif?.colorSpace ?? metadata.space ?? "unknown",
|
|
|
|
|
density: metadata.density ?? exif?.density ?? null,
|
2026-03-25 09:27:12 +08:00
|
|
|
isProgressive: metadata.isProgressive ?? false,
|
|
|
|
|
orientation: metadata.orientation ?? null,
|
|
|
|
|
hasProfile: metadata.hasProfile ?? false,
|
2026-05-12 21:55:17 +08:00
|
|
|
hasExif: exif?.hasExif ?? !!metadata.exif,
|
|
|
|
|
hasIcc: exif?.hasIcc ?? !!metadata.icc,
|
|
|
|
|
hasXmp: exif?.hasXmp ?? !!metadata.xmp,
|
|
|
|
|
bitDepth: exif?.bitDepth ?? metadata.depth ?? null,
|
2026-03-25 09:27:12 +08:00
|
|
|
pages: metadata.pages ?? 1,
|
|
|
|
|
histogram,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return reply.status(422).send({
|
|
|
|
|
error: "Failed to read image metadata",
|
|
|
|
|
details: err instanceof Error ? err.message : "Unknown error",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-22 04:20:35 +08:00
|
|
|
}
|
2026-05-12 21:55:17 +08:00
|
|
|
|
|
|
|
|
interface ExifMeta {
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
format: string;
|
|
|
|
|
colorSpace: string | null;
|
|
|
|
|
density: number | null;
|
|
|
|
|
bitDepth: string | null;
|
|
|
|
|
hasExif: boolean;
|
|
|
|
|
hasIcc: boolean;
|
|
|
|
|
hasXmp: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readExifToolMeta(buffer: Buffer, ext?: string): Promise<ExifMeta | null> {
|
|
|
|
|
const suffix = ext ? `.${ext}` : ".dng";
|
|
|
|
|
const tmpPath = join(tmpdir(), `info-exif-${Date.now()}${suffix}`);
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(tmpPath, buffer);
|
|
|
|
|
const { stdout } = await execFileAsync(
|
|
|
|
|
"exiftool",
|
|
|
|
|
[
|
|
|
|
|
"-j",
|
|
|
|
|
"-ImageWidth",
|
|
|
|
|
"-ImageHeight",
|
|
|
|
|
"-FileType",
|
|
|
|
|
"-BitsPerSample",
|
|
|
|
|
"-ColorSpace",
|
|
|
|
|
"-XResolution",
|
|
|
|
|
"-ICCProfileName",
|
|
|
|
|
"-EXIF:all",
|
|
|
|
|
"-XMP:XMPToolkit",
|
|
|
|
|
tmpPath,
|
|
|
|
|
],
|
|
|
|
|
{ timeout: 10_000 },
|
|
|
|
|
);
|
|
|
|
|
const [data] = JSON.parse(stdout);
|
|
|
|
|
if (!data) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
width: data.ImageWidth ?? 0,
|
|
|
|
|
height: data.ImageHeight ?? 0,
|
|
|
|
|
format: (data.FileType ?? "raw").toLowerCase(),
|
|
|
|
|
colorSpace: data.ColorSpace ?? null,
|
|
|
|
|
density: data.XResolution ?? null,
|
|
|
|
|
bitDepth: data.BitsPerSample ? String(data.BitsPerSample) : null,
|
|
|
|
|
hasExif: Object.keys(data).some((k) => k.startsWith("EXIF:") || k === "ExifVersion"),
|
|
|
|
|
hasIcc: !!data.ICCProfileName,
|
|
|
|
|
hasXmp: !!data.XMPToolkit,
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
} finally {
|
|
|
|
|
await rm(tmpPath, { force: true }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|