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
+51 -11
View File
@@ -4,10 +4,10 @@
// `MakeImageFromEncoded` answers null, and LibRaw opens the file only to report a
// 0x0 frame. So the reader is the only thing standing between a scan and a blank
// cell in the wall, and it is checked here against a reference it had no hand in:
// one 8x8 crop of the sample frame, written by libvips to a PPM and to three
// TIFFs that differ only in how the strip is compressed — none, PackBits, and
// LZW with horizontal differencing — which therefore all have to come back as
// the PPM's own pixels, exactly.
// one 8x8 crop of the sample frame, written by libvips to a PPM and to four
// TIFFs that differ only in how the strip is compressed — none, PackBits, LZW
// with horizontal differencing, and JPEG — the first three of which therefore
// all have to come back as the PPM's own pixels, exactly.
//
// cd docker/frontend && node scripts/tiff-decode-check.mjs
//
@@ -22,7 +22,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
// `vips crop ../sample/P1010256.JPG out.v 1200 1600 8 8`, then
// `vips copy out.v out.{ppm,tif}`, `[compression=none|packbits|lzw]`.
// `vips copy out.v out.{ppm,tif}`, `[compression=none|packbits|lzw]` — and once
// more with `[compression=jpeg,Q=100]`, which is the other TIFF a scan comes as
// and the only one whose strip is a JPEG: that one is lossy, so its pixels are
// near the PPM's rather than equal to them.
const FIXTURES = {
'none.tif':
'SUkqAMgAAABTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4EPAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAAAQAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAwAAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA',
@@ -32,6 +35,8 @@ const FIXTURES = {
'SUkqAMQAAACAFMjkl/Px8h0PBYWC0VBIJBABgMCv2KPV6PgmEAhAgDAQPiAMBUKhQHA8FAaURR/vl8Psjj0fAMAgAQB4NgwGggIhEIA0HAsAUF9Pl+EceEAExwJhMIgKZA4HAoO1MEAkDPmsEgekEE10FAsEREBhYLBMPWcBWl8WskkAggS4Ae5V0EiAPh0L3l9vx/Pu/E8jEcA4OxXIDiMSCELhYMv7HPp9Pwpk0ngF/P/BgGoA4SCUQA0GA96Pd7SqAhAAAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFAAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAAC8AAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAPQEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
'crop.ppm':
'UDYKI3ZpcHMycHBtIC0gMjAyNi0xMC0wOVQxNTowMjozNi41MDg0MDIrMDcKOCA4CjI1NQpTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4E=',
'jpeg.tif':
'SUkqALwAAAD/2P/AABEIAAgACANSEQBHEQBCEQD/2gAMA1IARwBCAAA/APLf2HvHvimy8KeI/DQvbqy0bS7E3d4Ec2r3GqajdSBWaZCtu37v5G8zdM6IgySeflj9h3xf4qs/CviXwyL67s9G0yye7vUic2rzapqN1LtJmULC37vKN5geZ0RBz3+Qf2MdR8SnQfEWgxajf6bpmn2b3l4FY2rXGqX90wUvKmyB/wB3iNvM3zOqRkYJYn//2QAQAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAABwAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAswAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAFsBBwAhAQAAngEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA/9j/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/2Q==',
};
function fixture(name) {
@@ -73,26 +78,38 @@ const check = (what, ok, detail = '') => {
const cache = path.join(FRONTEND, 'node_modules', '.cache');
await mkdir(cache, { recursive: true });
const bundle = path.join(cache, 'tiff-decode-check.mjs');
// `jpeg.tif` is the one fixture whose strip is a JPEG, and the reader decodes
// that with the very CanvasKit the app boots — so the two have to come out of
// ONE module instance, or the shim's `Skia` stays null inside the reader. Hence
// the single stdin entry that re-exports both, rather than two bundles.
await build({
entryPoints: [path.join(FRONTEND, 'src', 'engine', 'tiffDecode.ts')],
stdin: {
contents:
"export { decodeTiff } from './src/engine/tiffDecode';\n" +
"export { initSkia } from './src/engine/skiaShim';",
resolveDir: FRONTEND,
loader: 'ts',
},
bundle: true,
format: 'esm',
platform: 'node',
target: 'es2022',
// The one thing in the reader that is CanvasKit is the JPEG-in-TIFF strip, and
// these three fixtures have no JPEG in them; leaving it out keeps the check
// about the strip reader and not about a wasm download.
external: ['canvaskit-wasm/full'],
outfile: bundle,
});
let decodeTiff;
let decodeTiff, initSkia;
try {
({ decodeTiff } = await import(pathToFileURL(bundle).href));
({ decodeTiff, initSkia } = await import(pathToFileURL(bundle).href));
} finally {
await rm(bundle, { force: true });
}
await initSkia({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const reference = readPpm(fixture('crop.ppm'));
for (const name of ['none.tif', 'pb.tif', 'lzw.tif']) {
@@ -118,6 +135,29 @@ for (const name of ['none.tif', 'pb.tif', 'lzw.tif']) {
check(name, wrong === 0 && opaque, `${frame.width}x${frame.height}, ${wrong} wrong pixels`);
}
// The JPEG strip is lossy — Q=100 is close to the PPM, not equal to it — so this
// one is checked by how far a channel strays rather than by equality. It is also
// the fixture that fails loudly when CanvasKit is not up (its `Skia` is null) or
// when the pixels are read out of the decoded image the wrong way (all black).
{
const frame = decodeTiff(fixture('jpeg.tif'), 640);
if (!frame) {
check('jpeg.tif', false, 'no frame');
} else if (frame.width !== reference.width || frame.height !== reference.height) {
check('jpeg.tif', false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
} else {
let worst = 0;
let opaque = true;
for (let i = 0; i < reference.width * reference.height; i++) {
for (let c = 0; c < 3; c++) {
worst = Math.max(worst, Math.abs(frame.rgba[i * 4 + c] - reference.pixels[i * 3 + c]));
}
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
}
check('jpeg.tif', worst <= 12 && opaque, `${frame.width}x${frame.height}, worst channel ${worst}`);
}
}
check('a file that is not a TIFF', decodeTiff(fixture('crop.ppm'), 640) === null);
check('a TIFF cut short', decodeTiff(fixture('none.tif').subarray(0, 40), 640) === null);
+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();
}