1cb2618fd7
Two holes left by the last change. A frame is opened with `window.location.href` rather than a link, so the studio was still handed a fresh page and the scan with it — every route out of the catalogue now goes through `go()`, which pushes the address instead of reloading while a scan is in flight. And a row counted what was filed away rather than what had been read, so it sat at zero for the whole of a first scan: the catalogue's frames only reach IndexedDB in one batch at the end. `ScanProgress.counts` carries the frames the scan has reached, per row, off the scan's own bookkeeping, and the column reads it while the scan runs — the row under the reader's eye moves as the roll is read, and the toolbar's line stays the whole picture. A re-read counts from the start, which is what it is doing. The regression check grows the two: a frame opened mid-scan from the catalogue (of a roll it already holds) has to stay in the page, and the row has to count.
524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
// 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<IDBDatabase> | null = null;
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
dbPromise ??= new Promise<IDBDatabase>((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<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
|
|
return openDb().then(
|
|
(db) =>
|
|
new Promise<T>((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<LibraryFolder[]> {
|
|
try {
|
|
const rows = await ask<LibraryFolder[]>(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<LibraryFolder | null> {
|
|
if (!canBrowseFolders()) throw new Error('no-folder-picker');
|
|
const handle = await (
|
|
window as unknown as { showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle> }
|
|
).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<void> {
|
|
await ask(FOLDERS, 'readwrite', (s) => s.put({ ...folder, label }));
|
|
}
|
|
|
|
export async function removeFolder(name: string): Promise<void> {
|
|
const ids = (await listPhotos(name)).map((p) => p.id);
|
|
const db = await openDb();
|
|
await new Promise<void>((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<boolean> {
|
|
// `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<PermissionState>;
|
|
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
|
};
|
|
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<Blob | null>): Promise<Blob | null> {
|
|
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<Blob | null> {
|
|
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<Blob | null>((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', THUMB_QUALITY));
|
|
}
|
|
|
|
async function makeThumbnail(file: File, bytes: Uint8Array): Promise<Blob | null> {
|
|
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<string, number>;
|
|
// 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<ScanProgress> {
|
|
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<string>();
|
|
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} };
|
|
const counts: Record<string, number> = {};
|
|
// 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<void>((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<ScanProgress> {
|
|
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<LibraryPhoto[]> {
|
|
try {
|
|
const rows = await ask<LibraryPhoto[]>(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<LibraryPhoto | null> {
|
|
try {
|
|
return (await ask<LibraryPhoto | undefined>(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<File> {
|
|
return photo.handle.getFile();
|
|
}
|
|
|
|
// --- edits -----------------------------------------------------------------
|
|
|
|
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
|
|
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<LibraryEdit | null> {
|
|
try {
|
|
return (await ask<LibraryEdit | undefined>(EDITS, 'readonly', (s) => s.get(id))) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function listEditedIds(): Promise<Set<string>> {
|
|
try {
|
|
const rows = await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
|
|
return new Set(rows.map((e) => e.photoId));
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|