fix(library): read a .tif's own pixels for its tile

A `.tif` in an album drew nothing: the tile took the file's embedded JPEG —
which a TIFF has not got, so the search found the frame's own pixel data and
kept three hundred kilobytes of it as the frame's tile — and nothing else in
the app draws a TIFF either, `createImageBitmap` answering InvalidStateError
and CanvasKit's `MakeImageFromEncoded` answering null to one.
`src/engine/tiffDecode.ts` reads the file itself, strips or tiles, and hands
back a shrunk frame, which is what the tile is made of now. The tiles that
were kept are dropped once (v4) so a frame already looked at is drawn right.
This commit is contained in:
2026-10-09 15:03:53 +07:00
parent cfcd7e0bb0
commit 703e2cd421
5 changed files with 695 additions and 37 deletions
+65 -32
View File
@@ -55,6 +55,7 @@ import { sensorWhite } from './sensorWhite';
import { f32ToF16 } from './halfFloat';
import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch';
import { Skia } from './skiaShim';
import { decodeTiff } from './tiffDecode';
// What `imageData()` returns for the settings below: 16-bit, 3 channels, black
// level already gone — the post-process subtracts it whatever `noAutoScale`
@@ -291,58 +292,90 @@ 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.
//
// What CanvasKit draws, and only that: a TIFF goes to `decodeTiff` below, since
// `MakeImageFromEncoded` answers null to one (measured on a baseline 5472x3648
// scan) and the file has to be read by hand.
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;
if (img) {
try {
return shrinkToJpeg(img);
} finally {
img.delete();
}
}
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;
const frame = decodeTiff(bytes, 640);
if (!frame) return null;
const image = Skia.Image.MakeImage(
{
width: frame.width,
height: frame.height,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
},
frame.rgba,
frame.width * 4
);
if (!image) return null;
try {
return shrinkToJpeg(image);
} finally {
img.delete();
image.delete();
}
} catch {
return null;
}
}
// A frame shrunk to the square the wall paints and encoded to JPEG — the one
// step of a tile that is CanvasKit's whatever the frame came out of.
function shrinkToJpeg(img: any): Uint8Array | null {
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;
}
// 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> {
// A `.tif` is not a RAW: the file IS the picture, and no camera preview is
// hiding in it — scanning the bytes for one finds the frame's own pixel data
// and hands back three hundred kilobytes of it dressed as a JPEG, which is
// what the wall used to keep as the frame's tile and draw as nothing.
if (fileName && /\.(tiff?)$/i.test(fileName)) return tiffThumbnail(bytes);
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 {
// The same transfer as in the develop: the worker is handed a copy, so the