mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`]),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user