diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx
index 05f01dd..ba3d690 100644
--- a/docker/frontend/src/Library.tsx
+++ b/docker/frontend/src/Library.tsx
@@ -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() {
/>
) : (
- {photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : 'HEIC'}
+ {photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : isHeicName(photo.name) ? 'HEIC' : ''}
);
@@ -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;
diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts
index b25ec18..51f54d2 100644
--- a/docker/frontend/src/engine/library.ts
+++ b/docker/frontend/src/engine/library.ts
@@ -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 = Promise.resolve();
-function oneRawAtATime(fn: () => Promise): Promise {
- 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 {
+//
+// 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 {
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 {
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 {
+ 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