feat(library): give a folder its missing thumbnails, and the wall its stars
Four asks, one screen: a folder can be given the tiles it is short of, by hand or on the way in; the rescan row says RESCAN...; and the wall of thumbnails scores a frame without the trip to the stage. - Library.tsx + i18n: "RESCAN FROM THE TOP" is "RESCAN..." in both languages. The reading is unchanged — a row's roll is still read from the top, its subfolders included. - engine/library.ts + Library.tsx: the tile of one frame is one function now (`makeThumb`: the store, the backup behind it, then the file's own preview for a RAW or a HEIC and a decode for anything else). The wall's lazy path calls it instead of keeping its own copy of the same branches. - engine/library.ts: `missingThumbs` asks which of a folder's rows no tile was ever drawn for, in one transaction, and `makeThumbs` makes those — a few frames at a time, and giving up when the caller has moved on. - Library.tsx: the folder menu carries MAKE THUMBS, which reads the row's folder and what lies under it; and opening a folder asks for its own frames' tiles by itself, silently, in the background — the branch too when the screen is drawing the branch. A frame that lands after the ask is asked for again, which is why the frame count is a dependency of the effect. - Library.tsx: a wall card carries the same five stars the stage does, under the picture, and rates the frame on the click. The click stops at the star, so scoring a frame is not picking it. Verified in Chrome at 1440x900 against OPFS standing in for the disk, three frames with one of them in a subfolder: opening the folder leaves two tiles (the node's own frames) and MAKE THUMBS takes the roll to three, the frame in the subfolder included; clearing the store and reloading brings the two back on the open; the menu reads RESCAN... and MAKE THUMBS; and the wall's fourth star lights four, writes star 4 to the catalogue row, and takes it back on the second click. The same checks pass on the built bundle at :8090.
This commit is contained in:
+109
-52
@@ -5,7 +5,7 @@ import { api } from './api';
|
||||
import { keep, recall } from './remember';
|
||||
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
cachedFolders,
|
||||
cachedPhotos,
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
listEditedIds,
|
||||
listFolders,
|
||||
listWalkedDirs,
|
||||
makeTile,
|
||||
makeThumb,
|
||||
makeThumbs,
|
||||
normPath,
|
||||
onLibraryBlocked,
|
||||
pickFolder,
|
||||
putPhotoThumb,
|
||||
readPhotoFile,
|
||||
readPhotos,
|
||||
reconnectPhotosForFolders,
|
||||
@@ -1057,6 +1057,52 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
[rootName]
|
||||
);
|
||||
|
||||
// MAKING THUMBS: the tiles a folder's frames are short of, made from the frames
|
||||
// themselves. One job at a time — the target the reader is on — and a job the
|
||||
// reader has moved away from gives up at its next frame: a tile is a decode, and
|
||||
// two jobs over the same frames read the same disk twice for nothing.
|
||||
const thumbJob = useRef(0);
|
||||
const thumbTarget = useRef<string | null>(null);
|
||||
const makeThumbsFor = useCallback(
|
||||
async (folder: LibraryFolder, rel: string, opts: { below?: boolean; loud?: boolean } = {}) => {
|
||||
const key = `${normPath(folder.name)}|${normPath(rel)}|${opts.below === false ? 0 : 1}`;
|
||||
// A quiet ask for the target already being worked is the work in hand: the
|
||||
// catalogue lands a batch at a time, and every batch is not a reason to drop
|
||||
// the frames the job has queued and read them again.
|
||||
if (!opts.loud && thumbTarget.current === key) return 0;
|
||||
const job = ++thumbJob.current;
|
||||
thumbTarget.current = key;
|
||||
try {
|
||||
const made = await makeThumbs(folder, rel, {
|
||||
below: opts.below,
|
||||
shouldStop: () => thumbJob.current !== job,
|
||||
});
|
||||
if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name }));
|
||||
return made;
|
||||
} finally {
|
||||
// The target is let go by the job that stood for it: one cut off in the
|
||||
// middle is not the one that says the target has been looked at.
|
||||
if (thumbJob.current === job) thumbTarget.current = null;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
// Selecting a folder asks for its frames' tiles: whatever the catalogue is short
|
||||
// of is made in the background, so the wall the reader scrolls to is painted
|
||||
// rather than decoded a frame at a time. The open node's own frames — and the
|
||||
// branch under it when the screen is drawing the branch — which is the list the
|
||||
// wall would scroll and no more. Silent: the tiles filling in are the word. The
|
||||
// count is a dependency because a reading lands a folder's frames a batch at a
|
||||
// time, and a frame that arrives after the tiles were asked for is a frame with
|
||||
// none.
|
||||
useEffect(() => {
|
||||
if (!loaded || !node || !rootFolder) return;
|
||||
const rel = relOf(node);
|
||||
if (rel === null) return;
|
||||
void makeThumbsFor(rootFolder, rel, { below: deep });
|
||||
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
|
||||
|
||||
// The tree: one row per picked folder, then one per subfolder, indented by its
|
||||
// depth — the folders the walk has named, whether or not a frame has been read
|
||||
// out of them, each carrying how many frames sit at or under it. While a scan is
|
||||
@@ -2221,6 +2267,35 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo)}
|
||||
{/* The score under the picture, the same five stars the
|
||||
stage gives: the wall is where a roll is gone
|
||||
through, and going back to the stage for every
|
||||
number is the trip this saves. The click stops at
|
||||
the star — a score is not a pick, and the card's
|
||||
own click is what selects the frame. */}
|
||||
<div
|
||||
className="lib-stars"
|
||||
data-key={`lib-stars-${photo.id}`}
|
||||
role="group"
|
||||
aria-label={t('lib.rating')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
|
||||
data-key={`lib-star-${photo.id}-${n}`}
|
||||
aria-pressed={(photo.star ?? 0) >= n}
|
||||
aria-label={t('lib.starHint', { n })}
|
||||
title={t('lib.starHint', { n })}
|
||||
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="adm-meta">
|
||||
<b>{photo.name}</b>
|
||||
<span>
|
||||
@@ -2481,12 +2556,12 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
{t('lib.rename')}
|
||||
</button>
|
||||
) : null}
|
||||
{/* Three things can be asked of a roll: bring it up to date, read it
|
||||
from the top again, or leave it — and the last only of the
|
||||
reading that is this roll's, or of the request for it that is
|
||||
still waiting its turn. The first two are always there, because
|
||||
a reading already running is not a reason a reader cannot ask
|
||||
for the next one: it queues. */}
|
||||
{/* Four things can be asked of a roll: bring it up to date, read it
|
||||
from the top again, give it the tiles it is short of, or leave
|
||||
it — and the last only of the reading that is this roll's, or of
|
||||
the request for it that is still waiting its turn. The others are
|
||||
always there, because a reading already running is not a reason a
|
||||
reader cannot ask for the next one: it queues. */}
|
||||
{live?.folder === menu.folder.name || queued ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2525,6 +2600,24 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
|
||||
{t('lib.rescan')}
|
||||
</button>
|
||||
{/* The tiles this roll is short of, made from the frames themselves —
|
||||
the same job the screen runs when a folder is opened, asked for by
|
||||
hand on the row under the pointer, and over what lies under that
|
||||
row: a folder is read whole or not at all. */}
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-thumbs-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
const folder = menu.folder!;
|
||||
const rel = menu.rel ?? '';
|
||||
setMenu(null);
|
||||
void makeThumbsFor(folder, rel, { loud: true });
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">▤</span>
|
||||
{t('lib.makeThumbs')}
|
||||
</button>
|
||||
{/* A row with folders under it is a row that folds, and the branch
|
||||
it holds goes with it: a reader who is done with a tree means
|
||||
the whole of it shut, not the one row they happened to point
|
||||
@@ -2834,52 +2927,16 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
||||
|
||||
if (idsToFetch.length === 0) return;
|
||||
|
||||
// The strip draws one square whatever the frame behind it, so the preview a RAW
|
||||
// or a HEIC hands back — a whole develop, seconds of it — is shrunk to the
|
||||
// wall's size the one time it is paid for and then kept: every later visit
|
||||
// reads the tile back instead of the file.
|
||||
const tileOf = async (id: string, bytes: Uint8Array) => {
|
||||
const preview = new Blob([bytes as BlobPart], { type: 'image/jpeg' });
|
||||
const made = (await makeTile(preview)) ?? preview;
|
||||
void putPhotoThumb(id, made);
|
||||
return made;
|
||||
};
|
||||
|
||||
// The tile is `makeThumb`'s business: the store first, the backup behind it,
|
||||
// and only then the file — a RAW's own preview, or a decode. A frame the disk
|
||||
// will not hand over is a frame with no tile rather than a strip that stops.
|
||||
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
||||
if (!alive) return;
|
||||
const updates: Record<string, string> = {};
|
||||
for (const photo of list) {
|
||||
let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||
if (!blob && photo.handle) {
|
||||
try {
|
||||
const file = await readPhotoFile(photo);
|
||||
if (!alive) return;
|
||||
const name = file.name;
|
||||
if (isHeicName(name)) {
|
||||
const fullBytes = new Uint8Array(await file.arrayBuffer());
|
||||
let prevBytes = await heicThumbnail(fullBytes);
|
||||
if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320);
|
||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
||||
} 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);
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
if (blob && alive) {
|
||||
updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
||||
}
|
||||
if (!alive) return;
|
||||
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||
const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
|
||||
if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
||||
}
|
||||
if (alive && Object.keys(updates).length > 0) {
|
||||
setUrls((prev) => ({ ...prev, ...updates }));
|
||||
|
||||
@@ -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, isTiffName } from './rawDevelop';
|
||||
import { isHeicName } from './heicDevelop';
|
||||
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
||||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||
|
||||
const DB_NAME = 'recipescam-library';
|
||||
@@ -1919,6 +1919,123 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
||||
return res;
|
||||
}
|
||||
|
||||
// A RAW or a HEIC hands back a preview that is a whole develop, seconds of it:
|
||||
// shrunk to the tile's size the one time it is paid for and then kept, so every
|
||||
// later visit reads the tile back instead of the file.
|
||||
async function tileOf(id: string, preview: Blob): Promise<Blob> {
|
||||
const made = (await makeTile(preview)) ?? preview;
|
||||
void putPhotoThumb(id, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
// The tile of one frame, made there and then — the folder the reader just opened,
|
||||
// or the one frame the wall is drawing. The store is asked first and the backup
|
||||
// behind it, so a tile that is somewhere already is not decoded a second time;
|
||||
// only a frame nothing has is read off the disk, and which way that goes is the
|
||||
// file's own: a RAW or a HEIC gives its preview, anything else is decoded.
|
||||
//
|
||||
// ponytail: a TIFF has no preview reader here, so it is decoded whole through the
|
||||
// browser (`makeTile`) — `tiffDevelop` is the way in if a folder of scans ever
|
||||
// makes that wait a real one.
|
||||
export async function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const kept = await getPhotoThumb(photo.id);
|
||||
if (kept) return kept;
|
||||
if (!photo.handle) return null;
|
||||
try {
|
||||
const file = await readPhotoFile(photo);
|
||||
const name = file.name;
|
||||
let blob: Blob | null = null;
|
||||
if (isHeicName(name)) {
|
||||
const full = new Uint8Array(await file.arrayBuffer());
|
||||
const preview = (await heicThumbnail(full)) ?? (await heicToJpeg(full, 320));
|
||||
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||
} else if (isRawName(name) || isTiffName(name)) {
|
||||
const full = new Uint8Array(await file.arrayBuffer());
|
||||
const preview = await rawThumbnail(full, name);
|
||||
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||
} else {
|
||||
// A frame the browser decodes itself: the tile is made here, at the size the
|
||||
// wall paints, and kept — the walk never made one, so the decode is paid on
|
||||
// the visit that first draws the frame. A frame the decoder would not shrink
|
||||
// is drawn as it is rather than written to the store whole.
|
||||
const made = await makeTile(file);
|
||||
blob = made ?? file;
|
||||
if (made) await putPhotoThumb(photo.id, made);
|
||||
}
|
||||
return blob;
|
||||
} catch {
|
||||
// A frame the disk will not hand over is a frame with no tile, not a job that
|
||||
// stopped: the rest of the folder is still read.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Which of these frames the catalogue is short of a tile for: the rows the walk
|
||||
// wrote down that nothing has drawn yet. One transaction for the lot — asking a
|
||||
// frame at a time is a thousand transactions over a folder of a thousand.
|
||||
export async function missingThumbs(ids: string[]): Promise<string[]> {
|
||||
const missing: string[] = [];
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return ids;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(THUMBS, 'readonly');
|
||||
const store = tx.objectStore(THUMBS);
|
||||
for (const id of ids) {
|
||||
if (getCachedThumb(id)) continue;
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
if (!(req.result as { thumb?: Blob } | undefined)?.thumb) missing.push(id);
|
||||
};
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {
|
||||
// A store that will not answer is not a folder with its tiles: nothing is
|
||||
// reported missing, and nothing is made again for a read that failed.
|
||||
return [];
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// MAKE THUMBS: every frame of the folder — and, unless told otherwise, of what
|
||||
// lies under it — given a tile, and only the frames the catalogue is short of one.
|
||||
// A tile is a decode, so the frames are read a few at a time, and a job the
|
||||
// caller has moved on from gives up at its next frame: a reader who has walked to
|
||||
// another folder is not waiting for this one.
|
||||
export async function makeThumbs(
|
||||
folder: LibraryFolder,
|
||||
rel = '',
|
||||
opts: { below?: boolean; shouldStop?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
const below = opts.below !== false;
|
||||
const dir = normPath(rel);
|
||||
const rows = (await readPhotos(normPath(folder.name))) ?? [];
|
||||
const want = rows.filter((p) => {
|
||||
const pDir = normPath(p.dir ?? '');
|
||||
if (pDir === dir) return true;
|
||||
// `''` is the picked folder itself, so a row asked of the whole roll takes
|
||||
// every row of it and a row asked of a subfolder takes what lies under that.
|
||||
return below && pDir.startsWith(dir === '' ? '' : `${dir}/`);
|
||||
});
|
||||
const missing = await missingThumbs(want.map((p) => p.id));
|
||||
if (!missing.length) return 0;
|
||||
const byId = new Map(want.map((p) => [p.id, p]));
|
||||
const queue = [...missing];
|
||||
let made = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(LANES, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
if (opts.shouldStop?.()) return;
|
||||
const photo = byId.get(queue.shift()!);
|
||||
if (photo && (await makeThumb(photo))) made++;
|
||||
}
|
||||
})
|
||||
);
|
||||
return made;
|
||||
}
|
||||
|
||||
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
|
||||
@@ -248,12 +248,14 @@ export const en: Dict = {
|
||||
'lib.rename': 'RENAME FOLDER',
|
||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||
'lib.update': 'UPDATE...',
|
||||
'lib.rescan': 'RESCAN FROM THE TOP',
|
||||
'lib.rescan': 'RESCAN...',
|
||||
'lib.makeThumbs': 'MAKE THUMBS',
|
||||
'lib.thumbsMade': 'Made {n} thumbnails for {folder}.',
|
||||
'lib.resume': 'KEEP READING {folder}',
|
||||
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
|
||||
'lib.remove': 'REMOVE',
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
|
||||
'lib.nodeHint': 'Right-click to rename, update, rescan, make the missing thumbnails, collapse or remove the folder.',
|
||||
'lib.collapseAll': 'COLLAPSE ALL',
|
||||
'lib.collapse': 'COLLAPSE',
|
||||
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
||||
|
||||
@@ -260,12 +260,14 @@ export const vi = {
|
||||
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||
'lib.update': 'CẬP NHẬT...',
|
||||
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
|
||||
'lib.rescan': 'QUÉT LẠI...',
|
||||
'lib.makeThumbs': 'TẠO ẢNH THU NHỎ',
|
||||
'lib.thumbsMade': 'Đã tạo {n} ảnh thu nhỏ cho {folder}.',
|
||||
'lib.resume': 'ĐỌC TIẾP {folder}',
|
||||
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
|
||||
'lib.remove': 'BỎ',
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
|
||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, tạo ảnh thu nhỏ còn thiếu, thu gọn hay bỏ thư mục.',
|
||||
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
||||
'lib.collapse': 'THU GỌN',
|
||||
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
||||
|
||||
Reference in New Issue
Block a user