187 lines
7.7 KiB
JavaScript
187 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 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)));
|