mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Add input decode support for 10 new image format families: - JPEG 2000 (JP2/J2K): opj_decompress with ImageMagick fallback - EPS: ImageMagick + Ghostscript delegate with 50MB size guard - DDS: ImageMagick decode, first frame extraction - CUR: reuses ICO decoder (structurally identical) - DPX/Cineon: ImageMagick with sRGB colorspace conversion - FITS: ImageMagick with normalize + sRGB conversion - QOI: stub decoder (real codec deferred to Task 4) - PPM/PGM/PBM/PFM: Sharp-native via libvips (no CLI decoder needed) - SVGZ: gzip decompression with bomb protection before SVG sanitization - APNG: accepted via extension, decoded as PNG first frame by Sharp Updates magic bytes, MIME mappings, and frontend accept lists across all file picker entry points (dropzone, tool page, file upload, editor).
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/** Formats that need external CLI tools (not decodable by Sharp). */
|
|
const CLI_DECODED_FORMATS = new Set([
|
|
"raw",
|
|
"ico",
|
|
"tga",
|
|
"psd",
|
|
"exr",
|
|
"hdr",
|
|
"bmp",
|
|
"jxl",
|
|
"jp2",
|
|
"qoi",
|
|
"eps",
|
|
"dds",
|
|
"cur",
|
|
"dpx",
|
|
"fits",
|
|
]);
|
|
|
|
export function needsCliDecode(format: string): boolean {
|
|
return CLI_DECODED_FORMATS.has(format);
|
|
}
|
|
|
|
/**
|
|
* Main entry point - routes to the right decoder based on format.
|
|
* Returns a PNG buffer that Sharp can process downstream.
|
|
*
|
|
* @param buffer - The raw file buffer
|
|
* @param format - The detected format string (e.g. "raw", "psd", "ico")
|
|
* @param ext - Optional original file extension (e.g. "cr3", "nef").
|
|
* Passed to decodeRaw so the temp file uses the correct
|
|
* extension, which helps ExifTool and ImageMagick identify
|
|
* the RAW variant.
|
|
*/
|
|
export async function decodeToSharpCompat(
|
|
buffer: Buffer,
|
|
format: string,
|
|
ext?: string,
|
|
): Promise<Buffer> {
|
|
switch (format) {
|
|
case "raw":
|
|
return decodeRaw(buffer, ext);
|
|
case "ico":
|
|
return decodeIco(buffer);
|
|
case "psd":
|
|
return decodePsd(buffer);
|
|
case "tga":
|
|
return decodeTga(buffer);
|
|
case "exr":
|
|
return decodeExr(buffer);
|
|
case "hdr":
|
|
return decodeHdr(buffer);
|
|
case "bmp":
|
|
return decodeBmp(buffer);
|
|
case "jxl":
|
|
return decodeJxl(buffer);
|
|
case "jp2":
|
|
return decodeJp2(buffer);
|
|
case "eps":
|
|
return decodeEps(buffer);
|
|
case "dds":
|
|
return decodeDds(buffer);
|
|
case "cur":
|
|
return decodeIco(buffer); // CUR is structurally identical to ICO
|
|
case "dpx":
|
|
return decodeDpx(buffer);
|
|
case "fits":
|
|
return decodeFits(buffer);
|
|
case "qoi":
|
|
return decodeQoi(buffer);
|
|
default:
|
|
return buffer;
|
|
}
|
|
}
|
|
|
|
// ── ImageMagick helpers ────────────────────────────────────────
|
|
|
|
let cachedMagickCmd: string | null = null;
|
|
|
|
async function findMagickCmd(): Promise<string> {
|
|
if (cachedMagickCmd) return cachedMagickCmd;
|
|
for (const cmd of ["magick", "convert"]) {
|
|
try {
|
|
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
|
|
cachedMagickCmd = cmd;
|
|
return cmd;
|
|
} catch {
|
|
// try next
|
|
}
|
|
}
|
|
throw new Error("No ImageMagick found. Install imagemagick (provides convert/magick).");
|
|
}
|
|
|
|
function magickArgs(cmd: string, args: string[]): string[] {
|
|
return cmd === "magick" ? ["convert", ...args] : args;
|
|
}
|
|
|
|
// ── ICO decoder ────────────────────────────────────────────────
|
|
|
|
async function decodeIco(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `ico-in-${id}.ico`);
|
|
const outputPath = join(tmpdir(), `ico-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
// ICO contains multiple sizes; extract the largest by sorting
|
|
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── RAW decoder (ExifTool-first, ImageMagick fallback) ──────────
|
|
//
|
|
// Strategy: Many camera RAW files (CR2, CR3, NEF, ARW, etc.) embed a
|
|
// full-size JPEG preview. ExifTool can extract it near-instantly with
|
|
// `-b -JpgFromRaw`. This is faster and more reliable than ImageMagick's
|
|
// LibRaw delegate, which may not support newer formats like CR3.
|
|
//
|
|
// If ExifTool extraction fails (no embedded JPEG, or exiftool not
|
|
// installed), we fall back to ImageMagick + LibRaw.
|
|
|
|
async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
|
|
const id = randomUUID();
|
|
// Use the original extension so ExifTool / ImageMagick can identify the RAW variant.
|
|
const suffix = ext ? `.${ext.replace(/^\./, "")}` : ".dng";
|
|
const inputPath = join(tmpdir(), `raw-in-${id}${suffix}`);
|
|
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
|
|
// Attempt 1: ExifTool embedded JPEG extraction (fast path)
|
|
try {
|
|
const { stdout } = await execFileAsync("exiftool", ["-b", "-JpgFromRaw", inputPath], {
|
|
encoding: "buffer",
|
|
maxBuffer: 50 * 1024 * 1024,
|
|
timeout: 30_000,
|
|
} as never);
|
|
// stdout is a Buffer when encoding is "buffer"
|
|
const jpegBuf = stdout as unknown as Buffer;
|
|
if (jpegBuf && jpegBuf.length > 1000) {
|
|
// Verify it starts with JPEG SOI marker
|
|
if (jpegBuf[0] === 0xff && jpegBuf[1] === 0xd8) {
|
|
return jpegBuf;
|
|
}
|
|
}
|
|
} catch {
|
|
// ExifTool not available or no embedded JPEG -- fall through
|
|
}
|
|
|
|
// Attempt 2: ImageMagick + LibRaw delegate (full decode)
|
|
const cmd = await findMagickCmd();
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`]),
|
|
{ timeout: 120_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── ImageMagick decoders (PSD, TGA, EXR, HDR) ──────────────────
|
|
|
|
/**
|
|
* Decode PSD to PNG. Uses [0] to read only the flattened composite layer.
|
|
*/
|
|
async function decodePsd(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `psd-in-${id}.psd`);
|
|
const outputPath = join(tmpdir(), `psd-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode TGA to PNG.
|
|
*/
|
|
async function decodeTga(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `tga-in-${id}.tga`);
|
|
const outputPath = join(tmpdir(), `tga-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode EXR to PNG. Colorspace conversion from linear to sRGB is needed
|
|
* because EXR files are typically stored in linear light.
|
|
*/
|
|
async function decodeExr(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `exr-in-${id}.exr`);
|
|
const outputPath = join(tmpdir(), `exr-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
|
{ timeout: 120_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode Radiance HDR to PNG. Same colorspace handling as EXR.
|
|
*/
|
|
async function decodeHdr(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `hdr-in-${id}.hdr`);
|
|
const outputPath = join(tmpdir(), `hdr-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
|
{ timeout: 120_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function decodeBmp(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `bmp-in-${id}.bmp`);
|
|
const outputPath = join(tmpdir(), `bmp-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function decodeJxl(buffer: Buffer): Promise<Buffer> {
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `jxl-in-${id}.jxl`);
|
|
const outputPath = join(tmpdir(), `jxl-out-${id}.png`);
|
|
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
|
|
// Try djxl first (from libjxl-tools) — works even when ImageMagick
|
|
// lacks a JXL delegate (common on Ubuntu stock packages).
|
|
try {
|
|
await execFileAsync("djxl", [inputPath, outputPath], { timeout: 120_000 });
|
|
return await readFile(outputPath);
|
|
} catch {
|
|
// djxl not available, fall back to ImageMagick
|
|
}
|
|
|
|
const cmd = await findMagickCmd();
|
|
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── JPEG 2000 decoder (opj_decompress-first, ImageMagick fallback) ──
|
|
|
|
async function decodeJp2(buffer: Buffer): Promise<Buffer> {
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`);
|
|
const outputPath = join(tmpdir(), `jp2-out-${id}.png`);
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
try {
|
|
await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], {
|
|
timeout: 60_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} catch {
|
|
// opj_decompress not available, fall back to ImageMagick
|
|
}
|
|
const cmd = await findMagickCmd();
|
|
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── EPS decoder (ImageMagick + Ghostscript delegate) ──
|
|
|
|
const MAX_EPS_SIZE = 50 * 1024 * 1024;
|
|
|
|
async function decodeEps(buffer: Buffer): Promise<Buffer> {
|
|
if (buffer.length > MAX_EPS_SIZE) {
|
|
throw new Error(
|
|
`EPS file too large (${(buffer.length / 1024 / 1024).toFixed(1)}MB, limit: 50MB)`,
|
|
);
|
|
}
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `eps-in-${id}.eps`);
|
|
const outputPath = join(tmpdir(), `eps-out-${id}.png`);
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [
|
|
"-density",
|
|
"300",
|
|
"-define",
|
|
"gs:MaxBitmap=500000000",
|
|
inputPath,
|
|
"-colorspace",
|
|
"sRGB",
|
|
`png:${outputPath}`,
|
|
]),
|
|
{ timeout: 30_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── DDS decoder ──
|
|
|
|
async function decodeDds(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `dds-in-${id}.dds`);
|
|
const outputPath = join(tmpdir(), `dds-out-${id}.png`);
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── DPX / Cineon decoder ──
|
|
|
|
async function decodeDpx(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`);
|
|
const outputPath = join(tmpdir(), `dpx-out-${id}.png`);
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
|
{ timeout: 120_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── FITS decoder ──
|
|
|
|
async function decodeFits(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findMagickCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `fits-in-${id}.fits`);
|
|
const outputPath = join(tmpdir(), `fits-out-${id}.png`);
|
|
try {
|
|
await writeFile(inputPath, buffer);
|
|
await execFileAsync(
|
|
cmd,
|
|
magickArgs(cmd, [inputPath, "-normalize", "-colorspace", "sRGB", `png:${outputPath}`]),
|
|
{ timeout: 120_000 },
|
|
);
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── QOI decoder (stub -- real codec comes in Task 4) ──
|
|
|
|
async function decodeQoi(_buffer: Buffer): Promise<Buffer> {
|
|
throw new Error("QOI decode not yet implemented");
|
|
}
|