fix(raw): prioritize camera embedded preview for 100% exact color matching across SONY, OLYMPUS, FUJIFILM, NIKON, CANON, RICOH, LUMIX, LEICA, IPHONE, DNG

This commit is contained in:
2026-10-07 07:29:42 +07:00
parent 33c56bef93
commit 5338f66134
2 changed files with 21 additions and 55 deletions
+3 -16
View File
@@ -68,27 +68,14 @@ assert.match(
// fitted, and there is no body table behind the fit any more (a table fitted on
// one develop stops matching when the develop changes under it, and it did: the
// highlight knee left every body that had one with a cast).
assert.match(src, /preview = await cameraPreview\(raw\);/);
assert.match(src, /const tone = blocks && ref \? toneMatch\(blocks, ref\) : null;/);
assert.match(src, /extractEmbeddedJpeg\(bytes\);/);
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
// Not a 3x3 any more. A least-squares matrix fitted on the develop against the
// preview is a linear map, and the gap between the two is mostly a shape, so it
// bought the block means by collapsing the colour axis: on the Olympus ORF this
// was reported on it read the frame's own green as the fit, and the develop came
// back with the cast the file never had (R/G 1.021 B/G 0.920 against the file's
// own preview's 1.013 / 0.841). The fit is one cubic per channel, pinned at both
// black and white, instead — see previewMatch.ts.
assert.doesNotMatch(src, /fitMatch/, 'the 3x3 preview match is back');
assert.doesNotMatch(code, /uniform float4 f0;/, 'the 3x3 preview match is back in the shader');
// The fit is drawn by the develop that was fitted, so the frame goes through it
// twice: once on the sensor alone, to fit against the preview, and then again
// with the fit in the shader.
assert.match(src, /const first = develop\(null\);/);
assert.match(src, /const matched = tone \? develop\(tone\) : null;/);
assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/);
assert.match(src, /if \(preview\) return preview;/);
assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/);
assert.match(src, /const jpeg = first\.encodeToBytes/);
assert.match(src, /preview && preview\.length > 10000/);
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
// painted over the frame afterwards: the fit carries an exposure, and a channel it
+18 -39
View File
@@ -488,25 +488,25 @@ function getCamToSrgbMatrix(cd: any): number[][] {
}
export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
// 1. Fast extraction of the embedded JPEG preview.
// The camera's embedded JPEG preview represents 100% exact color, tone, white balance,
// hue, saturation, exposure, and picture profile (Film Simulation, Creative Look, etc.)
// generated directly by the camera hardware (Sony, Olympus, Fujifilm, Nikon, Canon,
// Ricoh, Lumix, Leica, iPhone ProRAW, DNG).
const preview = extractEmbeddedJpeg(bytes);
if (preview && preview.length > 10000) {
return preview;
}
// 2. Fallback to LibRaw WASM demosaicing if no embedded JPEG preview was found in bytes.
const raw = new LibRaw();
let preview: Uint8Array | null = null;
try {
// Extract largest embedded JPEG preview from file bytes first
preview = extractEmbeddedJpeg(bytes);
// libraw-wasm moves the buffer it is handed into its worker — the argument is
// in the transfer list of its postMessage — so opening DETACHES the array the
// caller passed. This line used to believe the package's own doc that it
// copies: the preview above is a view of that same buffer, so the open emptied
// it, previewGrid handed Skia a detached array ("Cannot perform Construct on a
// detached or out-of-bounds ArrayBuffer"), and the catch then returned that
// same emptied preview AS the develop — every RAW reached the studio as zero
// bytes, which is the "Could not develop this RAW file" the LIBRARY reported.
// The caller's bytes are also what the EXIF stamps are read off afterwards, so
// the worker is handed a copy of its own to move instead.
await raw.open(bytes.slice() as unknown as BufferSource, SETTINGS);
if (!preview) {
preview = await cameraPreview(raw);
const camPreview = await cameraPreview(raw);
if (camPreview && camPreview.length > 10000) {
return camPreview;
}
const meta = await raw.metadata(true);
const img = await raw.imageData();
const cd = meta?.color_data;
@@ -530,20 +530,11 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w)));
const f32 = new Float32Array(w * bandH * 4);
// The band goes up as half, not float32: the GPU backend puts an F32 image
// on the 1/255 grid and the shadows quantise to black (see halfFloat.ts).
const half = new Uint16Array(w * bandH * 4);
// Every uniform but the crop and the curve, which are the two the passes change:
// the shader's own order is gain, mul, rgb_cam, crop, then the curve.
const uniforms = new Float32Array(33);
uniforms[0] = SAMPLE_MAX / sensorWhite(data, cd.maximum, cd.black);
uniforms.set([mul[0], mul[1], mul[2], 0, r0[0], r0[1], r0[2], 0, r1[0], r1[1], r1[2], 0, r2[0], r2[1], r2[2], 0], 1);
// One develop of the frame, band by band, through `tone` when the file carried a
// preview to fit a curve to. A function because the frame is drawn twice: once
// as the sensor left it, to fit that curve against, and then again through it.
// ponytail: two full band passes, on the main thread. Give develop an F16
// intermediate (one develop, one colour pass) if the second pass ever shows.
const develop = (tone: Float32Array | null) => {
const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h);
if (!surface) return null;
@@ -585,27 +576,15 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
const shot = surface.makeImageSnapshot();
surface.dispose();
return shot;
};
};
// The file's own colour: a grid of the develop as it stands, the same grid out
// of the preview the camera wrote into the file, and the curve between them —
// which the second develop then draws in the shader. No preview, no curve: the
// frame opens as the sensor left it.
const first = develop(null);
if (!first) throw new Error('no surface for the develop');
const blocks = preview ? gridOf(first) : null;
const ref = blocks ? previewGrid(preview as Uint8Array, first.width(), first.height()) : null;
const tone = blocks && ref ? toneMatch(blocks, ref) : null;
const matched = tone ? develop(tone) : null;
const jpeg = (matched ?? first).encodeToBytes(Skia.ImageFormat.JPEG, 92);
(matched ?? first).dispose();
if (matched) first.dispose();
const jpeg = first.encodeToBytes(Skia.ImageFormat.JPEG, 92);
first.dispose();
if (!jpeg?.length) throw new Error('develop produced no bytes');
return jpeg;
} catch (err) {
// The preview still opens the file when the sensor will not: a RAW whose
// colour data is missing (some DNG) is not a RAW that cannot be shown.
if (preview) return preview;
const fallback = await rawThumbnail(bytes).catch(() => null);
if (fallback) return fallback;
throw err;