diff --git a/docker/frontend/scripts/raw-orf-check.mjs b/docker/frontend/scripts/raw-orf-check.mjs new file mode 100644 index 0000000..2ede25d --- /dev/null +++ b/docker/frontend/scripts/raw-orf-check.mjs @@ -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))); diff --git a/docker/frontend/scripts/test-raw-meta.mjs b/docker/frontend/scripts/test-raw-meta.mjs new file mode 100644 index 0000000..996a9fa --- /dev/null +++ b/docker/frontend/scripts/test-raw-meta.mjs @@ -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); + } +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 474aec0..9e238ed 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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 diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 77e605b..c5a91aa 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -212,13 +212,56 @@ async function cameraPreview(raw: LibRaw): Promise { 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 { 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; diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 4311d0e..6e63d80 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -77839d17ec9f1479a12118ebc0aad6fa1db0446afa67599bf840d5e9b8e1006d recipescam-images.tar.gz +0691741305a54025579249facae529bcb54ba48b007db5e098abacb2392a458b recipescam-images.tar.gz