// The local catalogue: the folders a visitor has handed over, a thumbnail of // every frame found in them, and the recipe each frame was last graded with. // Nothing here leaves the machine — no upload, no server, no account. // // The three stores are what the folder is made of: `folders` keeps the directory // handle so the catalogue survives a reload without a second trip through the // picker, `photos` keeps a file handle (the RAW itself is read only when a frame // is opened) plus the small JPEG the grid paints, and `edits` keeps the recipe a // frame was left at, keyed by the same id the grid uses. // // ponytail: Dexie would wrap this in three lines, but IndexedDB stores a // FileSystemHandle by itself and the whole surface is seven calls — a dependency // does not pay for itself here. Revisit if the catalogue grows queries (tags, // smart collections) that hand-rolled cursors would make ugly. import type { Recipe } from '../../shared/types'; import { readCapturedAt } from './imageOps'; import { isRawName, rawThumbnail } from './rawDevelop'; import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk'; const DB_NAME = 'recipescam-library'; const FOLDERS = 'folders'; const PHOTOS = 'photos'; const EDITS = 'edits'; // The grid cell is ~220px wide and a retina display doubles it: 512 on the long // edge is the largest a tile ever shows, and it costs ~30KB per frame. const THUMB_MAX = 512; const THUMB_QUALITY = 0.75; // Frames go down in batches, so a 3000-file folder is 60 transactions rather // than 3000 of them. const BATCH = 50; // Frames read at once. Reading a frame is nearly all waiting — the bytes come off // the disk, the decode runs on a thread of its own — and a wait that is not spent // on the next frame is time the roll does not get back. Past a few, the disk and // the decoder are the limit and the page has less room to breathe. const LANES = 4; // How often the walk's position is written down, in the same seconds as the // catalogue is read back on: the position is the whole of what the walk has found // and not read, and writing it is a string of a few megabytes on a long roll. const WALK_MS = 2000; // One LibRaw open at a time, whoever asks for it. A RAW is opened inside a worker // the library builds with a quarter of a gigabyte of linear memory of its own // (libraw-wasm instantiates emscripten's shared memory at 256MB), and the open // copies the frame into it whole: the four lanes are what a folder of RAW would // otherwise read at once, and measured on a roll of 48 22.6MB frames that is a // 1335MB page against a 565MB one before the roll is picked — past what a page is // given, and the page goes with it, which is the crash this answers. One at a time // the same roll peaks at 1180MB: the frames around the one being opened still have // their bytes read, their decodes run and their tiles encoded side by side, because // only the open queues. A folder of JPEGs never touches this. let rawTurn: Promise = Promise.resolve(); function oneRawAtATime(fn: () => Promise): Promise { const turn = rawTurn.then(fn, fn); rawTurn = turn.catch(() => undefined); return turn; } export interface LibraryFolder { // The directory's own name: the store's key, and the prefix of every frame id // found in it. A rename never touches this. name: string; // What the tree paints instead, when the visitor has renamed the folder. label?: string; handle: FileSystemDirectoryHandle; } export interface LibraryPhoto { // `${folder}/${dir}/${name}` — one frame appears once in the catalogue, under // the folder it was found in. id: string; folder: string; // The subfolder it sits in, relative to the picked folder — '' at the root. // This is what the tree column reads. dir: string; name: string; handle: FileSystemFileHandle; thumb: Blob | null; // When the shutter fired, per EXIF, else the file's own lastModified. taken: number; size: number; // When the file itself was last written, as the browser reports it. This — with // the size — is what says a frame has not moved since the last scan and does not // have to be read again; `taken` is the shutter's own time and says nothing // about the file. A frame stored before this field existed has none, and is // read one last time. mtime?: number; // The reader's own score for the frame, 0 or absent when they have not given // it one. It belongs to the catalogue and not to the disk — nothing about the // file changes — so a rescan carries it over, the way it carries `addedAt`. star?: number; addedAt: number; } // A folder a scan has walked into, whether or not a frame has been read out of // it yet: the column draws a roll from these before its frames arrive. They are // the scan's own report and live no longer than it does — a reload draws the // folders that hold frames, and the next scan names the rest again. export interface LibraryDir { // `${folder}/${rel}` — the tree key, the same way a frame id is spelled. id: string; folder: string; // The path under the picked folder, no leading or trailing slash. rel: string; } export interface LibraryEdit { photoId: string; recipe: Recipe; updatedAt: number; } export const SUPPORTED = /\.(jpe?g|png|webp|heic|heif|tiff?|avif)$/i; export function isSupportedPhoto(name: string): boolean { return isRawName(name) || SUPPORTED.test(name); } export function photoId(folder: string, name: string): string { return `${folder}/${name}`; } // The picker is Chromium-only (Chrome, Edge, Opera, Brave): Firefox and Safari // have no way to hand a page a folder that outlives the visit, and the caller // says so instead of showing a button that cannot work. export function canBrowseFolders(): boolean { return typeof window !== 'undefined' && 'showDirectoryPicker' in window; } let dbPromise: Promise | null = null; function openDb(): Promise { dbPromise ??= new Promise((resolve, reject) => { // No version on purpose. A version the browser has to reach for is a version // another tab can block: a tab still running the previous build holds the // catalogue open, the upgrade waits on it, and this one never gets an answer // — the screen comes up empty on a catalogue that is all there. Opening // without one takes the catalogue as it is and creates it when it is missing. const req = indexedDB.open(DB_NAME); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' }); if (!db.objectStoreNames.contains(PHOTOS)) { const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' }); photos.createIndex('folder', 'folder'); photos.createIndex('taken', 'taken'); } if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return dbPromise; } // One request per call: the transaction is the store's own, which is all a // keyed get/put/delete and a getAll ever need. A cursor-based scan (the folder // walk) goes through `walk` below instead, because it holds one transaction // across many requests. function ask(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise { return openDb().then( (db) => new Promise((resolve, reject) => { const req = run(db.transaction(store, mode).objectStore(store)); req.onsuccess = () => resolve(req.result as T); req.onerror = () => reject(req.error); }) ); } // --- folders --------------------------------------------------------------- export async function listFolders(): Promise { try { const rows = await ask(FOLDERS, 'readonly', (s) => s.getAll()); return rows.sort((a, b) => a.name.localeCompare(b.name)); } catch { return []; } } // The picker's own dialog; `id` makes the browser reopen at the folder this page // was last pointed at, which is what makes a second visit one click. export async function pickFolder(): Promise { if (!canBrowseFolders()) throw new Error('no-folder-picker'); const handle = await ( window as unknown as { showDirectoryPicker: (o?: unknown) => Promise } ).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-library' }); const folder: LibraryFolder = { name: handle.name, handle }; await ask(FOLDERS, 'readwrite', (s) => s.put(folder)); return folder; } // A rename is a label over the folder, not a new folder: everything downstream — // the frame ids, the thumbnail store, the recipe each frame was left at — hangs // off `name`, which stays the directory's own. export async function renameFolder(folder: LibraryFolder, label: string): Promise { await ask(FOLDERS, 'readwrite', (s) => s.put({ ...folder, label })); } export async function removeFolder(name: string): Promise { const ids = (await listPhotos(name)).map((p) => p.id); const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite'); tx.objectStore(FOLDERS).delete(name); const photos = tx.objectStore(PHOTOS); for (const id of ids) { photos.delete(id); tx.objectStore(EDITS).delete(id); } tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); // The reading's position goes with the folder: a folder that is gone is not // one to carry on reading, and a folder picked again starts at the top. await clearWalk(name); } // A handle kept in IndexedDB comes back without its permission: the browser // dropped it when the tab closed, and only the visitor can hand it back. Asking // costs one quiet prompt, which is why this runs at start-up rather than the // moment a frame is clicked. `requestPermission` needs the user's gesture in // some builds, so a refusal is not fatal — the folder is listed as needing a // click. export async function ensurePermission(handle: FileSystemHandle, write = false): Promise { // `FileSystemPermissionMode` is not in the DOM lib this project compiles // against, and the two values are the whole type. type PermissionMode = 'read' | 'readwrite'; const mode: PermissionMode = write ? 'readwrite' : 'read'; const askable = handle as FileSystemHandle & { queryPermission?: (o: { mode: PermissionMode }) => Promise; requestPermission?: (o: { mode: PermissionMode }) => Promise; }; try { if (!askable.queryPermission) return false; if ((await askable.queryPermission({ mode })) === 'granted') return true; return (await askable.requestPermission?.({ mode })) === 'granted'; } catch { return false; } } // --- thumbnails ------------------------------------------------------------ // EXIF is one JPEG segment, and a segment is 64KB at most — but only a JPEG keeps // it in the first one, so the slice that is handed to the parser is a few hundred // kilobytes rather than the whole frame. Reading 8MB through a JS array to find a // date in the first kilobyte of it is most of what a scan of JPEGs costs. export const HEAD_BYTES = 256 * 1024; // The probe is decoded at this width, purely to learn which edge of the frame is // the long one; the decoder scales the real one off that. A JPEG does not need // it: its own header says how big the frame is, in the first few hundred bytes. const PROBE_PX = 8; // A JPEG's frame header — the SOF segment — carries the frame's size, and it sits // well inside the bytes already read off the disk for the date. Reading it costs a // walk over a few hundred bytes where the probe costs a second decode of every // pixel of the file. function jpegSize(bytes: Uint8Array): { width: number; height: number } | null { if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; for (let i = 2; i + 8 < bytes.length; ) { if (bytes[i] !== 0xff) { i++; continue; } const marker = bytes[i + 1]; // SOF0–SOF15 minus the ones that are not frames: DHT, JPG, DAC. if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return { height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] }; } const length = (bytes[i + 2] << 8) | bytes[i + 3]; if (length < 2) return null; i += 2 + length; } return null; } // What a frame whose header this code cannot read is measured with: one decode // eight pixels wide. Only the aspect comes out of it — the two numbers that say // which edge is the long one — and the frame is decoded once more for real. async function sizeOf(blob: Blob): Promise<{ width: number; height: number } | null> { try { const probe = await createImageBitmap(blob, { resizeWidth: PROBE_PX }); const size = { width: probe.width, height: probe.height }; probe.close(); return size; } catch { return null; } } // The tile, read at the size the tile is painted at. Asking the decoder for 512 // on the long edge scales on the way out of the decoder — a quarter of the pixels // of a 24MP frame ever exist — where decoding the frame whole and drawing it // small pays for every one of them. The probe says which edge that is, and the // canvas is only here to re-encode to JPEG. async function tile(blob: Blob, size?: { width: number; height: number } | null): Promise { try { const known = size ?? (await sizeOf(blob)); if (!known) return null; const wide = known.width >= known.height; const bitmap = await createImageBitmap(blob, wide ? { resizeWidth: THUMB_MAX } : { resizeHeight: THUMB_MAX }); try { const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(bitmap, 0, 0); return await new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY)); } finally { bitmap.close(); } } catch { return null; } } // --- scanning -------------------------------------------------------------- export interface ScanProgress { folder: string; total: number; done: number; added: number; // How many frames have reached the catalogue. `done` counts the frames the // reading has got to, which is nearly all of them long before they are stored, // so a screen that read the catalogue back on `done` would read it again every // frame — and reading it back means every thumbnail in it again. This moves a // batch at a time, which is exactly when there is something new to read. written: number; // How many frames the scan has read out of each row of the tree, keyed the way // the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for // the rest) and counted the way the rows count: a frame sits on its own row and // on every row above it. A fresh object every frame, so a screen drawing the // column sees a change; the frames themselves only reach the catalogue in one // batch at the end, which would leave every row reading zero until then. counts: Record; // Every folder the walk has been into so far, a fresh list each time: the // column draws them while the frames under them are still being read. Not // filed away — a folder name is worth nothing once the scan it came from ends. dirs: LibraryDir[]; } // Walk the folder, keep what is new or changed, and leave the rest alone: a // second scan of a 2000-file folder only reads the frames that moved. A scan // that was cut off — the tab closed, the reader stopped it — is finished by the // next one: the frames it got through are skipped on their size and their time, // so asking again on the way in is the whole of resuming. // // The walk itself lives in `rollWalk`: layer by layer from the picked folder // down, one bounded pass at a time, with the folder the reader just clicked // pulled to the front of the queue. `jump` is that click. // // ponytail: the lanes run on the main thread. The reading itself is done // elsewhere (disk, the decoder's own threads), so what is left here is small — // but a 5000-frame folder still makes the page lumpy. Move the walk into a // worker (LibRaw is happy in one) if a catalogue that large is ever real. // Where the reading had got to. A reload in the middle of a few thousand frames // takes the page with it, and what it would take from the reading is the // position: the folders the walk had been through, the ones still in its queue, // and the frames it had found and not read. None of that is in the catalogue — // the catalogue only holds the frames that were read — so the reading would // start again at the top of the roll and walk every folder a second time. // // The position is held for the whole origin, which is the point: the tab that // reloads carries on from the frame it stopped at, and so does the next context // to open the app — the installed one beside the browser, where session storage, // being the tab's own, handed it nothing and the walk started at the top of the // roll a second time, every folder listed again and every frame it already held // read again only to be skipped. The file goes when the reading finishes. // // It is a file in the origin private file system and not a key in local storage, // which is where it was. The position is one path per frame the walk has found // and not read — about twenty-eight characters each, measured over a folder of // three thousand — and local storage on this origin takes 5,000,000 characters // and then throws, also measured. That is a hundred and seventy-five thousand // frames: past it the first write of a long roll fails, the position is nowhere, // and the next visit walks the whole roll from the top — the reader's own // complaint, and one a roll of RAW reaches in a morning. OPFS takes the same text // as a file, has room for a catalogue's worth of them, and is the origin's the // same way. An unwritable position is still a reading that starts at the top: // private mode lands there, as it always did. const WALK_DIR = 'walk'; const WALK_FILE = (folder: string) => `${encodeURIComponent(folder)}.json`; async function walkFile(folder: string, create: boolean): Promise { const root = await navigator.storage?.getDirectory?.(); if (!root) return null; const dir = await root.getDirectoryHandle(WALK_DIR, { create }); return dir.getFileHandle(WALK_FILE(folder), { create }); } interface WalkSaved { // The folders the walk is through, and the ones it has not read yet, in the // order it was going to read them. walked: string[]; pending: string[]; // The frames the walk has found and the reading has not got to. The front of // this is the frame a reloaded reading picks up at. frames: string[]; // How many of the front of `frames` were already counted as read by the reading // that wrote this down: the rows are in hand and not in the catalogue yet, so // the next reading reads them again — and counts none of them twice. restored: number; progress: ScanProgress; } async function loadWalk(folder: string): Promise { // One reading at a time: a peer that has announced this folder is walking it // right now, and what it has written down is a step of a walk still in // progress. Taken up here, two readings would write the same roll down at // once, each undoing the other's queue. In the tab's own storage this could not // come up; now that the position is the origin's, it can. if (peer?.folder === folder) return null; try { const fh = await walkFile(folder, false); if (!fh) return null; const saved = JSON.parse(await (await fh.getFile()).text()) as WalkSaved; return Array.isArray(saved?.walked) && Array.isArray(saved?.pending) && Array.isArray(saved?.frames) && Number.isInteger(saved?.restored) && saved.progress ? saved : null; } catch { return null; } } // A position that cannot be written down is a reading that starts from the top, // which is what this did before — private mode lands here, as it always did. async function saveWalk( folder: string, walked: string[], pending: string[], frames: string[], restored: number, progress: ScanProgress ): Promise { try { const fh = await walkFile(folder, true); if (!fh) return; const w = await fh.createWritable(); await w.write(JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved)); await w.close(); } catch { // Nothing to write down: the reading starts from the top, as it did before. } } async function clearWalk(folder: string): Promise { try { const root = await navigator.storage?.getDirectory?.(); const dir = await root?.getDirectoryHandle(WALK_DIR, { create: false }); await dir?.removeEntry(WALK_FILE(folder)); } catch { // Absent is the desired state. } } // The frames a written-down position names, asked of the root again: a frame is // spelled by its path, and a handle is not a thing that can be written down. The // folder part is asked once and kept, because a reading that stopped in a folder // of a few thousand frames has nearly all of them in the same folder. async function framesAt(root: FileSystemDirectoryHandle, rels: string[]): Promise { const dirs = new Map(); const out: WalkFile[] = []; for (const rel of rels) { const cut = rel.lastIndexOf('/'); const at = cut < 0 ? '' : rel.slice(0, cut); let dir = dirs.get(at); if (!dir) { const opened = await openAt(root, at); // A folder that will not open takes its frames with it; they are read // again when the walk reaches it, if it opens at all. if (!opened) continue; dirs.set(at, opened); dir = opened; } try { out.push({ handle: await dir.getFileHandle(rel.slice(cut + 1)), rel }); } catch { // A frame that is gone is not a frame to read. } } return out; } export async function scanFolder( folder: LibraryFolder, onProgress?: (p: ScanProgress) => void, shouldStop?: () => boolean, jump?: () => string | null ): Promise { const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p])); const saved = await loadWalk(folder.name); const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) }; // A queue comes back as paths, so the folders are asked for again; a reading // with nothing written down starts at the picked folder. if (saved) { for (const rel of saved.pending) { const dir = await openAt(folder.handle, rel); if (dir) tree.pending.push({ dir, rel }); } } else { tree.pending.push({ dir: folder.handle, rel: '' }); } // The frames this reading has found and not read: the walk fills it, the lanes // empty it, and a frame taken off the front is one a reload will not read // again. Also where a reading that was cut off picks up. const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : []; // What the passes found: the folders this scan walked into, and the names they // put in the column. `names` is cleared a pass at a time; `found` keeps the // whole scan so a folder that has gone from the disk goes from the column too. const names: string[] = []; const progress: ScanProgress = saved?.progress ?? { folder: folder.name, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {}, }; progress.folder = folder.name; // A position written down by an older visit has no count to carry on from. progress.written ??= 0; const found = new Set(progress.dirs.map((d) => d.id)); const counts: Record = progress.counts; // Count the frame the moment the scan gets to it, before it knows whether the // frame is new: the rows say how far the reading has come, not what it kept. const count = (rel: string) => { const cut = rel.lastIndexOf('/'); counts[folder.name] = (counts[folder.name] ?? 0) + 1; let path = ''; for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) { if (!part) continue; path = path ? `${path}/${part}` : part; const key = `${folder.name}/${path}`; counts[key] = (counts[key] ?? 0) + 1; } progress.counts = { ...counts }; }; // The folders this scan has walked into, kept for the whole of it: the column // is redrawn from this list, so it only ever grows. A reading that was cut off // hands the list on with the rest of its position. const dirs: LibraryDir[] = progress.dirs; let batch: LibraryPhoto[] = []; // The frames in hand: the ones a lane has been handed, and the ones whose row is // in `batch` and not yet in the catalogue. They are on neither side of the line a // position is written on — the catalogue does not hold them and the walk will not // find them again — so a reload has to read them again, and they are also the // frames the written-down count has already counted. let inHand: string[] = []; const pathOf = (row: LibraryPhoto) => (row.dir ? `${row.dir}/${row.name}` : row.name); // The position goes down on a clock, not on every batch. The whole of it is // written each time — a copy of every path the walk has found and not read — // and on a roll of a hundred thousand that is a few megabytes turned into a // string per batch, which is the page held still for the whole roll. A position // a step behind is a frame or two read again on the next visit, and a frame read // again is skipped on its size and its time. let wroteAt = 0; const write = async () => { const at = Date.now(); if (at - wroteAt < WALK_MS) return; wroteAt = at; const held = [...inHand, ...batch.map(pathOf)]; await saveWalk( folder.name, [...tree.walked], tree.pending.map((p) => p.rel), [...held, ...entries.map((e) => e.rel)], held.length, progress ); }; // One transaction per batch, a put per row: a store with `keyPath: 'id'` takes // a record, not an array of them. The position is written with it — a batch is // the beat the reading and the stored position are kept in step at, so at most // one batch is read again after a reload, and a frame that is read again is // skipped on its size and its time. const flush = async () => { if (!batch.length) return; const rows = batch; batch = []; const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(PHOTOS, 'readwrite'); const photos = tx.objectStore(PHOTOS); for (const row of rows) photos.put(row); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); progress.written += rows.length; await write(); }; // One frame, end to end: its bytes, its tile, its shutter time, and the row the // catalogue keeps. A frame that has not moved is dropped on its size and its // time before a byte of it is read, which is what makes a second scan of the // same folder cheap; a frame that will not read at all is skipped by the lane // that ran it — one bad file in a folder is not a failed folder. const readOne = async (handle: FileSystemFileHandle, rel: string): Promise => { const file = await handle.getFile(); const id = photoId(folder.name, rel); const seen = known.get(id); // Unchanged means the same size and the same write time: the frame is left // exactly as the catalogue already holds it. This is what makes a second scan // of the same folder cheap, and what finishes a roll whose first scan was cut // short — the frames it got through are skipped, the rest are read. if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) { // A row restored from a backup has no handle: a handle is not a thing that // can be written to a file, so the row comes back with everything but the // one thing that opens the frame, and the walk is where it gets it back. // The row is put again — the bytes of the frame are still never read. if (!seen.handle) { batch.push({ ...seen, handle }); if (batch.length >= BATCH) await flush(); } return; } // A RAW is read whole because that is the only way LibRaw can seek to the // preview the camera wrote inside it; a JPEG is handed to the decoder as it // is, and only its header is read for the date. Both tiles come off what the // camera wrote — a RAW's preview is a copy, where a develop is every pixel. const raw = isRawName(file.name); const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer())); const preview = raw ? await rawThumbnail(bytes) : null; // The tile is the preview the camera wrote inside a RAW, or the frame itself. // Both carry their own size in their first few hundred bytes — the same bytes // the date comes out of — so the decoder is handed the size instead of being // asked with a second decode of the whole frame to learn which edge is long. const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file; const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes); const thumb = src ? await tile(src, size) : null; const taken = (await readCapturedAt(bytes)) ?? file.lastModified; const cut = rel.lastIndexOf('/'); batch.push({ id, folder: folder.name, dir: cut < 0 ? '' : rel.slice(0, cut), name: handle.name, handle, thumb, taken, size: file.size, mtime: file.lastModified, // The reader's own score is not the file's: a frame that is read again // keeps the stars it was given. star: seen?.star, addedAt: seen?.addedAt ?? Date.now(), }); progress.added++; inHand = inHand.filter((r) => r !== rel); if (batch.length >= BATCH) await flush(); }; // The frames in hand, read a few at a time: reading a frame is almost all // waiting — the bytes come off the disk, the decode runs on a thread of its own // — and the wait of one frame is spent on the next one instead of sitting // still. Lanes are why the same roll lands in a fraction of the time; past a // few the disk and the decoder are the limit. A frame leaves the queue the // moment its lane is handed it, so the position on disk trails the reading by // no more than one batch. const drain = async () => { const lanes = new Set>(); while (entries.length) { if (shouldStop?.()) { stop = true; break; } const { handle, rel } = entries.shift()!; inHand.push(rel); // A frame a reload handed back that was counted already is read without // being counted twice; the ones behind it are frames that reading found and // never reached, and they are counted here as they are reached. if (restored) restored--; else { progress.done++; count(rel); } // A RAW takes the one open there is before its lane does anything, so a // folder of them is read a frame at a time whatever the lanes say. const read = isRawName(handle.name) ? oneRawAtATime(() => readOne(handle, rel)) : readOne(handle, rel); const lane = read .catch(() => undefined) .finally(() => lanes.delete(lane)); lanes.add(lane); if (lanes.size >= LANES) await Promise.race(lanes); onProgress?.(progress); } await Promise.all(lanes); }; // How many of the frames in hand were counted by the reading that handed them // back: the rows it had read and not yet stored. The rest of the queue is frames // it found and never got to, and this reading counts those as it reaches them — // the rows and the counter both say how far the reading has come, and a reload // that stopped at a frame must not count it twice. let restored = Math.min(saved?.restored ?? 0, entries.length); let stop = false; let walked = false; // One bounded pass at a time, and the pass yields between frames, so a folder // clicked while the scan runs is read on the next jump rather than after the // whole tree. Nothing clicked: the picked folder and its frames, then each // layer below with theirs, deepest last — the order `pending` is filled in. do { names.length = 0; // The queue a reading that was cut off left behind goes first, and the walk // is asked for nothing until it is through: the frames already found are // what that reading was in the middle of. if (!entries.length) { walked = await walkPass(tree, entries, names, isSupportedPhoto, jump ?? (() => null)); // The names come in before the frames they hold: the column grows one pass // ahead of the strip, which is the whole point of reading layer by layer. for (const rel of names) { const id = `${folder.name}/${rel.slice(0, -1)}`; if (found.has(id)) continue; found.add(id); dirs.push({ id, folder: folder.name, rel: rel.slice(0, -1) }); } // The column is handed the names the moment the pass is through, before a // single frame under them has been read. A copy, so the redraw has // something new to look at rather than the list growing under it, and a // reading that is cut off hands the same list on with its position. progress.dirs = dirs.slice(); // Every frame found is a frame the reading has or will reach, so what is // still in the queue is what is left of the total. progress.total = progress.done + entries.length; // The position before a frame of this pass is looked at: a reload here // reads the pass's frames again, and a frame already in the catalogue is // skipped anyway — where the other order would leave the last pass's // frames out of the catalogue until the next visit. await write(); onProgress?.(progress); } await drain(); } while (!walked && !stop); await flush(); // A reading that came to its end has no position worth keeping: the next one // walks the roll from the top and skips what has not moved. if (!stop) await clearWalk(folder.name); return progress; } // --- the scan in flight ---------------------------------------------------- // A scan belongs to the tab, not to the screen it was started from. A roll takes // minutes, and the reader who started it has every reason to go to the studio and // work on a frame while it runs — so the screen that started it may well be gone // before the scan is. What that screen would have held lives here instead: one // scan at a time, watched by whichever screen is up. export interface ScanSession { // The folder being read, by name. folder: string; progress: ScanProgress; // What the reader has asked for since: stop, or read this folder next (the path // as `rollWalk` spells it — `''` for the picked folder itself). stop: boolean; jump: string | null; } let live: ScanSession | null = null; const watchers = new Set<() => void>(); const tell = () => { for (const fn of watchers) fn(); }; // --- the other windows on this origin -------------------------------------- // An installed app and a browser tab share one origin, one catalogue and one // roll: without a word between them the second window opens, finds nothing in // flight here, and reads the same frames again — two readers of one folder, two // RAW decoders at 256MB apiece, for a progress the first window already has. // So the window holding the reading says so, on every frame and on a heartbeat, // and the windows that do not hold it take the reading as their own: they draw // the same progress, offer the same stop, and above all start nothing. const CHANNEL = 'recipescam-library'; const SELF = Math.random().toString(36).slice(2); // A window that has heard nothing for this long has a peer that was closed, or // one that is wedged — either way the reading is nobody's again, and this window // may pick it up as it always could. const PEER_MS = 6000; const HEART_MS = 2000; // One round trip, for the window that has just opened: whether another window // holds the reading is what decides whether this one starts its own. const ASK_MS = 250; interface Peer { folder: string; progress: ScanProgress; at: number; } let peer: Peer | null = null; let peerTimer: ReturnType | null = null; let heart: ReturnType | null = null; let askers: ((busy: boolean) => void)[] = []; // A browser without the channel (an old one, a worker) simply has no peers, and // everything below is a no-op there. const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(CHANNEL) : null; const say = (m: Record): void => { channel?.postMessage({ ...m, from: SELF }); }; const dropPeer = (): void => { if (peerTimer) { clearTimeout(peerTimer); peerTimer = null; } if (!peer) return; peer = null; tell(); }; const holdPeer = (folder: string, progress: ScanProgress): void => { peer = { folder, progress, at: Date.now() }; for (const fn of askers) fn(true); askers = []; if (peerTimer) clearTimeout(peerTimer); peerTimer = setTimeout(dropPeer, PEER_MS); tell(); }; // A peer holds the reading the way this window holds one, as far as a screen is // concerned: the same shape, read the same way. Whether the reading is stopped // or jumped from here is answered by relaying it, which is why the window // watching can offer the buttons the window reading has. const peerSession = (): ScanSession | null => peer ? { folder: peer.folder, progress: peer.progress, stop: false, jump: null } : null; const announce = (): void => { if (live) say({ k: 'scan', folder: live.folder, progress: live.progress }); }; if (channel) { channel.onmessage = (e: MessageEvent) => { const m = e.data as { k?: string; from?: string; folder?: string; progress?: ScanProgress; rel?: string | null } | null; if (!m || m.from === SELF) return; if (m.k === 'scan' && m.folder && m.progress) holdPeer(m.folder, m.progress); else if (m.k === 'end') { if (peer?.folder === m.folder) dropPeer(); } else if (m.k === 'who') announce(); // a window just opened and asks who reads else if (m.k === 'stop' && live && live.folder === m.folder) { live.stop = true; tell(); } else if (m.k === 'jump' && live && live.folder === m.folder) live.jump = m.rel ?? null; }; } // Whether another window is reading right now, asked rather than assumed: the // window that opens beside a running scan knows nothing until the reading // answers, and reading the disk twice over is exactly what the answer prevents. export function scanBusy(): Promise { if (live) return Promise.resolve(true); if (!channel) return Promise.resolve(false); const answered = new Promise((resolve) => { const settle = (busy: boolean): void => { askers = askers.filter((fn) => fn !== settle); resolve(busy); }; askers.push(settle); // Nobody answered: no reading is running, and waiting longer only delays // this window's own. setTimeout(() => settle(false), ASK_MS); }); say({ k: 'who' }); return answered; } // The scan in flight, or null. The same object for the whole of the scan, with // `progress` replaced on every pass — watchers are told, so they re-read it. // A reading another window holds answers the same way: the screens no longer // know or care which window the reading is in. export function scanSession(): ScanSession | null { return live ?? peerSession(); } // Watch, and get the unsubscribe back: the shape an effect already has. export function watchScan(fn: () => void): () => void { watchers.add(fn); return () => { watchers.delete(fn); }; } export function stopScan(): void { // A reading another window holds is stopped by telling it so: the button this // window drew is the same button, on the window that can act on it. if (!live) { if (peer) say({ k: 'stop', folder: peer.folder }); return; } live.stop = true; tell(); } export function jumpScan(rel: string | null): void { if (live) { live.jump = rel; return; } if (peer) say({ k: 'jump', folder: peer.folder, rel }); } // Start reading a roll. Only one at a time: the walk reads one frame at a time on // this thread, so a second scan would only slow the first one down. The promise // settles when the scan does — the caller that started it may be long gone. export function startScan(folder: LibraryFolder): Promise { if (live) return Promise.reject(new Error('a scan is already running')); const session: ScanSession = { folder: folder.name, progress: { folder: folder.name, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {} }, stop: false, jump: null, }; live = session; tell(); announce(); // Between two frames there is nothing to announce, and a window that hears // silence long enough would take the reading for abandoned and start its own. if (heart) clearInterval(heart); heart = setInterval(announce, HEART_MS); return scanFolder( folder, (p) => { session.progress = p; tell(); announce(); }, () => session.stop, () => session.jump ).finally(() => { if (heart) { clearInterval(heart); heart = null; } live = null; say({ k: 'end', folder: session.folder }); tell(); }); } // --- reading --------------------------------------------------------------- export async function listPhotos(folder?: string): Promise { try { const rows = await ask(PHOTOS, 'readonly', (s) => folder ? s.index('folder').getAll(folder) : s.getAll() ); // Newest shutter first — a folder of stills reads in the order it was shot. return rows.sort((a, b) => b.taken - a.taken); } catch { return []; } } export async function getPhoto(id: string): Promise { try { return (await ask(PHOTOS, 'readonly', (s) => s.get(id))) ?? null; } catch { return null; } } // Score a frame, or take the score back with a 0. Nothing on the disk changes — // the stars are the catalogue's own — so this is a read and a put, and a frame // that is read again on the next scan carries its score over. export async function setStar(id: string, star: number): Promise { const photo = await getPhoto(id); if (!photo) return; try { await ask(PHOTOS, 'readwrite', (s) => s.put({ ...photo, star })); } catch { // Private mode: the score lives no longer than the visit. } } // The frame itself, at last: nothing is read from the disk until this runs, so // the catalogue costs thumbnails and no more. export async function readPhotoFile(photo: LibraryPhoto): Promise { return photo.handle.getFile(); } // --- edits ----------------------------------------------------------------- export async function saveEdit(id: string, recipe: Recipe): Promise { try { const edit: LibraryEdit = { photoId: id, recipe, updatedAt: Date.now() }; await ask(EDITS, 'readwrite', (s) => s.put(edit)); } catch { // Private mode: the frame is still editable, it just forgets the recipe. } } export async function loadEdit(id: string): Promise { try { return (await ask(EDITS, 'readonly', (s) => s.get(id))) ?? null; } catch { return null; } } export async function listEditedIds(): Promise> { try { const rows = await ask(EDITS, 'readonly', (s) => s.getAll()); return new Set(rows.map((e) => e.photoId)); } catch { return new Set(); } } export async function listEdits(): Promise { try { return await ask(EDITS, 'readonly', (s) => s.getAll()); } catch { return []; } } // --- restore --------------------------------------------------------------- // The catalogue as it travels: the rows without the two things that cannot be // written to a file — a handle, which only the visitor's picker can hand out, and // the tile itself, which is a blob and would be a second copy of the backup // folder. Everything else is here, `mtime` and `size` and `star` included, which // is what lets the walk that follows match every frame and read none of them. export type CatalogueRow = Omit; // The rows a set of ids stands for, gaps left as gaps — one transaction for the // lot, where `getPhoto` would be one per frame. async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTOS, 'readonly'); const store = tx.objectStore(PHOTOS); const out: (LibraryPhoto | undefined)[] = []; ids.forEach((id, i) => { const req = store.get(id); req.onsuccess = () => { out[i] = req.result as LibraryPhoto | undefined; }; }); tx.oncomplete = () => resolve(out); tx.onerror = () => reject(tx.error); }); } // Put a catalogue back. A frame the catalogue already holds and can open — one // with a handle — is left exactly as it is: the backup's copy of it would come // back without one, which is a working catalogue traded for a worse one. Every // other row goes in as it came out. `thumb` fetches the tile that was backed up // beside the row, and a tile that will not read leaves the row without one: the // grid paints a blank cell until the next scan reads the frame. export async function restoreCatalogue( photos: CatalogueRow[], edits: LibraryEdit[], thumb?: (id: string) => Promise ): Promise { const db = await openDb(); const size = 200; let put = 0; for (let at = 0; at < photos.length; at += size) { const slice = photos.slice(at, at + size); const had = await rowsAt(slice.map((r) => r.id)); const want = slice.filter((_, i) => !had[i]?.handle); if (!want.length) continue; const rows = await Promise.all( want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto) ); await new Promise((resolve, reject) => { const tx = db.transaction(PHOTOS, 'readwrite'); const store = tx.objectStore(PHOTOS); for (const row of rows) store.put(row); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); put += rows.length; } for (let at = 0; at < edits.length; at += size) { const slice = edits.slice(at, at + size); await new Promise((resolve, reject) => { const tx = db.transaction(EDITS, 'readwrite'); const store = tx.objectStore(EDITS); for (const row of slice) store.put(row); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } return put; }