feat: add decode pipelines for JP2, EPS, QOI, DDS, CUR, DPX, FITS, PPM, SVGZ, APNG

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).
This commit is contained in:
SnapOtter
2026-05-08 00:05:50 +08:00
parent c0fb5896bb
commit 5c9fa2de49
10 changed files with 345 additions and 7 deletions
+162 -1
View File
@@ -8,7 +8,23 @@ 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"]);
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);
@@ -47,6 +63,20 @@ export async function decodeToSharpCompat(
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;
}
@@ -284,3 +314,134 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
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");
}