diff --git a/docker/frontend/scripts/raw-orf-check.mjs b/docker/frontend/scripts/raw-orf-check.mjs index 8036ab7..37c9c3c 100644 --- a/docker/frontend/scripts/raw-orf-check.mjs +++ b/docker/frontend/scripts/raw-orf-check.mjs @@ -100,7 +100,7 @@ writeFileSync( ); writeFileSync(join(dir, 'librawNode.mjs'), LIBRAW_NODE_SRC); -const { developRaw } = await import(pathToFileURL(join(dir, 'rawDevelop.mjs')).href); +const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(dir, 'rawDevelop.mjs')).href); // `Skia` is a live binding filled in by initSkia, so read it after the call. const Skia = await (await import(pathToFileURL(join(dir, 'skiaShim.mjs')).href)).initSkia(); @@ -109,20 +109,11 @@ const file = process.argv[2] ?? '/home/locpham/RecipesCam/docker/_3279791.ORF'; assert.ok(existsSync(file), `no such file: ${file}`); const bytes = new Uint8Array(readFileSync(file)); -// The largest embedded JPEG in the file: the camera's own preview, which is both -// the reference the curve is fitted to and what the eye calls correct here. -function embeddedPreview(buf) { - let best = null; - for (let i = 0; i < buf.length - 1; i++) { - if (buf[i] !== 0xff || buf[i + 1] !== 0xd8) continue; - let j = i + 2; - while (j < buf.length - 1 && !(buf[j] === 0xff && buf[j + 1] === 0xd9)) j++; - if (j < buf.length - 1 && (!best || j - i > best[1] - best[0])) best = [i, j + 2]; - i = j + 2; - } - return best ? buf.subarray(best[0], best[1]) : null; -} - +// The preview the curve is fitted to is the one the develop itself reads — +// `extractEmbeddedJpeg`, the same call the app makes. The byte-scanning copy that +// stood here found a JPEG in an ORF and nothing in a DNG or a RAF (their previews +// live behind a container this scanner's marker walk does not follow), so the +// reference came back undecodable and the check died on those two bodies. const jpeg = await developRaw(bytes); const out = file.replace(/\.\w+$/, '') + '-develop-check.jpg'; writeFileSync(out, jpeg); @@ -153,7 +144,7 @@ function readPixels(jpegBytes, w, h) { const N = 256; const dev = readPixels(jpeg, N, N); -const ref = readPixels(embeddedPreview(bytes), N, N); +const ref = readPixels(extractEmbeddedJpeg(bytes), N, N); // Bands by how bright the *camera's own* rendering says the block is: a neutral // render has both ratios at 1 there, a green/yellow veil pulls them apart. diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index a81c5e9..2d29346 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -212,48 +212,6 @@ async function cameraPreview(raw: LibRaw): Promise { return new Uint8Array(thumb.data); } -// Reads EXIF Orientation tag (1-8) from JPEG byte headers. -export function getJpegOrientation(bytes: Uint8Array): number { - if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1; - let offset = 2; - while (offset < bytes.length - 4) { - if (bytes[offset] !== 0xff) { offset++; continue; } - const marker = bytes[offset + 1]; - if (marker === 0xe1) { - const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; - if (offset + 4 + length > bytes.length) break; - const exifHeader = String.fromCharCode(...bytes.subarray(offset + 4, offset + 10)); - if (exifHeader === 'Exif\0\0') { - const tiffStart = offset + 10; - const littleEndian = bytes[tiffStart] === 0x49 && bytes[tiffStart + 1] === 0x49; - const read16 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8)) : ((bytes[o] << 8) | bytes[o + 1]); - const read32 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24)) : ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]); - if (read16(tiffStart + 2) !== 0x002a) break; - const firstIfdOffset = read32(tiffStart + 4); - let dirStart = tiffStart + firstIfdOffset; - if (dirStart + 2 > bytes.length) break; - const entries = read16(dirStart); - dirStart += 2; - for (let i = 0; i < entries; i++) { - const entryOffset = dirStart + i * 12; - if (entryOffset + 12 > bytes.length) break; - const tag = read16(entryOffset); - if (tag === 0x0112) { - const val = read16(entryOffset + 8); - return val >= 1 && val <= 8 ? val : 1; - } - } - } - break; - } - if (marker === 0xda || marker === 0xd9) break; - const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3]; - if (segLen < 2) break; - offset += 2 + segLen; - } - return 1; -} - // Fast pure JS binary scanner to extract embedded JPEG preview from RAW files (ARW, NEF, CR2, CR3, DNG, RAF, RW2, ORF, PEF). // Scans for all embedded JPEGs in the RAW file and returns the largest segment (the full-size camera preview). export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null { @@ -437,70 +395,38 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui if (!bmp) return null; try { if (bmp.width() < n || bmp.height() < n) return null; - const orient = getJpegOrientation(jpeg); - const isSwapped = orient === 5 || orient === 6 || orient === 7 || orient === 8; - const orientedW = isSwapped ? bmp.height() : bmp.width(); - const orientedH = isSwapped ? bmp.width() : bmp.height(); - + // The decode above has already turned the preview by its own EXIF + // orientation: a 1920x1280 orientation-8 Panasonic preview decodes to + // 1280x1920, the frame the camera meant. So these are display dimensions and + // the only thing left is to trim them to the develop's aspect. Rotating here + // as well — which this did — turned a portrait frame's reference a quarter + // turn against the develop it was being fitted to, and the fit was rejected + // as folded (see previewMatch), which is why the Panasonic RW2 opened with no + // curve at all. const targetAspect = w / h; - const bmpAspect = orientedW / orientedH; - let cropW = orientedW; - let cropH = orientedH; + const bmpAspect = bmp.width() / bmp.height(); + let cropW = bmp.width(); + let cropH = bmp.height(); if (Math.abs(bmpAspect - targetAspect) > 0.01) { if (bmpAspect > targetAspect) { - cropW = Math.round(orientedH * targetAspect); + cropW = Math.round(bmp.height() * targetAspect); } else { - cropH = Math.round(orientedW / targetAspect); + cropH = Math.round(bmp.width() / targetAspect); } } - const cropX = Math.round((orientedW - cropW) / 2); - const cropY = Math.round((orientedH - cropH) / 2); + const cropX = Math.round((bmp.width() - cropW) / 2); + const cropY = Math.round((bmp.height() - cropH) / 2); const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n); if (!surface) return null; const canvas = surface.getCanvas(); - canvas.save(); - - if (orient === 3) { - canvas.translate(n, n); - canvas.rotate(180, 0, 0); - canvas.drawImageRectCubic( - bmp, - Skia.XYWHRect(bmp.width() - cropX - cropW, bmp.height() - cropY - cropH, cropW, cropH), - Skia.XYWHRect(0, 0, n, n), - 1 / 3, - 1 / 3 - ); - } else if (orient === 6) { - canvas.translate(n, 0); - canvas.rotate(90, 0, 0); - canvas.drawImageRectCubic( - bmp, - Skia.XYWHRect(cropY, bmp.height() - cropX - cropW, cropH, cropW), - Skia.XYWHRect(0, 0, n, n), - 1 / 3, - 1 / 3 - ); - } else if (orient === 8) { - canvas.translate(0, n); - canvas.rotate(-90, 0, 0); - canvas.drawImageRectCubic( - bmp, - Skia.XYWHRect(bmp.width() - cropY - cropH, cropX, cropH, cropW), - Skia.XYWHRect(0, 0, n, n), - 1 / 3, - 1 / 3 - ); - } else { - canvas.drawImageRectCubic( - bmp, - Skia.XYWHRect(cropX, cropY, cropW, cropH), - Skia.XYWHRect(0, 0, n, n), - 1 / 3, - 1 / 3 - ); - } - canvas.restore(); + canvas.drawImageRectCubic( + bmp, + Skia.XYWHRect(cropX, cropY, cropW, cropH), + Skia.XYWHRect(0, 0, n, n), + 1 / 3, + 1 / 3 + ); surface.flush(); const px = canvas.readPixels(0, 0, { width: n,