fix(raw): fix RAW color matrix calculation, enable highlight recovery mode, and add dedicated tiffThumbnail decoder
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
60f452b2fa01abce7084a4e347b9c222a27d2bc63d8bbfec1cf75727cc29b2e9 recipescam-images.tar.gz
|
||||
d749edf89fe3ff164055a13f1f434c1a362fd39b5939c16a2f3db0986502ef91 recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user