Files
RecipesCam/docker/frontend/scripts/raw-orf-check.mjs
T
3dtours 8ed094cd9e fix(raw): stop the develop from handing the studio zero bytes
libraw-wasm moves the buffer it is given into its worker, so raw.open() detaches
the caller's array. developRaw read its embedded JPEG preview as a view of that
same array, so the open emptied the preview, Skia rejected the detached buffer
and the catch returned the emptied preview as the develop - every RAW opened from
the LIBRARY reached the studio as 0 bytes and the app said "Could not develop
this RAW file. Try again, or use its JPG."

Hand the worker a copy instead, in the develop and in the thumbnail path. The
EXIF stamps read off the caller's bytes afterwards are intact again.

The ORF check now hands the bytes over in the transfer list the way the package
does, so it fails on any develop that forgets this.
2026-10-06 16:16:32 +07:00

185 lines
7.7 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 } = 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)));