raw: decode the preview already oriented and drop the second turn

Skia's Image.MakeImageFromEncoded applies the embedded JPEG's EXIF
orientation itself. A Panasonic RW2 preview stored 1920x1280 with
orientation 8 therefore decodes as 1280x1920 already — the frame the
camera meant.

previewGrid turned it a second time by its own per-orientation canvas
rotation, so a portrait frame's reference grid met the develop a quarter
turn out, previewMatch rejected the fit as folded and the RW2 opened with
no tone curve at all (~9-13 levels off across the shadows).

Drop the rotation and read the crop rectangle and aspect straight from
bmp.width()/bmp.height(), the display dimensions Skia hands back.

Panasonic RW2 now fits a real curve (dRGB 2.3,-0.0,0.4, was -9.1,-10.3,
-13.2). The five bodies that were already stable — ORF, NEF, DNG, both
RAF — measure exactly as before.

getJpegOrientation had no other caller and goes with it; raw-orf-check
drops its own byte-scanning preview finder, which crashed on DNG and RAF,
and reads the reference through the shipped extractEmbeddedJpeg.
This commit is contained in:
2026-10-06 22:10:47 +07:00
parent 3e30d5e315
commit 93f56186b9
2 changed files with 29 additions and 112 deletions
+7 -16
View File
@@ -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.
+22 -96
View File
@@ -212,48 +212,6 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
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,