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
+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;