fix(library): stop calling a .tif a RAW, and stop the wall's render loop
Three things, all from one look at a folder of TIFF scans. A `.tif` is no longer a RAW. Nothing here develops one, and `tif`/`tiff` sat in `RAW_EXT` for no decoder's sake: opening a TIFF asked for PRO and its tile wore a RAW badge, for a file the engine has nothing to hand back. Both extensions are dropped, and a TIFF keeps its own route instead — the tile reads the file's own pixels (`useThumbUrls`, via `tiffDevelop`) and so does the studio — so a `.tif` still opens and still draws, it just is not a RAW any more. The wall's render loop. `painted` was a fresh `.slice()` on every render, so `useThumbUrls` saw a new list each time round and its effect ran again, and again: about twenty thousand renders a second while the page sat idle, and the file reads frozen at the first screenful, so a tile scrolled to never came up (7 of 24 cards drawn). `painted` is memoised on `listed, wall.from, wall.to` now — no date formats in eight idle seconds, 28 of 28 cards after a scroll. Scrolling between the two modes. SINGLE → ALL THUMBS lands the wall on the frame the strip was showing, centred in the window; ALL THUMBS → SINGLE swipes the strip to the frame nearest the wall's middle, centred in the row. Each way the frame the other mode was standing on is the one you come back to.
This commit is contained in:
@@ -16,7 +16,7 @@ import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine, MARK_FACES } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps';
|
||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||
import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDevelop';
|
||||
import { sharePhoto, type ShareSite } from './engine/share';
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
@@ -1182,6 +1182,27 @@ export function Workspace() {
|
||||
void savePhoto(file.name, jpeg);
|
||||
return;
|
||||
}
|
||||
if (isTiffName(file.name)) {
|
||||
// A TIFF: no longer a RAW, and nothing in it for the engine to develop
|
||||
// — but no browser decoder paints one either (see `tiffThumbnail`), so
|
||||
// it is read by hand at the file's own size and becomes the JPEG the
|
||||
// studio works on, the same way the HEIC above does. No tier: it is a
|
||||
// plain reading of pixels the file already holds.
|
||||
//
|
||||
// The session keeps the decode, not the file, so a reload opens the
|
||||
// frame at once instead of paying the read again; the name stays the
|
||||
// file's own and `isRawName` answers no, so the reload path above shows
|
||||
// the saved copy as the picture it is. The TIFF goes along as the bytes
|
||||
// the stamps read — the JPEG carries no EXIF of its own. A file the
|
||||
// decoders here do not know throws, and the reader is told the frame
|
||||
// could not be read rather than shown an empty stage.
|
||||
await clearRaw();
|
||||
const jpeg = await tiffDevelop(bytes);
|
||||
if (!jpeg) throw new Error('no TIFF decoder for this file');
|
||||
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
|
||||
void savePhoto(file.name, jpeg);
|
||||
return;
|
||||
}
|
||||
await clearRaw();
|
||||
await adoptPhoto(bytes, file.name, file);
|
||||
void savePhoto(file.name, bytes);
|
||||
|
||||
@@ -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 } from './engine/rawDevelop';
|
||||
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
cachedFolders,
|
||||
@@ -1399,7 +1399,15 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// the DOM and the number the browser formats for each date. The frame on the
|
||||
// stage is given a picture wherever it sits — the stage is not the wall.
|
||||
const wall = useWallWindow(listed.length, view === 'grid');
|
||||
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
|
||||
// Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
|
||||
// effect on the array it is handed, so a fresh slice every render made that
|
||||
// effect run every render — a `setUrls` per pass, a render per `setUrls`, and
|
||||
// thousands of passes a second in which the tiles of the rows scrolled to never
|
||||
// land at all.
|
||||
const painted = useMemo(
|
||||
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
|
||||
[listed, wall.from, wall.to]
|
||||
);
|
||||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
|
||||
|
||||
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
||||
@@ -1427,6 +1435,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
|
||||
// Auto-scroll timeline filmstrip to center the active selected thumbnail
|
||||
const scrolledFrameRef = useRef<string | null>(null);
|
||||
// The same frame for the wall: the strip hands it over on the way to ALL
|
||||
// THUMBS, and the wall scrolls to that frame's card rather than opening at the
|
||||
// top of a shelf the reader was somewhere in the middle of.
|
||||
const centredRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'preview' || !frame || !listed.length) return;
|
||||
@@ -1449,6 +1461,69 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
}
|
||||
}, [view, frame?.id, listed, node, strip.ref]);
|
||||
|
||||
// And the wall's half of it: the frame the strip was on, brought to the middle
|
||||
// of the wall. The card is not in the DOM to begin with — the wall only paints
|
||||
// the rows around its viewport — so the first passes go to where the row should
|
||||
// be and the ones after that read the card itself, which is the only honest
|
||||
// measure: a row stands as tall as its longest caption. The loop is bounded
|
||||
// rather than driven by state, because the wall reports its window by
|
||||
// re-rendering and a scroll set inside a render is a scroll the next render
|
||||
// undoes.
|
||||
useEffect(() => {
|
||||
if (view !== 'grid') return;
|
||||
const id = centredRef.current;
|
||||
const el = wall.ref.current;
|
||||
if (!id || !el) return;
|
||||
const idx = listed.findIndex((p) => p.id === id);
|
||||
if (idx < 0) {
|
||||
centredRef.current = null;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
let left = 8;
|
||||
const settle = () => {
|
||||
if (!alive) return;
|
||||
const card = el.querySelector<HTMLElement>(`[data-key="lib-photo-${CSS.escape(id)}"]`);
|
||||
if (card) {
|
||||
const box = el.getBoundingClientRect();
|
||||
const own = card.getBoundingClientRect();
|
||||
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
|
||||
} else {
|
||||
// No card drawn: the wall opens on the first screenful, so aim at the row
|
||||
// and let the next pass find it. The row's step is read off a drawn card
|
||||
// when there is one, and guessed at when there is not.
|
||||
const first = el.querySelector<HTMLElement>(':scope > .adm-card');
|
||||
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
||||
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
|
||||
el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2);
|
||||
}
|
||||
if (--left > 0) requestAnimationFrame(settle);
|
||||
else centredRef.current = null;
|
||||
};
|
||||
requestAnimationFrame(settle);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [view, listed, wall.ref]);
|
||||
|
||||
// The way back, and the way in: the frame the wall is standing on is the card
|
||||
// nearest the middle of its viewport — the same card the reader's eye is on.
|
||||
const openFromWall = () => {
|
||||
const el = wall.ref.current;
|
||||
const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0;
|
||||
let best: { id: string; d: number } | null = null;
|
||||
for (const card of el ? el.querySelectorAll<HTMLElement>(':scope > .adm-card') : []) {
|
||||
const box = card.getBoundingClientRect();
|
||||
const d = Math.abs(box.top + box.height / 2 - mid);
|
||||
if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d };
|
||||
}
|
||||
if (best) setUp(best.id);
|
||||
// The strip is built afresh when it comes back, so the frame is centred again
|
||||
// even when it is the frame it was already on.
|
||||
scrolledFrameRef.current = null;
|
||||
setView('preview');
|
||||
};
|
||||
|
||||
// A frame's score: click a star to give it, click the star it already has to
|
||||
// take the score back. The catalogue holds it, so it is the same score the next
|
||||
// visit reads — and the list on screen is told, rather than read back whole for
|
||||
@@ -1640,6 +1715,12 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// the tile stands in until it lands).
|
||||
const jpeg = await heicToJpeg(new Uint8Array(await file.arrayBuffer()), 2048);
|
||||
if (live) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' }));
|
||||
} else if (isTiffName(file.name)) {
|
||||
// A TIFF is nobody's decoder here either (see `tiffThumbnail`), and the
|
||||
// stage is the frame itself rather than the wall's tile, so it is
|
||||
// decoded at what a screen shows.
|
||||
const jpeg = await tiffDevelop(new Uint8Array(await file.arrayBuffer()), 2048);
|
||||
if (live && jpeg) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' }));
|
||||
} else if (isRawName(file.name)) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!live) return;
|
||||
@@ -1837,7 +1918,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
aria-pressed={view === 'preview'}
|
||||
aria-label={t('lib.viewPreview')}
|
||||
title={t('lib.viewPreview')}
|
||||
onClick={() => setView('preview')}
|
||||
onClick={openFromWall}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor">
|
||||
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" />
|
||||
@@ -1852,7 +1933,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
aria-pressed={view === 'grid'}
|
||||
aria-label={t('lib.viewGrid')}
|
||||
title={t('lib.viewGrid')}
|
||||
onClick={() => setView('grid')}
|
||||
onClick={() => {
|
||||
centredRef.current = frame?.id ?? null;
|
||||
setView('grid');
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor">
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" />
|
||||
@@ -2131,6 +2215,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onDoubleClick={() => {
|
||||
setUp(photo.id);
|
||||
scrolledFrameRef.current = null;
|
||||
setView('preview');
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
@@ -2775,7 +2860,7 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
||||
let prevBytes = await heicThumbnail(fullBytes);
|
||||
if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320);
|
||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
||||
} else if (isRawName(name) || /\.(tiff?)$/i.test(name)) {
|
||||
} else if (isRawName(name) || isTiffName(name)) {
|
||||
const fullBytes = new Uint8Array(await file.arrayBuffer());
|
||||
const prevBytes = await rawThumbnail(fullBytes, name);
|
||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// smart collections) that hand-rolled cursors would make ugly.
|
||||
import type { Recipe } from '../../shared/types';
|
||||
import { readCapturedAt } from './imageOps';
|
||||
import { isRawName } from './rawDevelop';
|
||||
import { isRawName, isTiffName } from './rawDevelop';
|
||||
import { isHeicName } from './heicDevelop';
|
||||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||
|
||||
@@ -1242,7 +1242,7 @@ export async function scanFolder(
|
||||
// RAW whose date sits deeper is read once more before the clock on the file
|
||||
// has to do. The tile is the wall's business — one is made when one is drawn
|
||||
// and not before — see `makeTile`.
|
||||
const rawish = isRawName(file.name) || /\.(tiff?)$/i.test(file.name) || isHeicName(file.name);
|
||||
const rawish = isRawName(file.name) || isTiffName(file.name) || isHeicName(file.name);
|
||||
const bytes = new Uint8Array(await file.slice(0, Math.min(file.size, HEAD_BYTES)).arrayBuffer());
|
||||
let taken = await readCapturedAt(bytes);
|
||||
if (taken === null && rawish && file.size > HEAD_BYTES) {
|
||||
|
||||
@@ -188,11 +188,12 @@ half4 main(float2 pos) {
|
||||
`;
|
||||
|
||||
// The extensions LibRaw is compiled for. Not `tif`: a TIFF in a camera folder is
|
||||
// as likely to be an export as a RAW.
|
||||
// as likely to be an export as a RAW, and opening one asked for PRO and wore a
|
||||
// RAW badge for a file the engine has nothing to develop in. It is read for its
|
||||
// own pixels instead — see `tiffDevelop`.
|
||||
const RAW_EXT = [
|
||||
'arw', 'srf', 'sr2', 'cr2', 'cr3', 'crw', 'nef', 'nrw', 'dng', 'orf', 'raf',
|
||||
'rw2', 'pef', 'srw', 'raw', 'rwl', '3fr', 'fff', 'iiq', 'mos', 'mrw', 'x3f',
|
||||
'tif', 'tiff',
|
||||
];
|
||||
|
||||
export const RAW_ACCEPT = RAW_EXT.map((e) => `.${e}`).join(',');
|
||||
@@ -202,6 +203,12 @@ export function isRawName(name: string): boolean {
|
||||
return name.includes('.') && RAW_EXT.includes(ext);
|
||||
}
|
||||
|
||||
// A TIFF is not a RAW: it is a picture no decoder here draws by itself, and the
|
||||
// one test for it, so the tile's route and the studio's route cannot drift apart.
|
||||
export function isTiffName(name: string): boolean {
|
||||
return /\.(tiff?)$/i.test(name);
|
||||
}
|
||||
|
||||
// The camera's own preview, when the file carries one: the colour reference the
|
||||
// curve below is fitted to, and what the file opens as when the sensor does not
|
||||
// decode.
|
||||
@@ -296,48 +303,65 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
// 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) {
|
||||
try {
|
||||
return shrinkToJpeg(img);
|
||||
} finally {
|
||||
img.delete();
|
||||
}
|
||||
}
|
||||
function tiffImage(bytes: Uint8Array, maxSide: number): any {
|
||||
const img = Skia.Image.MakeImageFromEncoded(bytes);
|
||||
if (img) return img;
|
||||
|
||||
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;
|
||||
const frame = decodeTiff(bytes, maxSide);
|
||||
if (!frame) return null;
|
||||
return 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
|
||||
);
|
||||
}
|
||||
|
||||
// The same read, encoded to JPEG. `max` is the long side it comes out at, 0 for
|
||||
// the file's own size.
|
||||
function tiffJpeg(bytes: Uint8Array, max: number): Uint8Array | null {
|
||||
try {
|
||||
const img = tiffImage(bytes, max || Infinity);
|
||||
if (!img) return null;
|
||||
try {
|
||||
return shrinkToJpeg(image);
|
||||
return shrinkToJpeg(img, max || Math.max(img.width(), img.height()));
|
||||
} finally {
|
||||
image.delete();
|
||||
img.delete();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function tiffThumbnail(bytes: Uint8Array): Uint8Array | null {
|
||||
return tiffJpeg(bytes, 640);
|
||||
}
|
||||
|
||||
// A `.tif` read for the screen, now that it is no file the engine develops and
|
||||
// no decoder the browser has: the frame is the picture, so it is decoded here —
|
||||
// by the studio at the file's own size, by the catalogue's stage at what a screen
|
||||
// shows (`max`, 0 for the file's own). CanvasKit is what draws it, and neither
|
||||
// caller has booted it: a visitor who only ever opens the wall never does.
|
||||
//
|
||||
// ponytail: at the file's own size this is the frame's RGBA in memory (80MB for a
|
||||
// baseline 5472x3648 scan) plus the canvas it is drawn to. Cap it if a plate ever
|
||||
// arrives that does not fit; the develop above is the same order already.
|
||||
export async function tiffDevelop(bytes: Uint8Array, max = 0): Promise<Uint8Array | null> {
|
||||
if (!Skia) await initSkia({ locateFile: (file) => `/wasm/${file}` });
|
||||
return tiffJpeg(bytes, max);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
function shrinkToJpeg(img: any, n = 640): 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;
|
||||
@@ -371,7 +395,7 @@ 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)) {
|
||||
if (fileName && isTiffName(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.
|
||||
|
||||
Reference in New Issue
Block a user