fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading

This commit is contained in:
2026-10-05 06:34:37 +07:00
parent 554e121e41
commit 91722861f2
12 changed files with 625 additions and 160 deletions
+204 -55
View File
@@ -4,11 +4,14 @@ 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 {
cachedPhotos,
canBrowseFolders,
clearLibraryCache,
ensurePermission,
getPhoto,
getPhotoThumbs,
HEAD_BYTES,
jumpScan,
listEditedIds,
@@ -71,6 +74,7 @@ const FRAME_KEY = 'recipescam.library.frame';
const COLUMN_KEY = 'recipescam.library.column';
const OPEN_KEY = 'recipescam.library.open';
const DEEP_KEY = 'recipescam.library.deep';
const VIEW_KEY = 'recipescam.library.view';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
@@ -112,8 +116,17 @@ interface Shot {
// back on a timer while a scan runs, which hands this screen a new frame object
// every few hundred milliseconds, so what keeps the file from being read again is
// the read in flight rather than the object under it.
const SHOTS_MAX = 50;
const shots = new Map<string, Promise<Shot | null>>();
function keepShot(id: string, promise: Promise<Shot | null>): void {
if (shots.size >= SHOTS_MAX) {
const oldestKey = shots.keys().next().value;
if (oldestKey) shots.delete(oldestKey);
}
shots.set(id, promise);
}
// How often a running scan is allowed to have its catalogue read back, and how
// often the tab may walk the folder again just for being raised. Both are the
// same trade — the reading is watched, not driven — and both are seconds for the
@@ -162,15 +175,15 @@ export function Library() {
// tree the way the last one was left.
const [expanded, setExpanded] = useState<Set<string>>(() => {
const saved = recall(OPEN_KEY);
// Nothing remembered means this is the first visit under that rule, and the
// node the last visit left would then open behind a shut row: its own rows
// are opened for it. A visit that folded everything remembers the empty set,
// which is not this, and is drawn as it was left.
const nodeParts = (recall(NODE_KEY) ?? '').split('/');
const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/'));
if (saved === null) {
const parts = (recall(NODE_KEY) ?? '').split('/');
return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
return new Set(nodeParents);
}
return new Set(saved.split(',').filter(Boolean));
const set = new Set(saved.split(',').filter(Boolean));
for (const parent of nodeParents) set.add(parent);
return set;
});
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
@@ -183,7 +196,7 @@ export function Library() {
// first one the node happens to hold. A frame that is gone falls through to
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
// these hours — and in the order the reader picked. A way of looking, not part
@@ -472,25 +485,37 @@ export function Library() {
};
}, [reload]);
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
useEffect(() => {
let alive = true;
void (async () => {
// 1. Trigger catalog reload immediately in parallel
const reloadPromise = reload();
// 2. Fetch list of directory folders
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
// The remembered folder only stands while the folder it belongs to does: a
// folder that was removed is not a folder to open on.
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
const ok = new Set<string>();
for (const folder of rows) {
if (await ensurePermission(folder.handle)) ok.add(folder.name);
}
// 3. Query directory permissions in parallel
const permResults = await Promise.all(
rows.map(async (folder) => ({
name: folder.name,
ok: await ensurePermission(folder.handle),
}))
);
if (!alive) return;
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
setReady(ok);
await reload();
// 4. Await photos reload if still running
await reloadPromise;
if (!alive) return;
setLoaded(true);
})();
@@ -517,6 +542,17 @@ export function Library() {
useEffect(() => {
keep(DEEP_KEY, String(deep));
}, [deep]);
useEffect(() => {
keep(VIEW_KEY, view);
}, [view]);
// Clear global memory caches when leaving Library screen
useEffect(() => {
return () => {
shots.clear();
clearLibraryCache();
};
}, []);
// The menu closes on the next click anywhere but its own items, and on Escape.
// Escape is the whole reason this listens on the document instead of sitting
@@ -710,17 +746,11 @@ export function Library() {
// numbers drawn over the column would count a roll down to a branch of itself.
// The column falls back on the catalogue until the reading is through.
const read = live && !live.progress.from ? live.progress.counts : null;
const nodes = useMemo<Node[]>(() => {
// Memoize photo count map derived from photos and dirs array separately so that
// live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
const baseTreeInfo = useMemo(() => {
const hits = new Map<string, number>();
// The reading's own count is a floor, never a ceiling: a row says at least
// what the catalogue already holds of it. A roll being read again says its
// own size while the reading is still walking up to it — where the reading's
// number alone would have the head of the tree count 29980 while the line
// above it counts a hundred thousand, one question with two answers.
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const foldersUnder = new Map<string, Set<string>>();
// One path per folder between the picked one and `rel`, so a row is drawn for
// every folder on the way to a frame as well as for the frame's own.
const add = (folder: string, rel: string, counted: boolean) => {
const set = foldersUnder.get(folder) ?? new Set<string>();
foldersUnder.set(folder, set);
@@ -735,9 +765,13 @@ export function Library() {
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
if (photo.dir) add(photo.folder, photo.dir, true);
}
// A folder the walk found is a row before it holds a frame: the names arrive
// a pass ahead of the thumbnails.
for (const dir of dirs) add(dir.folder, dir.rel, false);
return { hits, foldersUnder };
}, [photos, dirs]);
const nodes = useMemo<Node[]>(() => {
const { hits, foldersUnder } = baseTreeInfo;
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
@@ -754,7 +788,7 @@ export function Library() {
}
}
return out;
}, [dirs, folders, photos, read]);
}, [baseTreeInfo, folders, read]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
@@ -840,6 +874,53 @@ export function Library() {
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
const scrolledNodeRef = useRef<string | null>(null);
useEffect(() => {
if (!node || !loaded) return;
if (scrolledNodeRef.current === node) return;
const scrollToNode = () => {
const treeEl = treeRef.current;
if (!treeEl) return;
const activeBtn = treeEl.querySelector<HTMLElement>('.adm-album.on');
if (activeBtn) {
activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
scrolledNodeRef.current = node;
}
};
scrollToNode();
const timer = setTimeout(scrollToNode, 120);
return () => clearTimeout(timer);
}, [node, loaded, rows]);
// Auto-scroll timeline filmstrip to center the active selected thumbnail
const scrolledFrameRef = useRef<string | null>(null);
useEffect(() => {
if (view !== 'preview' || !frame || !shown.length) return;
const idx = shown.findIndex((p) => p.id === frame.id);
if (idx < 0) return;
const scrollStripToFrame = () => {
const el = strip.ref.current;
if (!el) return;
const width = el.clientWidth || 800;
const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2);
el.scrollLeft = targetLeft;
};
const key = `${node}:${frame.id}`;
if (scrolledFrameRef.current !== key) {
scrolledFrameRef.current = key;
scrollStripToFrame();
requestAnimationFrame(scrollStripToFrame);
}
}, [view, frame?.id, shown, node, strip.ref]);
// 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
@@ -886,7 +967,7 @@ export function Library() {
return null;
}
})();
shots.set(id, read);
keepShot(id, read);
}
void read.then((shot) => {
if (live) setShot(shot);
@@ -902,6 +983,59 @@ export function Library() {
setZoom(1);
}, [frame?.id]);
const [stageUrl, setStageUrl] = useState<string | null>(null);
const stageUrlRef = useRef<string | null>(null);
const revokeStageUrl = () => {
if (stageUrlRef.current) {
URL.revokeObjectURL(stageUrlRef.current);
stageUrlRef.current = null;
}
};
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
useEffect(() => {
revokeStageUrl();
setStageUrl(null);
if (!frame) return;
let live = true;
void (async () => {
try {
const file = await readPhotoFile(frame);
if (!live) return;
let url: string | null = null;
if (isRawName(file.name)) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!live) return;
const previewBytes = await rawThumbnail(bytes);
if (previewBytes && live) {
const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
url = URL.createObjectURL(blob);
}
} else {
url = URL.createObjectURL(file);
}
if (live && url) {
revokeStageUrl();
stageUrlRef.current = url;
setStageUrl(url);
} else if (url) {
URL.revokeObjectURL(url);
}
} catch {
// Fall back to thumb tile URL if file reading fails
}
})();
return () => {
live = false;
revokeStageUrl();
};
}, [frame?.id]);
// A wheel tick over the frame is the zoom — a negative is the one thing on this
// screen worth filling the window with — and the point under the pointer is the
// point that stays put, which is how a reader aims at what to magnify. The
@@ -1100,6 +1234,7 @@ export function Library() {
column is the folder control, so one more folder is added
where folders are. */}
<aside
ref={treeRef}
className="adm-album-list"
data-key="lib-tree"
title={t('lib.colHint')}
@@ -1348,12 +1483,12 @@ export function Library() {
</>
) : frame ? (
<>
{urls[frame.id] ? (
{stageUrl || urls[frame.id] ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
ref={frameRef}
src={urls[frame.id]}
src={stageUrl || urls[frame.id]!}
alt=""
onClick={() => void open(frame)}
title={t('lib.open')}
@@ -1619,40 +1754,54 @@ export function Library() {
// one on the stage, whose tile may be nowhere near the strip's window.
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
// The URL a row was given, kept for as long as the row is on screen. A scan lands
// in batches, and each batch hands the screen the whole list again — so a URL
// made afresh per list is a picture the browser decodes afresh per batch:
// thousands of bitmaps over a roll, for tiles that have not moved. A row that
// leaves the list takes its URL with it.
const made = useRef(new Map<string, string>());
const made = useRef(new Map<string, { url: string; blob: Blob }>());
useEffect(() => {
const list = also && !photos.includes(also) ? [...photos, also] : photos;
const next: Record<string, string> = {};
for (const photo of list) {
if (!photo.thumb) continue;
const had = made.current.get(photo.id);
if (had) next[photo.id] = had;
else {
const url = URL.createObjectURL(photo.thumb);
made.current.set(photo.id, url);
next[photo.id] = url;
let alive = true;
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
if (!list.length) {
setUrls({});
return;
}
const ids = list.map((p) => p.id);
void getPhotoThumbs(ids).then((thumbMap) => {
if (!alive) return;
const next: Record<string, string> = {};
for (const photo of list) {
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
if (!blob) continue;
const had = made.current.get(photo.id);
if (had && had.blob === blob) {
next[photo.id] = had.url;
} else {
if (had) URL.revokeObjectURL(had.url);
const url = URL.createObjectURL(blob);
made.current.set(photo.id, { url, blob });
next[photo.id] = url;
}
}
}
for (const [id, url] of made.current) {
if (id in next) continue;
URL.revokeObjectURL(url);
made.current.delete(id);
}
setUrls(next);
for (const [id, item] of made.current) {
if (id in next) continue;
URL.revokeObjectURL(item.url);
made.current.delete(id);
}
setUrls(next);
});
return () => {
alive = false;
};
}, [photos, also]);
// The screen going away takes every URL with it.
useEffect(
() => () => {
for (const url of made.current.values()) URL.revokeObjectURL(url);
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
made.current.clear();
},
[]
);
return urls;
}