fix(library): draw a .tif tile on the wall's own route

Two things kept a scanned `.tif` off the wall, and both had to be fixed for
its tile to come out as a picture.

CanvasKit was never up on the catalogue's route: the studio boots it when the
workspace mounts, but a visitor who only ever opens the library never does, so
the JPEG inside the file had no decoder and the tile was drawn as nothing. The
`.tif` branch now brings the engine up itself.

And the pixels were being read out of that JPEG through an image `readPixels`
handed a buffer of ours, which CanvasKit answers by throwing rather than
filling — the frame came back black to a check on the return value. It now
takes the buffer CanvasKit hands back, the form the canvas calls in this repo
have always used. Measured in the browser on a 5472x3648 JPEG-compressed scan:
the tile went from 2378 bytes of solid black to 68670 bytes at mean 189.2 sd
74.6, against the 189.3/74.6 the same picture's uncompressed copy yields.

`scripts/tiff-decode-check.mjs` gains the fixture that was broken — a
JPEG-compressed strip — judged by how far a channel strays from the libvips
reference rather than by equality, since that strip is lossy. Its bundle now
re-exports the shim alongside the reader so the two are one module instance and
the fixture's JPEG has a decoder behind it.
This commit is contained in:
2026-10-09 15:21:38 +07:00
parent c3d46b0d15
commit 259e5f3378
3 changed files with 76 additions and 26 deletions
+8 -2
View File
@@ -54,7 +54,7 @@ import LibRaw from 'libraw-wasm';
import { sensorWhite } from './sensorWhite';
import { f32ToF16 } from './halfFloat';
import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch';
import { Skia } from './skiaShim';
import { Skia, initSkia } from './skiaShim';
import { decodeTiff } from './tiffDecode';
// What `imageData()` returns for the settings below: 16-bit, 3 channels, black
@@ -371,7 +371,13 @@ export async function rawThumbnail(bytes: Uint8Array, fileName?: string): Promis
// 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);
if (fileName && /\.(tiff?)$/i.test(fileName)) {
// CanvasKit is what draws the JPEG inside one, and the catalogue has a route
// of its own: the studio boots the engine, a visitor who only ever opens the
// wall never does, and a tile read before it is up draws as nothing.
if (!Skia) await initSkia({ locateFile: (file) => `/wasm/${file}` });
return tiffThumbnail(bytes);
}
const fast = extractEmbeddedJpeg(bytes);
if (fast) return fast;
+17 -13
View File
@@ -238,19 +238,23 @@ export function decodeTiff(bytes: Uint8Array, maxSide: number): TiffFrame | null
const w = image.width();
const h = image.height();
if (!w || !h) return null;
const data = new Uint8Array(w * h * 4);
const done = image.readPixels(
0, 0,
{
width: w,
height: h,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
},
data, w * 4,
);
return done ? { kind: 'rgba', width: w, height: h, data } : null;
// CanvasKit's image `readPixels` only takes its own buffer: handed one of
// ours it throws, and the frame came back black to a check on its return
// value (measured on a 5472x128 strip of a JPEG-compressed scan).
const px = image.readPixels(0, 0, {
width: w,
height: h,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
return {
kind: 'rgba',
width: w,
height: h,
data: new Uint8Array(px.buffer, px.byteOffset, px.byteLength),
};
} finally {
image.delete();
}