web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library now takes a folder through Chromium's directory picker, keeps the handle in IndexedDB so the folder is there on the next visit, and walks it into a grid: one thumbnail per frame, the frame's own date, and the recipe it was last graded with. Nothing is uploaded and nothing is read twice — the RAW itself is opened only when a tile is clicked, at which point the studio develops it and the recipe comes back on top. The studio files every change back against the frame, debounced, so reopening a RAW is not doing the grade again. Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and a copy where a develop is a full decode of every pixel, and off createImageBitmap for anything else. A RAW with no preview inside it gets a placeholder tile rather than a minute of decoding per file. node scripts/library-check.mjs ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2 ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview) ok studio developed the frame from its handle ok the address was handed back — url=/app (no ?lib= left behind) ok the look was filed back against the frame — baseFilter=none, 19 knobs ok the tile says the frame is edited
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
// 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';
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
export interface LibraryPhoto {
|
||||
// `${folder}/${name}` — one frame appears once in the catalogue, under the
|
||||
// folder it was found in.
|
||||
id: string;
|
||||
folder: 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;
|
||||
}
|
||||
|
||||
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) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||||
photos.createIndex('folder', 'folder');
|
||||
photos.createIndex('taken', 'taken');
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
): Promise<ScanProgress> {
|
||||
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
|
||||
const entries: FileSystemFileHandle[] = [];
|
||||
for await (const entry of folder.handle.values()) {
|
||||
// `values()` is typed as the base handle, and only a file handle has the
|
||||
// getFile this walk needs.
|
||||
const file = entry as FileSystemFileHandle;
|
||||
if (entry.kind === 'file' && isSupportedPhoto(file.name)) entries.push(file);
|
||||
}
|
||||
const progress: ScanProgress = { folder: folder.name, total: entries.length, done: 0, added: 0 };
|
||||
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 store = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) store.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
};
|
||||
for (const handle of entries) {
|
||||
if (shouldStop?.()) break;
|
||||
progress.done++;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const id = photoId(folder.name, handle.name);
|
||||
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];
|
||||
batch.push({
|
||||
id,
|
||||
folder: folder.name,
|
||||
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);
|
||||
}
|
||||
await flush();
|
||||
return progress;
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,27 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
|
||||
return new Uint8Array(thumb.data);
|
||||
}
|
||||
|
||||
// The preview on its own, for the catalogue: a folder of RAW files has to show
|
||||
// a tile per frame, and unpack_thumb is a seek and a copy where the develop
|
||||
// above is a full decode of every pixel at full resolution. No preview inside
|
||||
// the file means no tile — the row still lists the frame by name, and the
|
||||
// studio develops it the moment it is opened.
|
||||
//
|
||||
// ponytail: still a whole LibRaw open per file, reading the file's bytes into
|
||||
// memory to do it. Fine for the scan's one-at-a-time walk; give the catalogue a
|
||||
// worker and a sync-access handle if a 10k-frame folder ever turns up.
|
||||
export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const raw = new LibRaw();
|
||||
try {
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
return await cameraPreview(raw);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
raw.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// MATCH_GRID x MATCH_GRID block colours of a frame, one byte per channel.
|
||||
function gridOf(image: any, n = MATCH_GRID): Uint8Array | null {
|
||||
const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n);
|
||||
|
||||
Reference in New Issue
Block a user