feat: expand camera RAW support with exiftool-first decode strategy

- Add CR3 (Canon) brand detection in ftyp magic bytes, returning "raw"
  to route through the RAW decode pipeline
- Add magic bytes for Fujifilm RAF, Sigma X3F, and Minolta MRW formats
- Expand RAW_EXTENSIONS set from 6 to 24 formats (CR3, NRW, RAF, PEF,
  3FR, IIQ, SRW, X3F, RWL, GPR, FFF, MRW, MEF, KDC, DCR, ERF, PTX)
- Rewrite decodeRaw() with two-tier strategy: ExifTool embedded JPEG
  extraction (fast path) with ImageMagick+LibRaw fallback (full decode)
- Add ext parameter to decodeToSharpCompat/decodeRaw so temp files use
  the original extension for correct format identification
- Pass fileExt from tool-factory when calling decodeToSharpCompat
- Add JXL, ICO, PSD, EXR, HDR, TGA MIME entries to CONTENT_TYPE_TO_EXT
This commit is contained in:
SnapOtter
2026-05-07 23:59:47 +08:00
parent f66ce9add8
commit 5205f4a215
3 changed files with 103 additions and 8 deletions
+45 -1
View File
@@ -38,6 +38,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
@@ -64,7 +77,31 @@ 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"]);
@@ -219,6 +256,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;
}
}
+48 -6
View File
@@ -17,11 +17,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":
@@ -84,16 +95,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}`]),
+10 -1
View File
@@ -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) {
@@ -314,6 +317,12 @@ 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",
};
const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType];
if (expectedExt) {