fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading
This commit is contained in:
+204
-55
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user