fix(raw): fix RAW color matrix calculation, enable highlight recovery mode, and add dedicated tiffThumbnail decoder

This commit is contained in:
2026-10-05 16:03:41 +07:00
parent 480fd148a3
commit f98ce2c042
4 changed files with 142 additions and 15 deletions
+3 -2
View File
@@ -892,10 +892,11 @@ export async function scanFolder(
return;
}
const raw = isRawName(file.name);
const isTiff = /\.(tiff?)$/i.test(file.name);
const RAW_HEADER_BYTES = 4 * 1024 * 1024;
const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer();
const sliceBuffer = await file.slice(0, (raw || isTiff) ? (isTiff ? Math.min(file.size, 16 * 1024 * 1024) : RAW_HEADER_BYTES) : HEAD_BYTES).arrayBuffer();
const bytes = new Uint8Array(sliceBuffer);
const preview = raw ? await rawThumbnail(bytes) : null;
const preview = (raw || isTiff) ? await rawThumbnail(bytes, file.name) : null;
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
const thumb = src ? await tile(src, size) : null;
+138 -12
View File
@@ -76,7 +76,7 @@ const SETTINGS = {
noAutoBright: true,
gamm: [1, 1] as [number, number],
userQual: 3,
highlight: 0,
highlight: 2,
};
// 16-bit samples, but everything below is [0,1] — Skia reads an F32 image as
@@ -195,7 +195,6 @@ half4 main(float2 pos) {
const RAW_EXT = [
'arw', 'srf', 'sr2', 'cr2', 'cr3', 'crw', 'nef', 'nrw', 'dng', 'orf', 'raf',
'rw2', 'pef', 'srw', 'raw', 'rwl', '3fr', 'fff', 'iiq', 'mos', 'mrw', 'x3f',
'tif', 'tiff',
];
export const RAW_ACCEPT = RAW_EXT.map((e) => `.${e}`).join(',');
@@ -284,16 +283,64 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
// above is a full decode of every pixel at full resolution. No preview inside
// the file means no tile — the row still lists the frame by name, and the
// studio develops it the moment it is opened.
export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
export function tiffThumbnail(bytes: Uint8Array): Uint8Array | null {
try {
const img = Skia.Image.MakeImageFromEncoded(bytes);
if (!img) return null;
try {
const w = img.width();
const h = img.height();
if (!w || !h) return null;
const n = 640;
const wide = w >= h;
const targetW = wide ? n : Math.max(1, Math.round((w / h) * n));
const targetH = wide ? Math.max(1, Math.round((h / w) * n)) : n;
const surface = Skia.Surface.MakeOffscreen(targetW, targetH) ?? Skia.Surface.Make(targetW, targetH);
if (!surface) return null;
const canvas = surface.getCanvas();
canvas.drawImageRectCubic(
img,
Skia.XYWHRect(0, 0, w, h),
Skia.XYWHRect(0, 0, targetW, targetH),
1 / 3,
1 / 3
);
surface.flush();
const snapshot = surface.makeImageSnapshot();
surface.dispose();
if (!snapshot) return null;
const jpeg = snapshot.encodeToBytes(Skia.ImageFormat.JPEG, 80);
snapshot.dispose();
return jpeg ? new Uint8Array(jpeg) : null;
} finally {
img.delete();
}
} catch {
return null;
}
}
// The preview on its own, for the catalogue: a folder of RAW files has to show
// a tile per frame, and unpack_thumb is a seek and a copy where the develop
// above is a full decode of every pixel at full resolution. No preview inside
// the file means no tile — the row still lists the frame by name, and the
// studio develops it the moment it is opened.
export async function rawThumbnail(bytes: Uint8Array, fileName?: string): Promise<Uint8Array | null> {
const fast = extractEmbeddedJpeg(bytes);
if (fast) return fast;
if (fileName && /\.(tiff?)$/i.test(fileName)) {
const tiff = tiffThumbnail(bytes);
if (tiff) return tiff;
}
const raw = new LibRaw();
try {
await raw.open(bytes as unknown as BufferSource, SETTINGS);
return await cameraPreview(raw);
} catch {
return null;
return tiffThumbnail(bytes);
} finally {
try {
raw.dispose();
@@ -338,16 +385,96 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui
const bmp = Skia.Image.MakeImageFromEncoded(jpeg);
if (!bmp) return null;
try {
// A preview of another shape is a crop of the frame, not the frame: fitting
// against it lines the two grids up on different scenes and fits nothing.
if (Math.abs(bmp.width() / bmp.height() / (w / h) - 1) > 0.02) return null;
if (bmp.width() < n * 4) return null;
return gridOf(bmp, n);
if (bmp.width() < n || bmp.height() < n) return null;
const targetAspect = w / h;
const bmpAspect = bmp.width() / bmp.height();
let cropW = bmp.width();
let cropH = bmp.height();
if (Math.abs(bmpAspect - targetAspect) > 0.01) {
if (bmpAspect > targetAspect) {
cropW = Math.round(bmp.height() * targetAspect);
} else {
cropH = Math.round(bmp.width() / targetAspect);
}
}
const cropX = Math.round((bmp.width() - cropW) / 2);
const cropY = Math.round((bmp.height() - 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
);
surface.flush();
const px = canvas.readPixels(0, 0, {
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
}) as Uint8Array | null;
surface.dispose();
return px ? new Uint8Array(px.buffer, px.byteOffset, px.byteLength) : null;
} finally {
bmp.delete();
}
}
function invert3x3(m: number[][]): number[][] | null {
const [a, b, c] = m[0];
const [d, e, f] = m[1];
const [g, h, i] = m[2];
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if (Math.abs(det) < 1e-6) return null;
const invDet = 1.0 / det;
return [
[(e * i - f * h) * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet],
[(f * g - d * i) * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet],
[(d * h - e * g) * invDet, (b * g - a * h) * invDet, (a * e - b * d) * invDet],
];
}
function getCamToSrgbMatrix(cd: any): number[][] {
if (cd?.cam_xyz && Array.isArray(cd.cam_xyz) && cd.cam_xyz.length >= 3) {
const cx = cd.cam_xyz;
const scale = (Math.abs(cx[0][0]) > 100) ? 10000.0 : 1.0;
const rX = (cx[0][0] || 0) / scale, rY = (cx[0][1] || 0) / scale, rZ = (cx[0][2] || 0) / scale;
const gX = (cx[1][0] || 0) / scale, gY = (cx[1][1] || 0) / scale, gZ = (cx[1][2] || 0) / scale;
const bX = (cx[2][0] || 0) / scale, bY = (cx[2][1] || 0) / scale, bZ = (cx[2][2] || 0) / scale;
const m00 = 3.2404542 * rX - 1.5371385 * rY - 0.4985314 * rZ;
const m01 = 3.2404542 * gX - 1.5371385 * gY - 0.4985314 * gZ;
const m02 = 3.2404542 * bX - 1.5371385 * bY - 0.4985314 * bZ;
const m10 = -0.9692660 * rX + 1.8760108 * rY + 0.0415560 * rZ;
const m11 = -0.9692660 * gX + 1.8760108 * gY + 0.0415560 * gZ;
const m12 = -0.9692660 * bX + 1.8760108 * bY + 0.0415560 * bZ;
const m20 = 0.0556434 * rX - 0.2040259 * rY + 1.0572252 * rZ;
const m21 = 0.0556434 * gX - 0.2040259 * gY + 1.0572252 * gZ;
const m22 = 0.0556434 * bX - 0.2040259 * bY + 1.0572252 * bZ;
return [[m00, m01, m02], [m10, m11, m12], [m20, m21, m22]];
}
if (cd?.rgb_cam && Array.isArray(cd.rgb_cam) && cd.rgb_cam.length >= 3) {
const m = [
[cd.rgb_cam[0][0], cd.rgb_cam[0][1], cd.rgb_cam[0][2]],
[cd.rgb_cam[1][0], cd.rgb_cam[1][1], cd.rgb_cam[1][2]],
[cd.rgb_cam[2][0], cd.rgb_cam[2][1], cd.rgb_cam[2][2]],
];
const inv = invert3x3(m);
if (inv) return inv;
}
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
}
export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
const raw = new LibRaw();
let preview: Uint8Array | null = null;
@@ -358,7 +485,7 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
const meta = await raw.metadata(true);
const img = await raw.imageData();
const cd = meta?.color_data;
if (!img || !cd?.cam_mul || !cd.rgb_cam) throw new Error('RAW has no colour data');
if (!img || !cd?.cam_mul || (!cd.rgb_cam && !cd.cam_xyz)) throw new Error('RAW has no colour data');
const { width: w, height: h } = img;
const data = img.data as Uint16Array;
@@ -374,8 +501,7 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
: [1, 1, 1, 1];
const green = rawMul[1] || rawMul[3] || 1;
const mul = rawMul.map((v: number) => v / green);
const row = (i: number) => cd.rgb_cam[i].slice(0, 3);
const [r0, r1, r2] = [row(0), row(1), row(2)];
const [r0, r1, r2] = getCamToSrgbMatrix(cd);
const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w)));
const f32 = new Float32Array(w * bandH * 4);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
60f452b2fa01abce7084a4e347b9c222a27d2bc63d8bbfec1cf75727cc29b2e9 recipescam-images.tar.gz
d749edf89fe3ff164055a13f1f434c1a362fd39b5939c16a2f3db0986502ef91 recipescam-images.tar.gz