mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #83 from ashim-hq/feat/extended-format-support
feat: extended image format support (JXL, RAW, ICO, TGA, PSD, EXR, HDR)
This commit is contained in:
@@ -15,6 +15,20 @@ const SAFE_STORAGE_EXTENSIONS = new Set([
|
||||
".avif",
|
||||
".svg",
|
||||
".pdf",
|
||||
".heic",
|
||||
".heif",
|
||||
".jxl",
|
||||
".ico",
|
||||
".dng",
|
||||
".cr2",
|
||||
".nef",
|
||||
".arw",
|
||||
".orf",
|
||||
".rw2",
|
||||
".tga",
|
||||
".psd",
|
||||
".exr",
|
||||
".hdr",
|
||||
]);
|
||||
|
||||
let storageReady = false;
|
||||
|
||||
@@ -13,6 +13,13 @@ const SUPPORTED_INPUT_FORMATS = new Set([
|
||||
"avif",
|
||||
"heif",
|
||||
"svg",
|
||||
"jxl",
|
||||
"ico",
|
||||
"raw",
|
||||
"tga",
|
||||
"psd",
|
||||
"exr",
|
||||
"hdr",
|
||||
]);
|
||||
|
||||
interface MagicEntry {
|
||||
@@ -31,6 +38,17 @@ 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
|
||||
// JXL ISOBMFF container
|
||||
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
|
||||
// JXL raw codestream
|
||||
{ bytes: [0xff, 0x0a], offset: 0, format: "jxl" },
|
||||
// ICO
|
||||
{ bytes: [0x00, 0x00, 0x01, 0x00], offset: 0, format: "ico" },
|
||||
// PSD ("8BPS")
|
||||
{ bytes: [0x38, 0x42, 0x50, 0x53], offset: 0, format: "psd" },
|
||||
// OpenEXR
|
||||
{ bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" },
|
||||
// TGA has no reliable magic bytes — detected by extension only
|
||||
];
|
||||
|
||||
export interface ValidationResult {
|
||||
@@ -45,6 +63,19 @@ export interface ValidationError {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Camera RAW extensions that share TIFF magic bytes. */
|
||||
const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]);
|
||||
|
||||
/** Formats that Sharp cannot decode natively — skip dimension check. */
|
||||
const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr"]);
|
||||
|
||||
/**
|
||||
* Check whether a file extension corresponds to a Camera RAW format.
|
||||
*/
|
||||
export function isRawExtension(ext: string): boolean {
|
||||
return RAW_EXTENSIONS.has(ext.toLowerCase().replace(/^\./, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an uploaded image buffer.
|
||||
*
|
||||
@@ -53,9 +84,14 @@ export interface ValidationError {
|
||||
* 2. Magic bytes match a known image format
|
||||
* 3. Format is in the supported input formats list
|
||||
* 4. Image dimensions do not exceed MAX_MEGAPIXELS
|
||||
*
|
||||
* @param buffer - The image file buffer
|
||||
* @param filename - Optional original filename, used for extension-based
|
||||
* format detection (Camera RAW, TGA)
|
||||
*/
|
||||
export async function validateImageBuffer(
|
||||
buffer: Buffer,
|
||||
filename?: string,
|
||||
): Promise<ValidationResult | ValidationError> {
|
||||
// 1. Empty / null-byte check
|
||||
if (!buffer || buffer.length === 0) {
|
||||
@@ -68,8 +104,23 @@ export async function validateImageBuffer(
|
||||
return { valid: false, reason: "File contains no image data" };
|
||||
}
|
||||
|
||||
// 2. Format detection (magic bytes for raster, text check for SVG)
|
||||
const detectedFormat = detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null);
|
||||
// Extract extension from filename for extension-based detection
|
||||
const ext = filename ? (filename.split(".").pop()?.toLowerCase() ?? "") : "";
|
||||
|
||||
// 2. Format detection (magic bytes for raster, text check for SVG, text check for HDR)
|
||||
let detectedFormat =
|
||||
detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null) || detectHdrText(buffer);
|
||||
|
||||
// RAW formats share TIFF magic bytes — differentiate by extension
|
||||
if (detectedFormat === "tiff" && ext && isRawExtension(ext)) {
|
||||
detectedFormat = "raw";
|
||||
}
|
||||
|
||||
// TGA has no magic bytes — detect by extension only
|
||||
if (!detectedFormat && ext === "tga") {
|
||||
detectedFormat = "tga";
|
||||
}
|
||||
|
||||
if (!detectedFormat) {
|
||||
return { valid: false, reason: "Unrecognized image format" };
|
||||
}
|
||||
@@ -83,6 +134,12 @@ export async function validateImageBuffer(
|
||||
}
|
||||
|
||||
// 4. Dimensions check via sharp metadata
|
||||
// For formats Sharp can't decode natively, skip the dimension check.
|
||||
// The actual decoding happens later in the tool pipeline.
|
||||
if (CLI_DECODED_FORMATS.has(detectedFormat)) {
|
||||
return { valid: true, format: detectedFormat, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const sharpOpts = detectedFormat === "svg" ? { density: 72 } : undefined;
|
||||
const metadata = await sharp(buffer, sharpOpts).metadata();
|
||||
@@ -99,7 +156,9 @@ export async function validateImageBuffer(
|
||||
|
||||
return { valid: true, format: detectedFormat, width, height };
|
||||
} catch {
|
||||
return { valid: false, reason: "Failed to read image metadata" };
|
||||
// Sharp failed but we already confirmed valid magic bytes / extension.
|
||||
// This can happen for JXL, ICO, or other formats Sharp partially supports.
|
||||
return { valid: true, format: detectedFormat, width: 0, height: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,3 +227,16 @@ function detectMagicBytes(buffer: Buffer): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Radiance HDR format by text header.
|
||||
* HDR files start with "#?RADIANCE" or "#?RGBE".
|
||||
*/
|
||||
function detectHdrText(buffer: Buffer): string | null {
|
||||
if (buffer.length < 10) return null;
|
||||
const header = buffer.slice(0, 11).toString("ascii");
|
||||
if (header.startsWith("#?RADIANCE") || header.startsWith("#?RGBE")) {
|
||||
return "hdr";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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"]);
|
||||
|
||||
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.
|
||||
*/
|
||||
export async function decodeToSharpCompat(buffer: Buffer, format: string): Promise<Buffer> {
|
||||
switch (format) {
|
||||
case "raw":
|
||||
return decodeRaw(buffer);
|
||||
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);
|
||||
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 (ImageMagick with LibRaw delegate) ─────────────
|
||||
|
||||
async function decodeRaw(buffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `raw-in-${id}.dng`);
|
||||
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,19 @@ 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" },
|
||||
};
|
||||
|
||||
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"]);
|
||||
|
||||
/**
|
||||
* Detect the input image format and return matching output config.
|
||||
* Falls back to PNG for undetectable or unsupported output formats
|
||||
* (SVG, BMP, raw camera formats like CR2/NEF).
|
||||
* (SVG, BMP, Camera RAW, TGA, PSD, EXR, HDR, ICO).
|
||||
*/
|
||||
export async function resolveOutputFormat(
|
||||
inputBuffer: Buffer,
|
||||
@@ -41,6 +45,11 @@ export async function resolveOutputFormat(
|
||||
// format detection failed
|
||||
}
|
||||
|
||||
// Force PNG fallback for formats without a Sharp output encoder
|
||||
if (detected && PNG_FALLBACK_FORMATS.has(detected)) {
|
||||
detected = undefined;
|
||||
}
|
||||
|
||||
const mapped = detected ? FORMAT_MAP[detected] : undefined;
|
||||
const config = mapped ?? PNG_FALLBACK;
|
||||
const quality = qualityOverride ?? DEFAULT_QUALITY;
|
||||
|
||||
Reference in New Issue
Block a user