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:
2026-09-28 17:46:12 +07:00
parent 0f2e109aa2
commit 81637b8502
8 changed files with 966 additions and 14 deletions
+69 -13
View File
@@ -18,6 +18,8 @@ import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
import { developRaw, isRawName } from './engine/rawDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, readPhotoFile, saveEdit } from './engine/library';
import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
@@ -572,6 +574,10 @@ export function Workspace() {
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
// The saved photo standing on the stage, if any — its own consent chip shows.
const [openPhotoId, setOpenPhotoId] = useState<number | null>(null);
// The catalogue frame on the stage, if any (`/library` hands one over as
// `/app?lib=<id>`). It is what the recipe is filed back against, so the frame
// reopens in the studio at the look it was left at.
const [libPhotoId, setLibPhotoId] = useState<string | null>(null);
// Recipes made in this session, guest ones included. A guest's copy lives
// here and nowhere else, so reloading the page is what deletes it.
const [created, setCreated] = useState<Recipe[]>([]);
@@ -638,18 +644,23 @@ export function Workspace() {
// ...and the photo half of the session, back out of IndexedDB. When that is
// empty but a RAW is still parked in OPFS, the RAW is what "the last photo"
// means: develop it again rather than open on nothing.
loadPhoto()
.then(async (photo) => {
if (!alive) return;
if (photo) {
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
}
const parked = await readRaw();
if (!alive || !parked) return;
const jpeg = await developRaw(parked.bytes);
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
})
.catch(() => undefined);
// A frame handed over by the catalogue (`/app?lib=<id>`) wins over both: the
// visitor just clicked it, and it is newer than anything the session holds.
const wanted = new URLSearchParams(window.location.search).get('lib');
if (wanted) openLibraryPhoto(wanted);
else
loadPhoto()
.then(async (photo) => {
if (!alive) return;
if (photo) {
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
}
const parked = await readRaw();
if (!alive || !parked) return;
const jpeg = await developRaw(parked.bytes);
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
})
.catch(() => undefined);
return () => {
alive = false;
};
@@ -936,6 +947,7 @@ export function Workspace() {
setError(null);
setBusy(t('auth.busy'));
setOpenPhotoId(null);
setLibPhotoId(null);
try {
await adoptPhoto(bytes, name, blob);
// A shot survives a reload like any other photo of the session's own.
@@ -955,8 +967,10 @@ export function Workspace() {
setBusy(t('auth.busy'));
// A file from the disk is nobody's saved frame: SAVE PHOTO must add one,
// not overwrite whichever of the folder's own was open before. Opening one
// of those sets the id back, after this call.
// of those sets the id back, after this call. Same for a catalogue frame —
// the caller that hands one over names it again on the way back.
setOpenPhotoId(null);
setLibPhotoId(null);
try {
const bytes = await fileToBytes(file);
if (isRawName(file.name)) {
@@ -1635,6 +1649,10 @@ export function Workspace() {
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
// The moment the app has done the visitor's work is the moment it has earned
// a place on the machine: the install offer waits for this instead of
// opening on the way in (which nobody reads).
offerInstall();
return true;
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
@@ -1866,6 +1884,44 @@ export function Workspace() {
[loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t]
);
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
// come off the disk through the handle the catalogue kept, then the recipe that
// frame was last left at goes on top. A frame first opened has no recipe yet
// and arrives on the neutral stock, like any other file.
const openLibraryPhoto = useCallback(
async (id: string) => {
setBusy(t('auth.busy'));
setError(null);
// The address is a handover, not a state: the frame lives in the session
// from here on, so a reload reopens it like any other photo on the stage.
window.history.replaceState(null, '', window.location.pathname);
clearPreview();
setHistPick(null);
try {
const row = await getPhoto(id);
if (!row) throw new Error('not in the catalogue');
await loadFile(await readPhotoFile(row));
const saved = await loadEdit(id);
if (saved) applyRecipe(saved.recipe);
setLibPhotoId(id);
} catch {
setError(t('err.decode'));
} finally {
setBusy(null);
}
},
[loadFile, applyRecipe, clearPreview, t]
);
// What the catalogue remembers about a frame is the look it was left at, filed
// back on every change — debounced, because a slider drag fires this per frame
// of the drag and IndexedDB is on the wrong side of that.
useEffect(() => {
if (!libPhotoId || !source) return;
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
return () => clearTimeout(timer);
}, [libPhotoId, source, recipe]);
// The saved frame's own switch. The reel reads `consent`, so the tick answers
// the click at once; only the store's refusal (a failed call) takes it back.
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
+281
View File
@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from './i18n/I18nProvider';
import {
canBrowseFolders,
ensurePermission,
getPhoto,
listEditedIds,
listFolders,
listPhotos,
pickFolder,
readPhotoFile,
removeFolder,
scanFolder,
type LibraryFolder,
type LibraryPhoto,
type ScanProgress,
} from './engine/library';
// The catalogue screen: folders from the visitor's own disk, a tile per frame,
// and a recipe remembered per frame. Nothing is uploaded — the page never holds
// more than the thumbnails, and the RAW itself is read only when a frame is
// opened in the studio (`/app?lib=<id>`).
//
// ponytail: no rating, no flags, no collections, no search. IndexedDB already
// holds the fields a few of those would need (folder, taken); add them once the
// catalogue is big enough that scrolling it stops working.
export function Library() {
const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>([]);
const [ready, setReady] = useState<Set<string>>(new Set());
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
const [edited, setEdited] = useState<Set<string>>(new Set());
const [pick, setPick] = useState<string | null>(null);
const [progress, setProgress] = useState<ScanProgress | null>(null);
const [note, setNote] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const stopRef = useRef(false);
const reload = useCallback(async () => {
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
setPhotos(rows);
setEdited(edits);
}, []);
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
useEffect(() => {
let alive = true;
void (async () => {
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
const ok = new Set<string>();
for (const folder of rows) {
if (await ensurePermission(folder.handle)) ok.add(folder.name);
}
if (!alive) return;
setReady(ok);
await reload();
})();
return () => {
alive = false;
};
}, [reload]);
const scan = useCallback(
async (folder: LibraryFolder) => {
stopRef.current = false;
setBusy(true);
setNote(null);
try {
const result = await scanFolder(folder, setProgress, () => stopRef.current);
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
await reload();
} catch {
setNote(t('lib.failed'));
} finally {
setProgress(null);
setBusy(false);
}
},
[reload, t]
);
const add = useCallback(async () => {
setNote(null);
try {
const folder = await pickFolder();
if (!folder) return;
setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name)));
setReady((prev) => new Set(prev).add(folder.name));
setPick(folder.name);
await scan(folder);
} catch (err) {
// Closing the picker is a "not now", not a failure worth a line of text.
if (err instanceof DOMException && err.name === 'AbortError') return;
setNote(t('lib.failed'));
}
}, [scan, t]);
const reconnect = useCallback(
async (folder: LibraryFolder) => {
if (await ensurePermission(folder.handle)) {
setReady((prev) => new Set(prev).add(folder.name));
await scan(folder);
} else {
setNote(t('lib.failed'));
}
},
[scan, t]
);
const drop = useCallback(
async (folder: LibraryFolder) => {
await removeFolder(folder.name);
setFolders((prev) => prev.filter((f) => f.name !== folder.name));
if (pick === folder.name) setPick(null);
await reload();
},
[pick, reload]
);
// The frame goes to the studio through the address bar: the studio owns the
// load (develop, EXIF, session) and this page owns no part of it.
const open = useCallback(async (photo: LibraryPhoto) => {
const row = await getPhoto(photo.id);
if (!row) return;
// Read once here so a moved or deleted file answers with a note instead of
// a studio that opens on nothing.
try {
await readPhotoFile(row);
} catch {
setNote(t('lib.missing', { name: row.name }));
return;
}
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
}, [t]);
const shown = useMemo(() => (pick ? photos.filter((p) => p.folder === pick) : photos), [photos, pick]);
const urls = useThumbUrls(shown);
return (
<div className="adm">
<header className="adm-bar">
<a className="adm-brand" href="/">
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
<span>Recipes<em>Cam</em></span>
</a>
<b className="adm-title">{t('lib.title')}</b>
<span className="adm-spacer" />
<span className="hint" data-key="lib-count">
{t('lib.count', { n: photos.length })}
</span>
<a className="btn" data-key="lib-studio" href="/app">
{t('nav.studio')}
</a>
</header>
{note ? <p className="hint adm-note">{note}</p> : null}
{canBrowseFolders() ? (
<div className="adm-frame" data-key="lib-frame">
<div className="adm-pane">
<p className="hint adm-sub">{t('lib.hint')}</p>
<div className="chip-row" style={{ marginBottom: 12 }}>
<button type="button" className="btn primary" data-key="lib-add" disabled={busy} onClick={() => void add()}>
{t('lib.add')}
</button>
{progress ? (
<button type="button" className="btn" data-key="lib-stop" onClick={() => (stopRef.current = true)}>
{t('lib.stop')}
</button>
) : null}
<span className="hint" data-key="lib-progress">
{progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''}
</span>
</div>
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
<div className="chip-row" style={{ marginBottom: 12 }}>
{folders.length > 1 ? (
<button
type="button"
className={pick === null ? 'chip on' : 'chip'}
data-key="lib-folder-all"
onClick={() => setPick(null)}
>
{t('lib.all')}
</button>
) : null}
{folders.map((folder) => (
<span key={folder.name} className="chip-row">
<button
type="button"
className={pick === folder.name ? 'chip on' : 'chip'}
data-key={`lib-folder-${folder.name}`}
onClick={() => setPick(folder.name)}
>
{folder.name}
</button>
{ready.has(folder.name) ? (
<>
<button
type="button"
className="btn ghost"
data-key={`lib-rescan-${folder.name}`}
disabled={busy}
onClick={() => void scan(folder)}
>
{t('lib.rescan')}
</button>
<button type="button" className="btn ghost" data-key={`lib-drop-${folder.name}`} onClick={() => void drop(folder)}>
{t('lib.remove')}
</button>
</>
) : (
<button
type="button"
className="btn"
data-key={`lib-reconnect-${folder.name}`}
disabled={busy}
onClick={() => void reconnect(folder)}
>
{t('lib.reconnect')}
</button>
)}
</span>
))}
</div>
{shown.length === 0 && !busy ? <p className="hint adm-note">{t('lib.empty')}</p> : null}
<div className="adm-grid">
{shown.map((photo) => (
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
{urls[photo.id] ? (
<img src={urls[photo.id]} alt="" loading="lazy" onClick={() => void open(photo)} title={t('lib.open')} />
) : (
<div className="lib-noimg" onClick={() => void open(photo)} title={t('lib.open')}>
RAW
</div>
)}
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
{t('lib.open')}
</button>
</article>
))}
</div>
</div>
</div>
) : (
<p className="adm-note hint">{t('lib.unsupported')}</p>
)}
</div>
);
}
// One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and `shown` is the only list
// the grid paints.
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
useEffect(() => {
const next: Record<string, string> = {};
for (const photo of photos) if (photo.thumb) next[photo.id] = URL.createObjectURL(photo.thumb);
setUrls(next);
return () => {
for (const url of Object.values(next)) URL.revokeObjectURL(url);
};
}, [photos]);
return urls;
}
+350
View File
@@ -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();
}
}
+21
View File
@@ -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);
+15
View File
@@ -973,6 +973,21 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
padding: 10px;
}
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
/* A frame whose RAW carries no preview of its own: the tile stands in for the
picture and opens the file just the same. */
.lib-noimg {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
background: var(--bg-sunken);
color: var(--text-dim);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.14em;
cursor: pointer;
}
.adm-meta { display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; color: var(--text-dim); min-width: 0; }
.adm-meta b { color: var(--text); font-family: var(--mono); }
.adm-meta span { overflow-wrap: anywhere; }
+3
View File
@@ -98,6 +98,9 @@ export function TopBar({
</button>
<span className="spacer" />
{/* The catalogue is a page of its own, open to guests as much as to
members — it reads the visitor's own folders and never the server. */}
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
<button
type="button"