// 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 { 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; 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; 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); }); } // 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 ------------------------------------------------------------ interface Bitmap { width: number; height: number; close: () => void; } function downscale(blob: Blob, draw: (bitmap: Bitmap, w: number, h: number) => Promise): Promise { return createImageBitmap(blob) .then(async (bitmap) => { try { const scale = Math.min(1, THUMB_MAX / Math.max(bitmap.width, bitmap.height)); return await draw(bitmap, Math.max(1, Math.round(bitmap.width * scale)), Math.max(1, Math.round(bitmap.height * scale))); } finally { bitmap.close(); } }) .catch(() => null); } // A canvas rather than OffscreenCanvas: this runs on the main thread anyway (the // scan is a background chore, not a render), and the element works in every // browser the studio supports. async function toThumbnail(bitmap: Bitmap, w: number, h: number): Promise { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(bitmap as unknown as CanvasImageSource, 0, 0, w, h); return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', THUMB_QUALITY)); } async function makeThumbnail(file: File, bytes: Uint8Array): Promise { if (!isRawName(file.name)) return downscale(file, toThumbnail); // A RAW is unpacked to the preview the camera wrote inside it — a seek and a // copy, where a develop is a full decode of every pixel. A file that carries // no preview gets no tile; the studio develops it the moment it is opened. const preview = await rawThumbnail(bytes); if (!preview) return null; return downscale(new Blob([preview as BlobPart], { type: 'image/jpeg' }), toThumbnail); } // --- scanning -------------------------------------------------------------- export interface ScanProgress { folder: string; total: number; done: number; added: 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. // // 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: read one frame at a time, whole, on the main thread — a RAW is read // as its bytes for LibRaw and released again. A folder of 5000 RAW files takes // minutes and makes the page lumpy while it runs. Move the walk into a worker // (LibRaw is happy in one) if a catalogue that large is ever real. 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 tree: Walk = { root: folder.handle, pending: [{ dir: folder.handle, rel: '' }], walked: new Set() }; const entries: WalkFile[] = []; // 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 found = new Set(); const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} }; const counts: Record = {}; // 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. const dirs: LibraryDir[] = []; let batch: LibraryPhoto[] = []; // One transaction per batch, a put per row: a store with `keyPath: 'id'` takes // a record, not an array of them. 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); }); }; 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 { const from = entries.length; names.length = 0; 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. progress.dirs = dirs.slice(); progress.total += entries.length - from; for (const { handle, rel } of entries.slice(from)) { if (shouldStop?.()) { stop = true; break; } progress.done++; count(rel); try { const file = await handle.getFile(); const id = photoId(folder.name, rel); const seen = known.get(id); if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) { onProgress?.(progress); continue; } const bytes = new Uint8Array(await file.arrayBuffer()); const [thumb, taken] = [await makeThumbnail(file, bytes), (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, addedAt: seen?.addedAt ?? Date.now(), }); progress.added++; if (batch.length >= BATCH) await flush(); } catch { // A frame that will not read is a frame the catalogue skips: one bad file // in a folder is not a failed folder. } onProgress?.(progress); } } while (!walked && !stop); await flush(); 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 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. export function scanSession(): ScanSession | null { return live; } // 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 { if (!live) return; live.stop = true; tell(); } export function jumpScan(rel: string | null): void { if (live) live.jump = 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, dirs: [], counts: {} }, stop: false, jump: null, }; live = session; tell(); return scanFolder( folder, (p) => { session.progress = p; tell(); }, () => session.stop, () => session.jump ).finally(() => { live = null; 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; } } // 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(); } }