Files
RecipesCam/docker/frontend/scripts/raw-orf-check.mjs
T
3dtours 93f56186b9 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.
2026-10-06 22:10:47 +07:00

176 lines
7.6 KiB
JavaScript

// 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 file bytes are handed over in the transfer list exactly as the package
// does — so opening detaches them, and this check fails on any develop that
// forgets to keep the caller's array intact.
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) { return this.run('open', bytes, 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, 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();
// --- 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 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);
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(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.
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)));