From 259e5f3378c91e020b28eaa1ae5c798645f00b67 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 9 Oct 2026 15:21:38 +0700 Subject: [PATCH] fix(library): draw a .tif tile on the wall's own route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/scripts/tiff-decode-check.mjs | 62 +++++++++++++++---- docker/frontend/src/engine/rawDevelop.ts | 10 ++- docker/frontend/src/engine/tiffDecode.ts | 30 +++++---- 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/docker/frontend/scripts/tiff-decode-check.mjs b/docker/frontend/scripts/tiff-decode-check.mjs index 33358ba..cd9f539 100644 --- a/docker/frontend/scripts/tiff-decode-check.mjs +++ b/docker/frontend/scripts/tiff-decode-check.mjs @@ -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); diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index a786ab5..cf95387 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -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; diff --git a/docker/frontend/src/engine/tiffDecode.ts b/docker/frontend/src/engine/tiffDecode.ts index e2ba2d5..fd13de2 100644 --- a/docker/frontend/src/engine/tiffDecode.ts +++ b/docker/frontend/src/engine/tiffDecode.ts @@ -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(); }