// 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 } = 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)));