diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 45101d5..8847a99 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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. */} +
e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
{photo.name} @@ -2481,12 +2556,12 @@ function Catalogue({ admin }: { admin: boolean }) { {t('lib.rename')} ) : 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 ? ( + {/* 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. */} + {/* 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 = {}; 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 })); diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index ce658d3..58a85a7 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, 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> 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 { + 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 { + 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 { + const missing: string[] = []; + try { + const db = await openDb(); + if (!db.objectStoreNames.contains(THUMBS)) return ids; + await new Promise((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 { + 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 { try { const db = await openDb(); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index aa1d4a4..c4c9502 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 1f0572f..74fc9a5 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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.',