merge: resolve conflict with main branch in README.md

This commit is contained in:
SnapOtter
2026-05-08 17:32:16 +08:00
150 changed files with 32270 additions and 101 deletions
+126 -2
View File
@@ -20,6 +20,17 @@ const SUPPORTED_INPUT_FORMATS = new Set([
"psd",
"exr",
"hdr",
"jp2",
"qoi",
"eps",
"dds",
"cur",
"dpx",
"fits",
"ppm",
"pgm",
"pbm",
"pfm",
]);
interface MagicEntry {
@@ -38,6 +49,19 @@ const MAGIC_BYTES: MagicEntry[] = [
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "cr3" }, // ftyp box; verified below
// Fujifilm RAF: "FUJIFILMCCD-RAW" at offset 0
{
bytes: [
0x46, 0x55, 0x4a, 0x49, 0x46, 0x49, 0x4c, 0x4d, 0x43, 0x43, 0x44, 0x2d, 0x52, 0x41, 0x57,
],
offset: 0,
format: "raw",
},
// Sigma X3F: "FOVb" at offset 0
{ bytes: [0x46, 0x4f, 0x56, 0x62], offset: 0, format: "raw" },
// Minolta MRW: "\x00MRM" at offset 0
{ bytes: [0x00, 0x4d, 0x52, 0x4d], offset: 0, format: "raw" },
// JXL ISOBMFF container
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
// JXL raw codestream
@@ -49,6 +73,47 @@ const MAGIC_BYTES: MagicEntry[] = [
// OpenEXR
{ bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" },
// TGA has no reliable magic bytes — detected by extension only
// JPEG 2000 JP2 box signature (NOT ISOBMFF)
{
bytes: [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a],
offset: 0,
format: "jp2",
},
// JPEG 2000 raw codestream (J2K/J2C)
{ bytes: [0xff, 0x4f, 0xff, 0x51], offset: 0, format: "jp2" },
// QOI: "qoif" at offset 0
{ bytes: [0x71, 0x6f, 0x69, 0x66], offset: 0, format: "qoi" },
// DDS: "DDS " at offset 0
{ bytes: [0x44, 0x44, 0x53, 0x20], offset: 0, format: "dds" },
// CUR: Windows cursor (ICO variant, byte 3 = 0x02 vs ICO's 0x01)
{ bytes: [0x00, 0x00, 0x02, 0x00], offset: 0, format: "cur" },
// DPX forward: "SDPX"
{ bytes: [0x53, 0x44, 0x50, 0x58], offset: 0, format: "dpx" },
// DPX reverse: "XPDS"
{ bytes: [0x58, 0x50, 0x44, 0x53], offset: 0, format: "dpx" },
// Cineon
{ bytes: [0x80, 0x2a, 0x5f, 0xd7], offset: 0, format: "dpx" },
// FITS: "SIMPLE" at offset 0
{ bytes: [0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45], offset: 0, format: "fits" },
// EPS ASCII header: "%!PS-Adobe"
{
bytes: [0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65],
offset: 0,
format: "eps",
},
// EPS binary (DOS EPS)
{ bytes: [0xc5, 0xd0, 0xd3, 0xc6], offset: 0, format: "eps" },
// Netpbm: P1-P7 headers (these MUST go AFTER the PNG entry to avoid false matches on 0x50)
{ bytes: [0x50, 0x31], offset: 0, format: "pbm" },
{ bytes: [0x50, 0x34], offset: 0, format: "pbm" },
{ bytes: [0x50, 0x32], offset: 0, format: "pgm" },
{ bytes: [0x50, 0x35], offset: 0, format: "pgm" },
{ bytes: [0x50, 0x33], offset: 0, format: "ppm" },
{ bytes: [0x50, 0x36], offset: 0, format: "ppm" },
{ bytes: [0x50, 0x37], offset: 0, format: "ppm" },
// PFM (Portable FloatMap)
{ bytes: [0x50, 0x46], offset: 0, format: "pfm" },
{ bytes: [0x50, 0x66], offset: 0, format: "pfm" },
];
export interface ValidationResult {
@@ -64,10 +129,50 @@ export interface ValidationError {
}
/** Camera RAW extensions that share TIFF magic bytes. */
const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]);
const RAW_EXTENSIONS = new Set([
"dng",
"cr2",
"cr3",
"nef",
"nrw",
"arw",
"orf",
"rw2",
"raf",
"pef",
"3fr",
"iiq",
"srw",
"x3f",
"rwl",
"gpr",
"fff",
"mrw",
"mef",
"kdc",
"dcr",
"erf",
"ptx",
]);
/** Formats that Sharp cannot decode natively — skip dimension check. */
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",
]);
/**
* Check whether a file extension corresponds to a Camera RAW format.
@@ -121,6 +226,18 @@ export async function validateImageBuffer(
detectedFormat = "tga";
}
// SVGZ: gzip-compressed SVG, detected by extension + gzip magic
if (!detectedFormat && ext === "svgz") {
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
detectedFormat = "svg";
}
}
// APNG: Sharp handles as PNG first frame. Accept .apng extension.
if (!detectedFormat && ext === "apng") {
detectedFormat = "png";
}
if (!detectedFormat) {
return { valid: false, reason: "Unrecognized image format" };
}
@@ -219,6 +336,13 @@ function detectMagicBytes(buffer: Buffer): string | null {
const brand = buffer.slice(8, 12).toString("ascii");
if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue;
}
// For ftyp, verify CR3 brand at bytes 8-11.
if (entry.format === "cr3") {
if (buffer.length < 12) continue;
const brand = buffer.slice(8, 12).toString("ascii");
if (brand !== "crx ") continue;
return "raw"; // CR3 is a RAW format, routed through decodeRaw()
}
return entry.format;
}
}
+248 -7
View File
@@ -4,11 +4,31 @@ import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import sharp from "sharp";
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",
"ppm",
"pgm",
"pbm",
"fits",
]);
export function needsCliDecode(format: string): boolean {
return CLI_DECODED_FORMATS.has(format);
@@ -17,11 +37,22 @@ export function needsCliDecode(format: string): boolean {
/**
* 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): Promise<Buffer> {
export async function decodeToSharpCompat(
buffer: Buffer,
format: string,
ext?: string,
): Promise<Buffer> {
switch (format) {
case "raw":
return decodeRaw(buffer);
return decodeRaw(buffer, ext);
case "ico":
return decodeIco(buffer);
case "psd":
@@ -36,6 +67,24 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi
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);
case "ppm":
case "pgm":
case "pbm":
return decodeNetpbm(buffer, format);
default:
return buffer;
}
@@ -84,16 +133,47 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
}
}
// ── RAW decoder (ImageMagick with LibRaw delegate) ─────────────
// ── 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): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `raw-in-${id}.dng`);
// 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}`]),
@@ -242,3 +322,164 @@ 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 ──
async function decodeQoi(buffer: Buffer): Promise<Buffer> {
const { qoiDecode } = await import("@snapotter/image-engine");
const { header, pixels } = qoiDecode(new Uint8Array(buffer));
return sharp(Buffer.from(pixels), {
raw: { width: header.width, height: header.height, channels: 4 },
})
.png()
.toBuffer();
}
// ── Netpbm (PPM/PGM/PBM) decoder ──
async function decodeNetpbm(buffer: Buffer, format: string): Promise<Buffer> {
try {
return await sharp(buffer).png().toBuffer();
} catch {
const cmd = await findMagickCmd();
const id = randomUUID();
const ext = format === "pgm" ? "pgm" : format === "pbm" ? "pbm" : "ppm";
const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`);
const outputPath = join(tmpdir(), `netpbm-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(() => {});
}
}
}
+105
View File
@@ -0,0 +1,105 @@
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";
import { qoiEncode } from "@snapotter/image-engine";
import sharp from "sharp";
const execFileAsync = promisify(execFile);
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.");
}
function magickArgs(cmd: string, args: string[]): string[] {
return cmd === "magick" ? ["convert", ...args] : args;
}
export async function encodeBmp(inputBuffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `bmp-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `bmp-enc-out-${id}.bmp`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `bmp3:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeIco(inputBuffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `ico-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `ico-enc-out-${id}.ico`);
try {
const pngBuffer = await sharp(inputBuffer)
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
await writeFile(inputPath, pngBuffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ico:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeJp2(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jp2-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `jp2-enc-out-${id}.jp2`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
try {
const rate = quality ? String(Math.max(1, Math.round(quality / 10))) : "5";
await execFileAsync("opj_compress", ["-i", inputPath, "-o", outputPath, "-r", rate], {
timeout: 60_000,
});
return await readFile(outputPath);
} catch {
/* fall back to ImageMagick */
}
const cmd = await findMagickCmd();
const q = quality ? ["-quality", String(quality)] : [];
await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jp2:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({
resolveWithObject: true,
});
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
return Buffer.from(encoded);
}
+2 -2
View File
@@ -18,14 +18,14 @@ const FORMAT_MAP: Record<
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
jxl: { format: "png", extension: "png", contentType: "image/png" },
jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" },
};
const DEFAULT_QUALITY = 95;
const PNG_FALLBACK = FORMAT_MAP.png;
/** Formats that have no Sharp output encoder — fall back to PNG. */
const PNG_FALLBACK_FORMATS = new Set(["svg", "bmp", "raw", "tga", "psd", "exr", "hdr", "ico"]);
const PNG_FALLBACK_FORMATS = new Set(["svg", "raw", "tga", "psd", "exr", "hdr"]);
/**
* Detect the input image format and return matching output config.
+19
View File
@@ -1,3 +1,4 @@
import { gunzipSync } from "node:zlib";
import { env } from "../config.js";
/**
@@ -36,6 +37,24 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
return Buffer.from(svg, "utf-8");
}
const MAX_SVGZ_DECOMPRESSED_SIZE = 50 * 1024 * 1024;
/**
* Decompress an SVGZ (gzip-compressed SVG) buffer.
* Returns the buffer unchanged if it is not gzip-compressed.
* Throws on decompression bomb or invalid SVG content.
*/
export function decompressSvgz(buffer: Buffer): Buffer {
if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) {
return buffer;
}
const decompressed = gunzipSync(buffer, { maxOutputLength: MAX_SVGZ_DECOMPRESSED_SIZE });
if (!isSvgBuffer(decompressed)) {
throw new Error("SVGZ file does not contain valid SVG content after decompression");
}
return decompressed;
}
/**
* Check whether a buffer looks like SVG content.
* Examines the first 4KB for an <svg tag.
+18 -2
View File
@@ -16,7 +16,7 @@ import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js";
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
import { computeTimeout } from "../lib/timeout.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
@@ -192,9 +192,12 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools.
// The decoded buffer is PNG, so update the filename extension to match.
// Pass the original file extension so RAW decoder can use the correct
// temp file suffix (e.g. .cr3, .nef) for format identification.
if (needsCliDecode(validation.format)) {
try {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
} catch (err) {
@@ -209,6 +212,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const isSvg = validation.format === "svg";
if (isSvg) {
try {
fileBuffer = decompressSvgz(fileBuffer);
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
@@ -314,6 +318,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
"image/bmp": ".bmp",
"image/heic": ".heic",
"image/heif": ".heif",
"image/jxl": ".jxl",
"image/x-icon": ".ico",
"image/vnd.adobe.photoshop": ".psd",
"image/x-exr": ".exr",
"image/vnd.radiance": ".hdr",
"image/x-targa": ".tga",
"image/jp2": ".jp2",
"image/qoi": ".qoi",
"application/postscript": ".eps",
"image/vnd.ms-dds": ".dds",
"image/x-dpx": ".dpx",
"image/fits": ".fits",
};
const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType];
if (expectedExt) {
+5 -1
View File
@@ -333,7 +333,7 @@ const settingsSchema = z.object({
cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z.string().default("#FFFFFF"),
aspectRatio: z.string().default("free"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
outputExt = "avif";
break;
case "jxl":
pipeline = pipeline.jxl({ quality: settings.quality });
outputExt = "jxl";
break;
default:
pipeline = pipeline.png();
outputExt = "png";
+42 -1
View File
@@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
gif: "image/gif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
bmp: "image/bmp",
ico: "image/x-icon",
jp2: "image/jp2",
qoi: "image/x-qoi",
};
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
bmp: encodeBmp,
ico: encodeIco,
jp2: encodeJp2,
qoi: encodeQoi,
};
const settingsSchema = z.object({
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]),
format: z.enum([
"jpg",
"png",
"webp",
"avif",
"tiff",
"gif",
"heic",
"heif",
"jxl",
"bmp",
"ico",
"jp2",
"qoi",
]),
quality: z.number().min(1).max(100).optional(),
});
@@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) {
toolId: "convert",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
// CLI-encoded formats bypass Sharp entirely
const cliEncoder = CLI_ENCODERS[settings.format];
if (cliEncoder) {
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return {
buffer: outputBuffer,
filename: `${baseName}.${settings.format}`,
contentType,
};
}
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
const image = sharp(inputBuffer, sharpOpts);
+6 -2
View File
@@ -26,13 +26,14 @@ const EXT_MAP: Record<string, string> = {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
const settingsSchema = z.object({
format: z
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"])
.default("auto"),
quality: z.number().int().min(1).max(100).default(95),
});
@@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) {
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
@@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
+5 -1
View File
@@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).default("original"),
quality: z.number().int().min(1).max(100).default(80),
maxWidth: z.number().int().min(0).default(0),
maxHeight: z.number().int().min(0).default(0),
@@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) {
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
mimeType = "image/avif";
break;
case "jxl":
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
mimeType = "image/jxl";
break;
default:
outputBuffer = await pipeline.toBuffer();
mimeType = detectMimeType(ext);
+2 -2
View File
@@ -21,7 +21,7 @@ const settingsSchema = z.object({
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
});
@@ -198,7 +198,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
}),
process: async (inputBuffer, settings, filename) => {
@@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpeg: "image/jpeg",
avif: "image/avif",
png: "image/png",
jxl: "image/jxl",
};
const FORMAT_EXTENSIONS: Record<string, string> = {
@@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record<string, string> = {
jpeg: "jpg",
avif: "avif",
png: "png",
jxl: "jxl",
};
const settingsSchema = z.object({
format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"),
format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"),
quality: z.number().min(1).max(100).default(80),
maxWidth: z.number().positive().optional(),
maxHeight: z.number().positive().optional(),
+6 -1
View File
@@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"),
format: z
.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"])
.default("png"),
dpi: z.number().min(36).max(2400).default(150),
quality: z.number().min(1).max(100).default(85),
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
@@ -86,6 +88,7 @@ const FORMAT_EXT: Record<string, string> = {
gif: ".gif",
heic: ".heic",
heif: ".heif",
jxl: ".jxl",
};
async function convertWithSharp(
@@ -114,6 +117,8 @@ async function convertWithSharp(
return s.tiff().toBuffer();
case "gif":
return s.gif().toBuffer();
case "jxl":
return s.jxl({ quality }).toBuffer();
case "heic":
case "heif": {
const pngBuf = await s.png().toBuffer();
+2 -1
View File
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
rows: z.number().min(1).max(100).default(3),
tileWidth: z.number().min(10).optional(),
tileHeight: z.number().min(10).optional(),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"),
quality: z.number().min(1).max(100).default(90),
});
@@ -31,6 +31,7 @@ function resolveOutputFormat(
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
avif: { sharpFormat: "avif", ext: ".avif" },
jxl: { sharpFormat: "jxl", ext: ".jxl" },
};
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
}
+5 -1
View File
@@ -24,7 +24,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) {
pipeline = pipeline.webp({ quality: settings.quality });
} else if (settings.format === "avif") {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
} else if (settings.format === "jxl") {
pipeline = pipeline.jxl({ quality: settings.quality });
} else {
pipeline = pipeline.png();
}
@@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) {
result = await sharp(result).webp({ quality: settings.quality }).toBuffer();
} else if (settings.format === "avif") {
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
} else if (settings.format === "jxl") {
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
}
}
+5 -1
View File
@@ -26,7 +26,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"),
});
interface ParsedSvgFile {
@@ -77,6 +77,10 @@ async function convertSvg(
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "jxl":
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
ext = "jxl";
break;
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
+5 -1
View File
@@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) {
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
const onProgress = (percent: number, stage: string) => {
@@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
finalFormat = "avif";
@@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;