fix(raw): fix RAW preview matching, EXIF orientation alignment & studio initial state
This commit is contained in:
@@ -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)));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -578,11 +578,9 @@ export function Workspace() {
|
||||
}, []);
|
||||
|
||||
const defaultLook = useCallback((): Look => {
|
||||
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
||||
const cur = lookRef.current?.recipe ?? DEFAULT_RECIPES[0];
|
||||
return {
|
||||
recipe: { ...(provia ? filmSimToRecipe(provia) : cur), id: cur.id, name: cur.name, isCustom: true },
|
||||
simId: provia ? provia.id : null,
|
||||
recipe: { ...BASE_RECIPE, id: 'web-current', name: 'BASE', isCustom: true },
|
||||
simId: null,
|
||||
frameId: 'none',
|
||||
crop: 'none',
|
||||
cropRect: DEFAULT_CROP_RECT,
|
||||
@@ -1054,10 +1052,9 @@ export function Workspace() {
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
setIso(foundIso);
|
||||
// A new photo starts a new history whose floor is the default look, so
|
||||
// UNDO walks all the way back and still leaves the photo loaded. REDO has
|
||||
// nothing to walk forward onto in a history that just began.
|
||||
setUndo([defaultLook()]);
|
||||
const def = defaultLook();
|
||||
restoreLook(def);
|
||||
setUndo([def]);
|
||||
setRedo([]);
|
||||
if (keepGeo) return;
|
||||
setUseGeotag(!!found);
|
||||
@@ -1068,7 +1065,7 @@ export function Workspace() {
|
||||
// location prompt.
|
||||
if (!found && pro && canLocate && !locDeniedRef.current) locateMe(taken);
|
||||
},
|
||||
[defaultLook, pro, locateMe]
|
||||
[defaultLook, restoreLook, pro, locateMe]
|
||||
);
|
||||
|
||||
// A shot, from the live viewfinder into the studio. The recipe is already in
|
||||
|
||||
@@ -212,13 +212,56 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
|
||||
return new Uint8Array(thumb.data);
|
||||
}
|
||||
|
||||
// Reads EXIF Orientation tag (1-8) from JPEG byte headers.
|
||||
export function getJpegOrientation(bytes: Uint8Array): number {
|
||||
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1;
|
||||
let offset = 2;
|
||||
while (offset < bytes.length - 4) {
|
||||
if (bytes[offset] !== 0xff) { offset++; continue; }
|
||||
const marker = bytes[offset + 1];
|
||||
if (marker === 0xe1) {
|
||||
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
if (offset + 4 + length > bytes.length) break;
|
||||
const exifHeader = String.fromCharCode(...bytes.subarray(offset + 4, offset + 10));
|
||||
if (exifHeader === 'Exif\0\0') {
|
||||
const tiffStart = offset + 10;
|
||||
const littleEndian = bytes[tiffStart] === 0x49 && bytes[tiffStart + 1] === 0x49;
|
||||
const read16 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8)) : ((bytes[o] << 8) | bytes[o + 1]);
|
||||
const read32 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24)) : ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]);
|
||||
if (read16(tiffStart + 2) !== 0x002a) break;
|
||||
const firstIfdOffset = read32(tiffStart + 4);
|
||||
let dirStart = tiffStart + firstIfdOffset;
|
||||
if (dirStart + 2 > bytes.length) break;
|
||||
const entries = read16(dirStart);
|
||||
dirStart += 2;
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const entryOffset = dirStart + i * 12;
|
||||
if (entryOffset + 12 > bytes.length) break;
|
||||
const tag = read16(entryOffset);
|
||||
if (tag === 0x0112) {
|
||||
const val = read16(entryOffset + 8);
|
||||
return val >= 1 && val <= 8 ? val : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (marker === 0xda || marker === 0xd9) break;
|
||||
const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
if (segLen < 2) break;
|
||||
offset += 2 + segLen;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fast pure JS binary scanner to extract embedded JPEG preview from RAW files (ARW, NEF, CR2, CR3, DNG, RAF, RW2, ORF, PEF).
|
||||
// Runs in ~1-3ms without loading full file or initializing LibRaw WebAssembly.
|
||||
// Scans for all embedded JPEGs in the RAW file and returns the largest segment (the full-size camera preview).
|
||||
export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
const len = buf.length;
|
||||
let largest: Uint8Array | null = null;
|
||||
let maxLen = 0;
|
||||
for (let i = 0; i < len - 4; i++) {
|
||||
if (buf[i] === 0xff && buf[i + 1] === 0xd8 && buf[i + 2] === 0xff) {
|
||||
// Found JPEG SOI marker
|
||||
let pos = i + 2;
|
||||
let eoiPos = -1;
|
||||
let valid = true;
|
||||
@@ -247,7 +290,6 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
break;
|
||||
}
|
||||
if (marker === 0xda) {
|
||||
// Start of scan: entropy-coded data until EOI
|
||||
pos += 2 + segLen;
|
||||
while (pos < len - 1) {
|
||||
if (buf[pos] === 0xff) {
|
||||
@@ -267,12 +309,17 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
pos += 2 + segLen;
|
||||
}
|
||||
}
|
||||
if (valid && eoiPos > i && (eoiPos - i) > 10000) {
|
||||
return buf.subarray(i, eoiPos);
|
||||
if (valid && eoiPos > i) {
|
||||
const segSize = eoiPos - i;
|
||||
if (segSize > 10000 && segSize > maxLen) {
|
||||
maxLen = segSize;
|
||||
largest = buf.subarray(i, eoiPos);
|
||||
}
|
||||
i = eoiPos - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return largest;
|
||||
}
|
||||
|
||||
// The preview on its own, for the catalogue: a folder of RAW files has to show
|
||||
@@ -383,30 +430,70 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui
|
||||
if (!bmp) return null;
|
||||
try {
|
||||
if (bmp.width() < n || bmp.height() < n) return null;
|
||||
const orient = getJpegOrientation(jpeg);
|
||||
const isSwapped = orient === 5 || orient === 6 || orient === 7 || orient === 8;
|
||||
const orientedW = isSwapped ? bmp.height() : bmp.width();
|
||||
const orientedH = isSwapped ? bmp.width() : bmp.height();
|
||||
|
||||
const targetAspect = w / h;
|
||||
const bmpAspect = bmp.width() / bmp.height();
|
||||
let cropW = bmp.width();
|
||||
let cropH = bmp.height();
|
||||
const bmpAspect = orientedW / orientedH;
|
||||
let cropW = orientedW;
|
||||
let cropH = orientedH;
|
||||
if (Math.abs(bmpAspect - targetAspect) > 0.01) {
|
||||
if (bmpAspect > targetAspect) {
|
||||
cropW = Math.round(bmp.height() * targetAspect);
|
||||
cropW = Math.round(orientedH * targetAspect);
|
||||
} else {
|
||||
cropH = Math.round(bmp.width() / targetAspect);
|
||||
cropH = Math.round(orientedW / targetAspect);
|
||||
}
|
||||
}
|
||||
const cropX = Math.round((bmp.width() - cropW) / 2);
|
||||
const cropY = Math.round((bmp.height() - cropH) / 2);
|
||||
const cropX = Math.round((orientedW - cropW) / 2);
|
||||
const cropY = Math.round((orientedH - cropH) / 2);
|
||||
|
||||
const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n);
|
||||
if (!surface) return null;
|
||||
const canvas = surface.getCanvas();
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropX, cropY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
canvas.save();
|
||||
|
||||
if (orient === 3) {
|
||||
canvas.translate(n, n);
|
||||
canvas.rotate(180, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(bmp.width() - cropX - cropW, bmp.height() - cropY - cropH, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else if (orient === 6) {
|
||||
canvas.translate(n, 0);
|
||||
canvas.rotate(90, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropY, bmp.height() - cropX - cropW, cropH, cropW),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else if (orient === 8) {
|
||||
canvas.translate(0, n);
|
||||
canvas.rotate(-90, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(bmp.width() - cropY - cropH, cropX, cropH, cropW),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else {
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropX, cropY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
surface.flush();
|
||||
const px = canvas.readPixels(0, 0, {
|
||||
width: n,
|
||||
@@ -471,9 +558,13 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
const raw = new LibRaw();
|
||||
let preview: Uint8Array | null = null;
|
||||
try {
|
||||
// Extract largest embedded JPEG preview from file bytes first
|
||||
preview = extractEmbeddedJpeg(bytes);
|
||||
// LibRaw copies the buffer it is handed, so the caller's bytes stay intact.
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
preview = await cameraPreview(raw);
|
||||
if (!preview) {
|
||||
preview = await cameraPreview(raw);
|
||||
}
|
||||
const meta = await raw.metadata(true);
|
||||
const img = await raw.imageData();
|
||||
const cd = meta?.color_data;
|
||||
|
||||
Reference in New Issue
Block a user