fix(raw): fix RAW preview matching, EXIF orientation alignment & studio initial state

This commit is contained in:
2026-10-06 12:23:38 +07:00
parent 23f534ffb8
commit 2e1feb195a
6 changed files with 333 additions and 31 deletions
+186
View File
@@ -0,0 +1,186 @@
// Develops a real ORF through the app's own pipeline in Node — libraw-wasm on a
// worker_threads shim, CanvasKit on the raster backend, the shipped
// RAW_DEVELOP_SKSL and the shipped toneMatch — and measures the frame's
// highlights against the preview the camera wrote inside the file. A cast that
// lives only in the highlights is invisible to a source-regex check and to the
// eye of anyone looking at the moonlit part of the frame.
//
// node scripts/raw-orf-check.mjs [file.ORF]
//
// Writes the develop next to the file for eyeballing.
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
// libraw-wasm drives an Emscripten worker written for the browser: it speaks
// `self.onmessage` / `self.postMessage`, where Node has parentPort. So the worker
// is booted behind a shim that renames one into the other, and the package's own
// message protocol ({ id, fn, args } / { id, out, error }) is spoken here.
// The worker is handed the file bytes copy itself — the transfer list would
// detach the caller's.
const LIBRAW_NODE_SRC = `
import { Worker } from 'node:worker_threads';
const SHIM = \`
import { parentPort, workerData } from 'node:worker_threads';
import { readFile } from 'node:fs/promises';
globalThis.self = globalThis;
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
Object.defineProperty(globalThis, 'onmessage', {
configurable: true,
set(fn) { parentPort.on('message', (data) => fn({ data })); },
});
// Emscripten asks for the wasm by the bare name it was built with ('libraw.wasm')
// and Node's fetch has no base to resolve that against.
const base = new URL('.', workerData.url);
globalThis.fetch = async (u) =>
new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } });
await import(workerData.url);
\`;
export default class LibRaw {
constructor() {
const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href;
this.worker = new Worker(SHIM, { eval: true, workerData: { url } });
this.pending = new Map();
this.next = 0;
this.worker.on('message', ({ id, out, error }) => {
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
if (error) p.reject(new Error(error));
else p.resolve(out);
});
}
run(fn, ...args) {
return new Promise((resolve, reject) => {
const id = this.next++;
this.pending.set(id, { resolve, reject });
const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer);
this.worker.postMessage({ id, fn, args }, transfer);
});
}
open(bytes, settings) {
const copy = Uint8Array.from(bytes);
return this.run('open', copy, settings);
}
metadata(full) { return this.run('metadata', !!full); }
imageData() { return this.run('imageData'); }
thumbnailData() { return this.run('thumbnailData'); }
dispose() { return this.worker.terminate(); }
}
`;
const dir = mkdtempSync(join(tmpdir(), 'raw-orf-check-'));
const ckEntry = import.meta.resolve('canvaskit-wasm/full');
writeFileSync(
join(dir, 'skiaShim.mjs'),
transpile('../src/engine/skiaShim.ts').replace(
/^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m,
`import CanvasKitInit from ${JSON.stringify(ckEntry)};`,
),
);
writeFileSync(join(dir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts'));
writeFileSync(join(dir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts'));
writeFileSync(join(dir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts'));
writeFileSync(
join(dir, 'rawDevelop.mjs'),
transpile('../src/engine/rawDevelop.ts')
.replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";')
.replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"')
.replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"')
.replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"')
.replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"'),
);
writeFileSync(join(dir, 'librawNode.mjs'), LIBRAW_NODE_SRC);
const { developRaw } = 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();
// --- measure -----------------------------------------------------------------
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;
}
const jpeg = await developRaw(bytes);
const out = file.replace(/\.\w+$/, '') + '-develop-check.jpg';
writeFileSync(out, jpeg);
console.log(`develop ${jpeg.length} bytes -> ${out}`);
function readPixels(jpegBytes, w, h) {
const img = Skia.Image.MakeImageFromEncoded(jpegBytes);
assert.ok(img, 'decode failed');
const surface = Skia.Surface.Make(w, h);
const paint = Skia.Paint();
paint.setAntiAlias(false);
surface.getCanvas().drawImageRectOptions(
img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h),
Skia.FilterMode.Linear, Skia.MipmapMode.None, paint,
);
surface.flush();
const shot = surface.makeImageSnapshot();
const px = shot.readPixels(0, 0, {
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
});
img.delete();
shot.delete();
surface.dispose();
paint.delete();
return px;
}
const N = 256;
const dev = readPixels(jpeg, N, N);
const ref = readPixels(embeddedPreview(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.
const rows = [];
for (let i = 0; i < N * N; i++) {
const r = ref[i * 4], g = ref[i * 4 + 1], b = ref[i * 4 + 2];
rows.push({
l: (r + g + b) / 3,
r, g, b,
d: [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]],
});
}
rows.sort((a, b) => b.l - a.l);
const report = (label, sel) => {
const m = (f) => sel.reduce((s, x) => s + f(x), 0) / sel.length;
const [r, g, b] = [m((x) => x.d[0]), m((x) => x.d[1]), m((x) => x.d[2])];
const [rr, rg, rb] = [m((x) => x.r), m((x) => x.g), m((x) => x.b)];
console.log(
`${label} n=${sel.length}` +
` | develop R/G ${(r / g).toFixed(3)} B/G ${(b / g).toFixed(3)}` +
` | preview R/G ${(rr / rg).toFixed(3)} B/G ${(rb / rg).toFixed(3)}` +
` | dRGB ${(r - rr).toFixed(1)},${(g - rg).toFixed(1)},${(b - rb).toFixed(1)}`,
);
};
report('all ', rows);
report('bright top 5% ', rows.slice(0, Math.round(N * N * 0.05)));
report('bright top 20% ', rows.slice(0, Math.round(N * N * 0.2)));
report('shadows bot 50% ', rows.slice(Math.round(N * N * 0.5)));
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import LibRaw from 'libraw-wasm';
const files = [
'D0004128.DNG',
'DSCF1701.RAF',
'P1010256.RW2',
'_3279791.ORF',
'APACHAI-20160215-357.NEF'
];
for (const file of files) {
try {
const bytes = readFileSync('../' + file);
const raw = new LibRaw();
await raw.open(bytes, { halfSize: true, noAutoScale: true });
const meta = await raw.metadata(true);
const cd = meta?.color_data;
console.log(`=== ${file} ===`);
console.log('cam_mul:', cd?.cam_mul);
console.log('pre_mul:', cd?.pre_mul);
console.log('rgb_cam:', cd?.rgb_cam);
console.log('cam_xyz:', cd?.cam_xyz);
raw.dispose();
} catch (err) {
console.log(`Error reading ${file}:`, err.message);
}
}