diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 921d32b9..ddd220a5 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -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; } } diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index 49ba3965..c691ca82 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -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 { +export async function decodeToSharpCompat( + buffer: Buffer, + format: string, + ext?: string, +): Promise { 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 { } } -// ── 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 { - const cmd = await findMagickCmd(); +async function decodeRaw(buffer: Buffer, ext?: string): Promise { 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}`]), diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 87055957..b6eb8b94 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -192,9 +192,12 @@ export function createToolRoute(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(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) {