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:
@@ -4,7 +4,7 @@ import { go } from './nav';
|
||||
import { api } from './api';
|
||||
import { keep, recall } from './remember';
|
||||
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||
import { isRawName, rawThumbnail, tiffThumbnail } from './engine/rawDevelop';
|
||||
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
cachedFolders,
|
||||
@@ -1643,7 +1643,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
} else if (isRawName(file.name)) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!live) return;
|
||||
const previewBytes = await rawThumbnail(bytes);
|
||||
const previewBytes = await rawThumbnail(bytes, file.name);
|
||||
if (previewBytes && live) {
|
||||
const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
|
||||
url = URL.createObjectURL(blob);
|
||||
@@ -2777,8 +2777,7 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
||||
} else if (isRawName(name) || /\.(tiff?)$/i.test(name)) {
|
||||
const fullBytes = new Uint8Array(await file.arrayBuffer());
|
||||
let prevBytes = await rawThumbnail(fullBytes, name);
|
||||
if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes);
|
||||
const prevBytes = await rawThumbnail(fullBytes, name);
|
||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
||||
} else {
|
||||
// A frame the wall can decode itself: the tile is made here, at the
|
||||
|
||||
@@ -24,7 +24,7 @@ const PHOTOS = 'photos';
|
||||
const THUMBS = 'thumbs';
|
||||
const EDITS = 'edits';
|
||||
const DIRS = 'dirs';
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 4;
|
||||
|
||||
// The tile the catalogue keeps is the same tile the grid cell and the strip both
|
||||
// paint, and there is one of them per frame of a roll that reaches six figures.
|
||||
@@ -236,6 +236,13 @@ function openDb(): Promise<IDBDatabase> {
|
||||
req.onblocked = () => blockedSink?.(true);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
// v4: every catalogue kept a tile for every frame, and a `.tif`'s tile was
|
||||
// the frame's own pixel data read as if it were a JPEG — the bytes are not
|
||||
// a picture, so the tile drew as nothing and, being kept, went on drawing
|
||||
// as nothing on every visit after. The tiles are dropped; each is made
|
||||
// again the first time its frame is looked at, which is when it was going
|
||||
// to be made anyway.
|
||||
if (db.objectStoreNames.contains(THUMBS)) db.deleteObjectStore(THUMBS);
|
||||
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
if (!db.objectStoreNames.contains(PHOTOS)) {
|
||||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
// TIFF → RGBA, for the catalogue's tiles.
|
||||
//
|
||||
// A `.tif` in an album had no tile, and none of the three decoders the app
|
||||
// already carries will make one. Measured on a baseline 5472x3648 scan written
|
||||
// by vips: `createImageBitmap` answers InvalidStateError, CanvasKit's
|
||||
// `MakeImageFromEncoded` answers null — so `tiffThumbnail` never returned
|
||||
// anything for a TIFF at all — and LibRaw opens the file only to report a 0x0
|
||||
// frame with no thumbnail inside it.
|
||||
//
|
||||
// So the frame is read here instead: the header, the strips or tiles with their
|
||||
// compression, the photometric, and one row in `step` out of the image. A 640px
|
||||
// tile shows no more of a 5472px row than that, which is why the full-size row
|
||||
// is never built — a scan album is hundreds of megabytes a frame and the tile
|
||||
// is the only thing anyone asked for.
|
||||
//
|
||||
// What is covered: little and big endian, strips and tiles, chunky (planar 1),
|
||||
// 1/8/16-bit, grey / RGB / palette, compression none, LZW (5), PackBits
|
||||
// (32773) and both JPEG-in-TIFF flavours (6 and 7, through CanvasKit, which is
|
||||
// the one thing in here that does decode a JPEG). YCbCr outside a JPEG and CMYK
|
||||
// are not decoded and answer null — the row still lists the frame by name.
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
|
||||
export interface TiffFrame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
}
|
||||
|
||||
// An IFD longer than this is not a camera file, it is something reading one.
|
||||
const MAX_ENTRIES = 4096;
|
||||
|
||||
interface Entry {
|
||||
type: number;
|
||||
count: number;
|
||||
/** Where the value's bytes are, inline in the entry or out at an offset. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
/** Position in the image this chunk's pixels start at. */
|
||||
x0: number;
|
||||
y0: number;
|
||||
/** Width of a stored row, which a tile pads out to its own tile width. */
|
||||
stride: number;
|
||||
width: number;
|
||||
height: number;
|
||||
offset: number;
|
||||
length: number;
|
||||
/** Set for the two JPEG flavours: the stream itself, tables already joined. */
|
||||
jpeg?: Uint8Array;
|
||||
}
|
||||
|
||||
interface Samples {
|
||||
kind: 'samples';
|
||||
width: number;
|
||||
height: number;
|
||||
spp: number;
|
||||
depth: number;
|
||||
data: Uint8Array | Uint16Array;
|
||||
}
|
||||
|
||||
interface Rgba {
|
||||
kind: 'rgba';
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
type Decoded = Samples | Rgba;
|
||||
|
||||
export function decodeTiff(bytes: Uint8Array, maxSide: number): TiffFrame | null {
|
||||
if (bytes.length < 8) return null;
|
||||
const little = bytes[0] === 0x49 && bytes[1] === 0x49;
|
||||
if (!little && !(bytes[0] === 0x4d && bytes[1] === 0x4d)) return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const u16 = (at: number) => view.getUint16(at, little);
|
||||
const u32 = (at: number) => view.getUint32(at, little);
|
||||
if (u16(2) !== 42) return null;
|
||||
|
||||
const valueSize = (type: number) => {
|
||||
if (type === 1 || type === 2 || type === 6 || type === 7) return 1;
|
||||
if (type === 3 || type === 8) return 2;
|
||||
if (type === 4 || type === 9 || type === 11) return 4;
|
||||
if (type === 5 || type === 10 || type === 12) return 8;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const readIfd = (offset: number): Map<number, Entry> | null => {
|
||||
if (offset < 8 || offset + 2 > bytes.length) return null;
|
||||
const count = u16(offset);
|
||||
if (count > MAX_ENTRIES || offset + 2 + count * 12 > bytes.length) return null;
|
||||
const tags = new Map<number, Entry>();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = offset + 2 + i * 12;
|
||||
const type = u16(entry + 2);
|
||||
const n = u32(entry + 4);
|
||||
const size = valueSize(type) * n;
|
||||
if (!size || n > 1 << 22) continue;
|
||||
const at = size <= 4 ? entry + 8 : u32(entry + 8);
|
||||
if (at < 0 || at + size > bytes.length) continue;
|
||||
tags.set(u16(entry), { type, count: n, at });
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
const num = (tags: Map<number, Entry>, tag: number, fallback = 0) => {
|
||||
const entry = tags.get(tag);
|
||||
if (!entry) return fallback;
|
||||
if (entry.type === 3) return u16(entry.at);
|
||||
if (entry.type === 4 || entry.type === 9) return u32(entry.at);
|
||||
if (entry.type === 1 || entry.type === 6 || entry.type === 7) return bytes[entry.at];
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const nums = (tags: Map<number, Entry>, tag: number) => {
|
||||
const entry = tags.get(tag);
|
||||
if (!entry) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < entry.count; i++) {
|
||||
if (entry.type === 3) out.push(u16(entry.at + i * 2));
|
||||
else if (entry.type === 4) out.push(u32(entry.at + i * 4));
|
||||
else out.push(bytes[entry.at + i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const tags = readIfd(u32(4));
|
||||
if (!tags) return null;
|
||||
|
||||
const width = num(tags, 256);
|
||||
const height = num(tags, 257);
|
||||
if (width < 1 || height < 1) return null;
|
||||
|
||||
const compression = num(tags, 259, 1);
|
||||
const photometric = num(tags, 262, 2);
|
||||
const spp = num(tags, 277, 1);
|
||||
if (num(tags, 284, 1) !== 1) return null;
|
||||
if (spp < 1 || spp > 4) return null;
|
||||
if (photometric === 5) return null;
|
||||
if (photometric === 6 && compression !== 6 && compression !== 7) return null;
|
||||
if (photometric === 2 && spp < 3) return null;
|
||||
|
||||
let depths = nums(tags, 258);
|
||||
if (!depths.length) depths = [1];
|
||||
if (depths.length === 1 && spp > 1) depths = new Array(spp).fill(depths[0]);
|
||||
if (depths.length !== spp) return null;
|
||||
const depth = depths[0];
|
||||
if (depths.some((d) => d !== depth)) return null;
|
||||
if (depth !== 1 && depth !== 8 && depth !== 16) return null;
|
||||
|
||||
const predictor = num(tags, 317, 1);
|
||||
if (predictor !== 1 && predictor !== 2) return null;
|
||||
|
||||
const tileWidth = num(tags, 322);
|
||||
const tileHeight = num(tags, 323);
|
||||
const tiled = tileWidth > 0 && tileHeight > 0 && tags.has(324);
|
||||
const rowsPerStrip = Math.max(1, num(tags, 278, height) || height);
|
||||
|
||||
const chunks = new Map<number, Chunk>();
|
||||
const tables = tags.get(347);
|
||||
const jpegTables = tables ? bytes.subarray(tables.at, tables.at + tables.count) : null;
|
||||
|
||||
if (compression === 6) {
|
||||
const offsets = nums(tags, 273);
|
||||
const lengths = nums(tags, 279);
|
||||
const offset = num(tags, 513, offsets[0] ?? -1);
|
||||
const length = num(tags, 514, lengths[0] ?? 0);
|
||||
if (offset < 0 || offset + length > bytes.length || !length) return null;
|
||||
chunks.set(0, {
|
||||
x0: 0, y0: 0, stride: width, width, height, offset, length,
|
||||
jpeg: bytes.subarray(offset, offset + length),
|
||||
});
|
||||
} else if (tiled) {
|
||||
const offsets = nums(tags, 324);
|
||||
const lengths = nums(tags, 325);
|
||||
const across = Math.ceil(width / tileWidth);
|
||||
const down = Math.ceil(height / tileHeight);
|
||||
for (let ty = 0; ty < down; ty++) {
|
||||
for (let tx = 0; tx < across; tx++) {
|
||||
const key = ty * across + tx;
|
||||
const offset = offsets[key];
|
||||
const length = lengths[key];
|
||||
if (offset == null || !length || offset + length > bytes.length) continue;
|
||||
const x0 = tx * tileWidth;
|
||||
const y0 = ty * tileHeight;
|
||||
const chunk: Chunk = {
|
||||
x0, y0, stride: tileWidth,
|
||||
width: Math.min(tileWidth, width - x0),
|
||||
height: Math.min(tileHeight, height - y0),
|
||||
offset, length,
|
||||
};
|
||||
if (compression === 7) {
|
||||
const scan = bytes.subarray(offset, offset + length);
|
||||
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
|
||||
}
|
||||
chunks.set(key, chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const offsets = nums(tags, 273);
|
||||
const lengths = nums(tags, 279);
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
const y0 = i * rowsPerStrip;
|
||||
if (y0 >= height) break;
|
||||
const offset = offsets[i];
|
||||
const length = lengths[i] ?? 0;
|
||||
if (offset < 0 || !length || offset + length > bytes.length) continue;
|
||||
const chunk: Chunk = {
|
||||
x0: 0, y0, stride: width, width,
|
||||
height: Math.min(rowsPerStrip, height - y0),
|
||||
offset, length,
|
||||
};
|
||||
if (compression === 7) {
|
||||
const scan = bytes.subarray(offset, offset + length);
|
||||
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
|
||||
}
|
||||
chunks.set(i, chunk);
|
||||
}
|
||||
}
|
||||
if (!chunks.size) return null;
|
||||
|
||||
const keyOf = tiled
|
||||
? (sx: number, sy: number) => Math.floor(sy / tileHeight) * Math.ceil(width / tileWidth) + Math.floor(sx / tileWidth)
|
||||
: (_sx: number, sy: number) => Math.floor(sy / rowsPerStrip);
|
||||
|
||||
const value = (s: Samples, i: number) => {
|
||||
if (i < 0 || i >= s.data.length) return 0;
|
||||
const raw = s.data[i];
|
||||
return s.depth === 16 ? raw >> 8 : raw;
|
||||
};
|
||||
|
||||
const decode = (chunk: Chunk): Decoded | null => {
|
||||
if (chunk.jpeg) {
|
||||
const image = Skia.Image.MakeImageFromEncoded(chunk.jpeg);
|
||||
if (!image) return null;
|
||||
try {
|
||||
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;
|
||||
} finally {
|
||||
image.delete();
|
||||
}
|
||||
}
|
||||
|
||||
const stride = Math.ceil((chunk.stride * spp * depth) / 8);
|
||||
const want = stride * chunk.height;
|
||||
let raw: Uint8Array;
|
||||
if (compression === 1) raw = bytes.subarray(chunk.offset, Math.min(bytes.length, chunk.offset + want));
|
||||
else if (compression === 5) raw = lzwDecode(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
|
||||
else if (compression === 32773) raw = packBits(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
|
||||
else return null;
|
||||
if (raw.length < want) return null;
|
||||
|
||||
if (depth === 1) {
|
||||
const data = new Uint8Array(chunk.height * chunk.width * spp);
|
||||
for (let y = 0; y < chunk.height; y++) {
|
||||
for (let x = 0; x < chunk.width; x++) {
|
||||
const at = y * stride + (x >> 3);
|
||||
const bit = at < raw.length ? (raw[at] >> (7 - (x & 7))) & 1 : 0;
|
||||
data[y * chunk.width * spp + x] = bit * 255;
|
||||
}
|
||||
}
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 1, data };
|
||||
}
|
||||
|
||||
if (depth === 16) {
|
||||
const data = new Uint16Array(chunk.height * chunk.width * spp);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const at = i * 2;
|
||||
data[i] = little ? raw[at] | (raw[at + 1] << 8) : (raw[at] << 8) | raw[at + 1];
|
||||
}
|
||||
if (predictor === 2) differencing(data, chunk, spp);
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 16, data };
|
||||
}
|
||||
|
||||
const data = new Uint8Array(want).fill(0);
|
||||
data.set(raw.subarray(0, want));
|
||||
if (predictor === 2) differencing(data, chunk, spp);
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 8, data };
|
||||
};
|
||||
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height));
|
||||
const outW = Math.max(1, Math.round(width * scale));
|
||||
const outH = Math.max(1, Math.round(height * scale));
|
||||
const out = new Uint8Array(outW * outH * 4);
|
||||
|
||||
const palette = photometric === 3 ? nums(tags, 320) : null;
|
||||
const half = palette ? palette.length / 3 : 0;
|
||||
|
||||
let cachedKey = -1;
|
||||
let cached: Decoded | null = null;
|
||||
|
||||
for (let oy = 0; oy < outH; oy++) {
|
||||
const sy = Math.min(height - 1, Math.floor((oy * height) / outH));
|
||||
for (let ox = 0; ox < outW; ox++) {
|
||||
const sx = Math.min(width - 1, Math.floor((ox * width) / outW));
|
||||
const key = keyOf(sx, sy);
|
||||
if (key !== cachedKey) {
|
||||
cachedKey = key;
|
||||
const chunk = chunks.get(key);
|
||||
cached = chunk ? decode(chunk) : null;
|
||||
}
|
||||
const chunk = chunks.get(key);
|
||||
if (!cached || !chunk) continue;
|
||||
const chunkX = sx - chunk.x0;
|
||||
const chunkY = sy - chunk.y0;
|
||||
if (chunkX < 0 || chunkY < 0 || chunkX >= chunk.width || chunkY >= chunk.height) continue;
|
||||
|
||||
const at = (oy * outW + ox) * 4;
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (cached.kind === 'rgba') {
|
||||
const i = (chunkY * cached.width + chunkX) * 4;
|
||||
if (i + 3 >= cached.data.length) continue;
|
||||
r = cached.data[i];
|
||||
g = cached.data[i + 1];
|
||||
b = cached.data[i + 2];
|
||||
} else {
|
||||
const i = chunkY * cached.width * spp + chunkX * spp;
|
||||
if (palette && half) {
|
||||
const index = value(cached, i);
|
||||
r = (palette[index] ?? 0) >> 8;
|
||||
g = (palette[half + index] ?? 0) >> 8;
|
||||
b = (palette[2 * half + index] ?? 0) >> 8;
|
||||
} else if (photometric === 2 || photometric === 6) {
|
||||
r = value(cached, i);
|
||||
g = value(cached, i + 1);
|
||||
b = value(cached, i + 2);
|
||||
} else {
|
||||
const gray = value(cached, i);
|
||||
r = g = b = photometric === 0 ? 255 - gray : gray;
|
||||
}
|
||||
}
|
||||
|
||||
out[at] = r;
|
||||
out[at + 1] = g;
|
||||
out[at + 2] = b;
|
||||
out[at + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return { width: outW, height: outH, rgba: out };
|
||||
}
|
||||
|
||||
// Horizontal differencing, undone in place: every sample is the one before it
|
||||
// plus the difference stored. Bounded by the type, which is the point — a 16-bit
|
||||
// sample wrapped at 65536 and an 8-bit one at 256, and the file wrapped them the
|
||||
// same way.
|
||||
function differencing(data: Uint8Array | Uint16Array, chunk: Chunk, spp: number): void {
|
||||
const row = chunk.width * spp;
|
||||
for (let y = 0; y < chunk.height; y++) {
|
||||
const base = y * row;
|
||||
for (let i = spp; i < row; i++) data[base + i] += data[base + i - spp];
|
||||
}
|
||||
}
|
||||
|
||||
// A JPEG-compressed strip carries the frame's own tables — the whole stream
|
||||
// minus the entropy-coded scan — and the strip is the scan. Joining the two is
|
||||
// the only way either decodes: a strip on its own is missing its tables, and the
|
||||
// tables on their own are not a picture.
|
||||
function jpegWithTables(tables: Uint8Array, strip: Uint8Array): Uint8Array {
|
||||
const tableFrom = tables[0] === 0xff && tables[1] === 0xd8 ? 2 : 0;
|
||||
const tableTo = tables.length > 4 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === 0xd9
|
||||
? tables.length - 2
|
||||
: tables.length;
|
||||
const scanFrom = strip[0] === 0xff && strip[1] === 0xd8 ? 2 : 0;
|
||||
const complete = strip.length > 4 && strip[strip.length - 2] === 0xff && strip[strip.length - 1] === 0xd9;
|
||||
const body = tableTo - tableFrom;
|
||||
const out = new Uint8Array(2 + body + (strip.length - scanFrom) + (complete ? 0 : 2));
|
||||
out[0] = 0xff;
|
||||
out[1] = 0xd8;
|
||||
out.set(tables.subarray(tableFrom, tableTo), 2);
|
||||
out.set(strip.subarray(scanFrom), 2 + body);
|
||||
if (!complete) {
|
||||
out[out.length - 2] = 0xff;
|
||||
out[out.length - 1] = 0xd9;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// TIFF's LZW: MSB-first codes, 9 to 12 bits, and the code width steps up one
|
||||
// code early — at 511, not 512 — which is the quirk every other implementation
|
||||
// trips on.
|
||||
function lzwDecode(src: Uint8Array, expected: number): Uint8Array {
|
||||
const out = new Uint8Array(expected);
|
||||
const prefix = new Int32Array(4096);
|
||||
const suffix = new Uint8Array(4096);
|
||||
const stack = new Uint8Array(4096);
|
||||
let taken = 0;
|
||||
let read = 0;
|
||||
let bitCount = 0;
|
||||
let bitBuffer = 0;
|
||||
let next = 258;
|
||||
let codeSize = 9;
|
||||
let previous = -1;
|
||||
let first = 0;
|
||||
|
||||
const code = () => {
|
||||
while (bitCount < codeSize) {
|
||||
if (read >= src.length) return -1;
|
||||
bitBuffer = (bitBuffer << 8) | src[read++];
|
||||
bitCount += 8;
|
||||
}
|
||||
bitCount -= codeSize;
|
||||
return (bitBuffer >> bitCount) & ((1 << codeSize) - 1);
|
||||
};
|
||||
|
||||
const emit = (at: number) => {
|
||||
let depth = 0;
|
||||
let walk = at;
|
||||
while (walk >= 256) {
|
||||
if (depth >= 4096 || prefix[walk] < 0) return;
|
||||
stack[depth++] = suffix[walk];
|
||||
walk = prefix[walk];
|
||||
}
|
||||
stack[depth++] = walk;
|
||||
first = walk;
|
||||
while (depth > 0) {
|
||||
if (taken >= out.length) return;
|
||||
out[taken++] = stack[--depth];
|
||||
}
|
||||
};
|
||||
|
||||
let current = code();
|
||||
while (current >= 0 && current !== 257) {
|
||||
if (current === 256) {
|
||||
next = 258;
|
||||
codeSize = 9;
|
||||
previous = -1;
|
||||
current = code();
|
||||
continue;
|
||||
}
|
||||
if (previous < 0) {
|
||||
if (current > 255) return out;
|
||||
emit(current);
|
||||
} else if (current < next) {
|
||||
emit(current);
|
||||
if (next < 4096) {
|
||||
prefix[next] = previous;
|
||||
suffix[next] = first;
|
||||
next++;
|
||||
}
|
||||
} else {
|
||||
if (next >= 4096) return out;
|
||||
prefix[next] = previous;
|
||||
suffix[next] = first;
|
||||
emit(next);
|
||||
next++;
|
||||
}
|
||||
previous = current;
|
||||
if (next === (1 << codeSize) - 1 && codeSize < 12) codeSize++;
|
||||
current = code();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function packBits(src: Uint8Array, expected: number): Uint8Array {
|
||||
const out = new Uint8Array(expected);
|
||||
let taken = 0;
|
||||
let read = 0;
|
||||
while (read < src.length && taken < expected) {
|
||||
let run = src[read++];
|
||||
if (run > 127) run -= 256;
|
||||
if (run >= 0) {
|
||||
const end = Math.min(src.length, read + run + 1);
|
||||
while (read < end && taken < expected) out[taken++] = src[read++];
|
||||
read = end;
|
||||
} else if (run !== -128) {
|
||||
const value = src[read++];
|
||||
const times = Math.min(1 - run, expected - taken);
|
||||
for (let i = 0; i < times; i++) out[taken++] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user