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: [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: "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: "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
|
// JXL ISOBMFF container
|
||||||
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
|
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
|
||||||
// JXL raw codestream
|
// JXL raw codestream
|
||||||
@@ -64,7 +77,31 @@ export interface ValidationError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Camera RAW extensions that share TIFF magic bytes. */
|
/** 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. */
|
/** 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"]);
|
||||||
@@ -219,6 +256,13 @@ function detectMagicBytes(buffer: Buffer): string | null {
|
|||||||
const brand = buffer.slice(8, 12).toString("ascii");
|
const brand = buffer.slice(8, 12).toString("ascii");
|
||||||
if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue;
|
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;
|
return entry.format;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,22 @@ export function needsCliDecode(format: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Main entry point - routes to the right decoder based on format.
|
* Main entry point - routes to the right decoder based on format.
|
||||||
* Returns a PNG buffer that Sharp can process downstream.
|
* 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) {
|
switch (format) {
|
||||||
case "raw":
|
case "raw":
|
||||||
return decodeRaw(buffer);
|
return decodeRaw(buffer, ext);
|
||||||
case "ico":
|
case "ico":
|
||||||
return decodeIco(buffer);
|
return decodeIco(buffer);
|
||||||
case "psd":
|
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> {
|
async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
|
||||||
const cmd = await findMagickCmd();
|
|
||||||
const id = randomUUID();
|
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`);
|
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFile(inputPath, buffer);
|
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(
|
await execFileAsync(
|
||||||
cmd,
|
cmd,
|
||||||
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`]),
|
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.
|
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools.
|
||||||
// The decoded buffer is PNG, so update the filename extension to match.
|
// 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)) {
|
if (needsCliDecode(validation.format)) {
|
||||||
try {
|
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];
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -314,6 +317,12 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
"image/bmp": ".bmp",
|
"image/bmp": ".bmp",
|
||||||
"image/heic": ".heic",
|
"image/heic": ".heic",
|
||||||
"image/heif": ".heif",
|
"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];
|
const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType];
|
||||||
if (expectedExt) {
|
if (expectedExt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user