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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user