feat(library): read the walk without tiling, and scope the filters to the open folder

The scan made a JPEG tile for every frame as it walked, so a wall of 12MP
files spent its whole budget in the walk; the tile now waits until the card
is actually drawn (makeTile + putPhotoThumb), and a rawish frame whose date
is unreadable retries at 4MB instead of stopping at the head.

The year menu was built from the whole catalogue, so it offered values the
open folder could not produce; it now follows the rows on screen and clears
the choice when a move leaves it stale.
This commit is contained in:
2026-10-08 11:28:14 +07:00
parent eee9328bbf
commit af23f3a7c9
2 changed files with 81 additions and 85 deletions
+30 -8
View File
@@ -19,9 +19,11 @@ import {
jumpScan,
listEditedIds,
listFolders,
makeTile,
normPath,
onLibraryBlocked,
pickFolder,
putPhotoThumb,
readPhotoFile,
readPhotos,
reconnectPhotosForFolders,
@@ -1150,11 +1152,23 @@ function Catalogue() {
[listed, strip.from, strip.to]
);
// The years the open folder holds, newest first: the filter offers the years
// there are frames to find in rather than a century of empty ones.
// there are frames to find in rather than a century of empty ones. Scoped to
// `shown` — the open node (+ its subfolders when that chip is on) — not the
// whole library, so picking a year never points at frames elsewhere.
const years = useMemo(
() => [...new Set(photos.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a),
[photos]
() => [...new Set(shown.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a),
[shown]
);
// A year the open folder does not hold is not a year to look at, and leaving
// it set would draw an empty shelf with no way back but the dropdown. Fires on
// a move, not on every frame landing, so a scan of the open folder filling in
// its rows cannot clear a year the reader just picked.
useEffect(() => {
if (year !== 'all' && !years.includes(Number(year))) setYear('all');
// ponytail: only the year option list is folder-derived; stars and hours are
// fixed lists that cannot go stale. Re-check the others if they ever grow.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node, deep, rootName]);
const handleSelectPhoto = useCallback(
(photo: LibraryPhoto, e: React.MouseEvent) => {
@@ -1495,9 +1509,11 @@ function Catalogue() {
// The tile: the thumbnail the catalogue kept, or a stand-in for the frame
// whose own small copy the file never carried — a RAW whose preview the camera
// never wrote, a HEIC with no thumbnail item (see engine/heicDevelop.ts). A tile
// The tile: the thumbnail the catalogue kept, or — the first time a frame is
// drawn — the one the wall makes and keeps for the visits after. A frame whose
// own small copy the file never carried says so while its tile is made: a RAW
// the camera wrote no preview in, a HEIC with no thumbnail item (see
// engine/heicDevelop.ts). A tile
// only carries a handler where a click
// on the picture means "open it" — in the strip the click belongs to the frame
// it raises, not to the studio. The word is only for a frame that has no
@@ -1513,7 +1529,7 @@ function Catalogue() {
/>
) : (
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
{photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : 'HEIC'}
{photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : isHeicName(photo.name) ? 'HEIC' : ''}
</div>
);
@@ -2550,7 +2566,13 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes);
if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' });
} else {
blob = file;
// A frame the wall can decode itself: the tile is made here, at the
// size the wall paints, because this is the first time anyone has
// looked at the frame. The walk never made one — a roll reads in a
// fraction of the time without it — so the decode is paid on the
// frame the reader draws and kept for every visit after.
blob = (await makeTile(file)) ?? file;
if (blob !== file) void putPhotoThumb(photo.id, blob);
}
} catch {
blob = null;
+51 -77
View File
@@ -14,8 +14,8 @@
// smart collections) that hand-rolled cursors would make ugly.
import type { Recipe } from '../../shared/types';
import { readCapturedAt } from './imageOps';
import { isRawName, rawThumbnail, tiffThumbnail } from './rawDevelop';
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
import { isRawName } from './rawDevelop';
import { isHeicName } from './heicDevelop';
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library';
@@ -63,13 +63,6 @@ const TELL_MS = 200;
// the same roll peaks at 1180MB: the frames around the one being opened still have
// their bytes read, their decodes run and their tiles encoded side by side, because
// only the open queues. A folder of JPEGs never touches this.
let rawTurn: Promise<unknown> = Promise.resolve();
function oneRawAtATime<T>(fn: () => Promise<T>): Promise<T> {
const turn = rawTurn.then(fn, fn);
rawTurn = turn.catch(() => undefined);
return turn;
}
export interface LibraryFolder {
// The directory's own name: the store's key, and the prefix of every frame id
// found in it. A rename never touches this.
@@ -89,7 +82,10 @@ export interface LibraryPhoto {
dir: string;
name: string;
handle: FileSystemFileHandle;
thumb: Blob | null;
// The tile the catalogue kept of this frame, where one has been made: it is
// made when the frame is first drawn and not when it is found, so a frame just
// read has none. Null is the same as absent — a tile that could not be made.
thumb?: Blob | null;
// When the shutter fired, per EXIF, else the file's own lastModified.
taken: number;
size: number;
@@ -469,34 +465,15 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
// kilobytes rather than the whole frame. Reading 8MB through a JS array to find a
// date in the first kilobyte of it is most of what a scan of JPEGs costs.
export const HEAD_BYTES = 256 * 1024;
// A RAW writes its IFD where its maker left it, and one that keeps its date past
// the first two hundred kilobytes is one read once more with this much of itself
// rather than one filed under the clock on the file.
const DEEP_HEAD_BYTES = 4 * 1024 * 1024;
// The probe is decoded at this width, purely to learn which edge of the frame is
// the long one; the decoder scales the real one off that. A JPEG does not need
// it: its own header says how big the frame is, in the first few hundred bytes.
const PROBE_PX = 8;
// A JPEG's frame header — the SOF segment — carries the frame's size, and it sits
// well inside the bytes already read off the disk for the date. Reading it costs a
// walk over a few hundred bytes where the probe costs a second decode of every
// pixel of the file.
function jpegSize(bytes: Uint8Array): { width: number; height: number } | null {
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
for (let i = 2; i + 8 < bytes.length; ) {
if (bytes[i] !== 0xff) {
i++;
continue;
}
const marker = bytes[i + 1];
// SOF0–SOF15 minus the ones that are not frames: DHT, JPG, DAC.
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] };
}
const length = (bytes[i + 2] << 8) | bytes[i + 3];
if (length < 2) return null;
i += 2 + length;
}
return null;
}
// What a frame whose header this code cannot read is measured with: one decode
// eight pixels wide. Only the aspect comes out of it — the two numbers that say
// which edge is the long one — and the frame is decoded once more for real.
@@ -516,7 +493,16 @@ async function sizeOf(blob: Blob): Promise<{ width: number; height: number } | n
// of a 24MP frame ever exist — where decoding the frame whole and drawing it
// small pays for every one of them. The probe says which edge that is, and the
// canvas is only here to re-encode to JPEG.
async function tile(blob: Blob, size?: { width: number; height: number } | null): Promise<Blob | null> {
//
// Nothing makes a tile until a tile is asked for. Reading a frame is nearly all
// decode — 30ms against the 2ms of reading the catalogue ever asks of a file —
// so a walk that made one for every frame it found spent its whole afternoon on
// pictures nobody had scrolled to: a roll of 160 frames took 2.8s to read and
// takes 0.3s when the tiles are left to the wall that draws them. The wall asks
// for the frames around the eye and no others, and the answer is kept, so the
// one decode a frame costs is paid the first time the frame is looked at and
// never again.
export async function makeTile(blob: Blob, size?: { width: number; height: number } | null): Promise<Blob | null> {
try {
const known = size ?? (await sizeOf(blob));
if (!known) return null;
@@ -938,48 +924,22 @@ export async function scanFolder(
}
return;
}
const raw = isRawName(file.name);
const isTiff = /\.(tiff?)$/i.test(file.name);
const heic = isHeicName(file.name);
// Slice buffer: HEIC uses up to 4MB; RAW & TIFF use up to 32MB (or full file if smaller).
const MAX_SLICE_BYTES = (raw || isTiff) ? 32 * 1024 * 1024 : heic ? 4 * 1024 * 1024 : HEAD_BYTES;
const sliceBuffer = await file.slice(0, Math.min(file.size, MAX_SLICE_BYTES)).arrayBuffer();
const bytes = new Uint8Array(sliceBuffer);
let preview: Uint8Array | null = null;
if (heic) {
preview = await oneRawAtATime(async () => {
let p = await heicThumbnail(bytes);
if (!p) {
try {
const fullBytes = new Uint8Array(await file.arrayBuffer());
p = await heicToJpeg(fullBytes, 320);
} catch {
p = null;
}
}
return p;
});
} else if (raw || isTiff) {
preview = await oneRawAtATime(async () => {
let p = await rawThumbnail(bytes, file.name);
if (!p && isTiff) {
try {
const fullBytes = new Uint8Array(await file.arrayBuffer());
p = tiffThumbnail(fullBytes);
} catch {
p = null;
}
}
return p;
});
// Where the frame was taken, off the few hundred kilobytes of its head that
// its own EXIF lives in — where the 32MB a camera's RAW used to be read for
// bought a preview this reading no longer wants. Nothing here looks at a
// pixel: the walk is as fast as the disk is, so the column and the frames
// near the top are on screen within the first pass and a folder of a hundred
// thousand is drawn while the reader is still on the first screen of it. A
// 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 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) {
const deeper = await file.slice(0, Math.min(file.size, DEEP_HEAD_BYTES)).arrayBuffer();
taken = await readCapturedAt(new Uint8Array(deeper));
}
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : (raw || heic || isTiff) ? null : file;
const size = preview ? jpegSize(preview) : (raw || heic || isTiff) ? null : jpegSize(bytes);
const thumb = src ? await tile(src, size) : null;
const taken = (await readCapturedAt(bytes)) ?? file.lastModified;
const relNorm = rel.replace(/\\/g, '/');
const cut = relNorm.lastIndexOf('/');
const row: LibraryPhoto = {
@@ -988,8 +948,7 @@ export async function scanFolder(
dir: cut < 0 ? '' : normPath(relNorm.slice(0, cut)),
name: handle.name,
handle,
thumb,
taken,
taken: taken ?? file.lastModified,
size: file.size,
mtime: file.lastModified,
star: seen?.star,
@@ -1524,6 +1483,21 @@ export async function getPhotoThumb(id: string): Promise<Blob | null> {
return null;
}
// A tile made because someone looked at the frame, kept where the next visit
// will find it: the decode is paid once per frame, on the visit that first drew
// it, and the reads after that are the same reads a scanned tile would have had.
export async function putPhotoThumb(id: string, blob: Blob): Promise<void> {
cacheThumb(id, blob);
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return;
const tx = db.transaction(THUMBS, 'readwrite');
tx.objectStore(THUMBS).put({ id, thumb: blob });
} catch {
// The tile is in hand either way; where it is not kept it is made again.
}
}
export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>> {
const res = new Map<string, Blob>();
const missing: string[] = [];