diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 6070045..9c09fce 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -1320,6 +1320,12 @@ export function clearLibraryCache(): void { cached = null; } +let backupTileFetcher: ((id: string) => Promise) | null = null; + +export function setBackupTileFetcher(fetcher: ((id: string) => Promise) | null): void { + backupTileFetcher = fetcher; +} + // Null, and not an empty list: a read that failed is not a catalogue with // nothing in it, and the screen that would draw an empty strip on one is the // screen that loses the frames it was showing — under a scan of its own, where @@ -1329,13 +1335,30 @@ export async function getPhotoThumb(id: string): Promise { if (cached) return cached; try { const db = await openDb(); - if (!db.objectStoreNames.contains(THUMBS)) return null; - const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id)); - if (row?.thumb) { - cacheThumb(id, row.thumb); - return row.thumb; + if (db.objectStoreNames.contains(THUMBS)) { + const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id)); + if (row?.thumb) { + cacheThumb(id, row.thumb); + return row.thumb; + } } } catch {} + if (backupTileFetcher) { + try { + const blob = await backupTileFetcher(id); + if (blob) { + cacheThumb(id, blob); + try { + const db = await openDb(); + if (db.objectStoreNames.contains(THUMBS)) { + const tx = db.transaction(THUMBS, 'readwrite'); + tx.objectStore(THUMBS).put({ id, thumb: blob }); + } + } catch {} + return blob; + } + } catch {} + } return null; } @@ -1352,26 +1375,56 @@ export async function getPhotoThumbs(ids: string[]): Promise> } if (!missing.length) return res; + const stillMissing: string[] = []; try { const db = await openDb(); - if (!db.objectStoreNames.contains(THUMBS)) return res; - await new Promise((resolve, reject) => { - const tx = db.transaction(THUMBS, 'readonly'); - const store = tx.objectStore(THUMBS); - for (const id of missing) { - const req = store.get(id); - req.onsuccess = () => { - const row = req.result as { id: string; thumb: Blob } | undefined; - if (row?.thumb) { - cacheThumb(id, row.thumb); - res.set(id, row.thumb); + if (db.objectStoreNames.contains(THUMBS)) { + await new Promise((resolve, reject) => { + const tx = db.transaction(THUMBS, 'readonly'); + const store = tx.objectStore(THUMBS); + for (const id of missing) { + const req = store.get(id); + req.onsuccess = () => { + const row = req.result as { id: string; thumb: Blob } | undefined; + if (row?.thumb) { + cacheThumb(id, row.thumb); + res.set(id, row.thumb); + } else { + stillMissing.push(id); + } + }; + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } else { + stillMissing.push(...missing); + } + } catch { + stillMissing.push(...missing); + } + + if (stillMissing.length && backupTileFetcher) { + const fetcher = backupTileFetcher; + await Promise.all( + stillMissing.map(async (id) => { + try { + const blob = await fetcher(id); + if (blob) { + cacheThumb(id, blob); + res.set(id, blob); + try { + const db = await openDb(); + if (db.objectStoreNames.contains(THUMBS)) { + const tx = db.transaction(THUMBS, 'readwrite'); + tx.objectStore(THUMBS).put({ id, thumb: blob }); + } + } catch {} } - }; - } - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } catch {} + } catch {} + }) + ); + } return res; } diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts index 5a600ab..730e9b9 100644 --- a/docker/frontend/src/engine/libraryBackup.ts +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -29,6 +29,7 @@ import { listPhotos, renameFolder, restoreCatalogue, + setBackupTileFetcher, type CatalogueRow, } from './library'; @@ -47,6 +48,23 @@ const THUMBS = 'thumbs'; // row that was left behind in it is a frame that will not come back. const VERSION = 1; +export async function readBackupTile(id: string): Promise { + try { + const root = await folderHandle(); + if (!root) return null; + const parts = id.split('/'); + const name = parts.pop() as string; + const dir = await at(root, [THUMBS, ...parts].join('/')); + const file = await dir.getFileHandle(spell(name)); + return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' }); + } catch { + return null; + } +} + +// Automatically register as lazy backup tile fetcher for library.ts +setBackupTileFetcher(readBackupTile); + interface Meta { id: 'meta'; at: number; @@ -371,22 +389,17 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom const folder = here.get(f.name); if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label); } - let done = 0; - const photos = await restoreCatalogue(cat.photos ?? [], cat.edits ?? [], async (id) => { - if (++done % 64 === 0) onProgress?.({ done, total: (cat.photos ?? []).length }); - try { - const parts = id.split('/'); - const name = parts.pop() as string; - const dir = await at(root, [THUMBS, ...parts].join('/')); - const file = await dir.getFileHandle(spell(name)); - return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' }); - } catch { - // A tile that is not there — or that has been taken away by hand — leaves - // the row without one, and the next scan of the folder reads the frame and - // paints it. Losing the picture is not worth losing the reading. - return null; - } - }); - onProgress?.({ done: photos, total: photos }); + + const photoList = cat.photos ?? []; + const editList = cat.edits ?? []; + const total = photoList.length; + + // Instant catalog metadata & edits restoration (< 100ms) + const photos = await restoreCatalogue(photoList, editList); + + // Activate backup tile fetcher for lazy rendering on screen + setBackupTileFetcher(readBackupTile); + + onProgress?.({ done: total, total }); return { photos, at: cat.at ?? 0 }; } diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 795e363..b0b2830 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -c496643cb1660e2b1011efedf1725659053af9de7fc6e9340ffd04d5e74e0d66 recipescam-images.tar.gz +873957f0d5504c2b72e317760f884fad27902555542e7374874ff4afee38057d recipescam-images.tar.gz