From 81637b85022dfadaab66b59ab748db1466dcf985 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 17:46:12 +0700 Subject: [PATCH] web: a catalogue of the folders on the visitor's own disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/frontend/scripts/library-check.mjs | 226 ++++++++++++++ docker/frontend/src/App.tsx | 82 ++++- docker/frontend/src/Library.tsx | 281 +++++++++++++++++ docker/frontend/src/engine/library.ts | 350 ++++++++++++++++++++++ docker/frontend/src/engine/rawDevelop.ts | 21 ++ docker/frontend/src/styles/app.css | 15 + docker/frontend/src/ui/TopBar.tsx | 3 + docker/frontend/tsconfig.json | 2 +- 8 files changed, 966 insertions(+), 14 deletions(-) create mode 100644 docker/frontend/scripts/library-check.mjs create mode 100644 docker/frontend/src/Library.tsx create mode 100644 docker/frontend/src/engine/library.ts diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs new file mode 100644 index 0000000..66a05c7 --- /dev/null +++ b/docker/frontend/scripts/library-check.mjs @@ -0,0 +1,226 @@ +// The catalogue, checked where it actually lives: in a browser. A directory +// handle, IndexedDB, a grid of tiles and a handover into the studio have no +// meaning outside one, so this is a Playwright run rather than a pure-node +// assert. +// +// Only Chromium can hand a page a folder, and only a person can answer the +// picker. The check stands in for both: it serves two real frames (one JPEG, one +// RAW) off a local server and answers `showDirectoryPicker` with a folder made of +// them. The stand-in handles carry their methods as *non-enumerable* properties, +// which is what lets them go through IndexedDB's structured clone at all (a +// clone copies enumerable own keys and drops the rest, and a function anywhere +// else is a DataCloneError); the methods are put back where the catalogue reads +// them, because a cloned handle is a plain object with no `getFile`. +// +// npm run build && node scripts/library-check.mjs +// PLAYWRIGHT_CORE= node scripts/library-check.mjs +// +// Playwright is not a dependency of this package (a browser run needs it, a +// build does not), so a run without it says SKIP and exits 0. +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..'); +const APP_PORT = Number(process.env.APP_PORT ?? 4185); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319); +const JPG = 'P1010256.JPG'; +const RAW = 'P1010256.RW2'; + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); + process.exit(0); +} + +// --- the two frames, off a local server (the page fetches them by CORS) ------ +const sampleServer = createServer(async (req, res) => { + const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); + if (name !== JPG && name !== RAW) { + res.writeHead(404).end(); + return; + } + const bytes = await readFile(path.join(SAMPLES, name)); + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'Access-Control-Allow-Origin': '*', + }); + res.end(bytes); +}); +await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); + +// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which +// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. +const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${APP_PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + sampleServer.close(); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); + process.exit(1); +} + +// --- the browser ------------------------------------------------------------ +const browser = await playwright.chromium.launch({ + // Playwright's own Chromium when it has one; CHROME points at any other. + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const context = await browser.newContext(); + +// The folder the picker would have returned, plus the revival of its handles on +// the way back out of IndexedDB. +await context.addInitScript( + ({ origin, names, folder }) => { + const bytes = new Map(); + const load = async (name) => { + if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); + return bytes.get(name); + }; + const fileHandle = (name) => { + const handle = { kind: 'file', name, __fake: true }; + Object.defineProperty(handle, 'getFile', { + value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }), + }); + return handle; + }; + const dirHandle = () => { + const handle = { kind: 'directory', name: folder }; + Object.defineProperties(handle, { + values: { value: () => names.map(fileHandle)[Symbol.iterator]() }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + }); + return handle; + }; + window.showDirectoryPicker = async () => dirHandle(); + + const revive = (row) => { + if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { + row.handle = row.handle.kind === 'directory' ? dirHandle() : fileHandle(row.handle.name); + } + return row; + }; + for (const method of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[method]; + IDBObjectStore.prototype[method] = function (...args) { + const request = original.apply(this, args); + const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(request, 'result', { + configurable: true, + get() { + const value = descriptor.get.call(this); + return Array.isArray(value) ? value.map(revive) : revive(value); + }, + }); + return request; + }; + } + }, + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG, RAW], folder: 'CheckRoll' } +); + +const page = await context.newPage(); +const catalogue = () => + page.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const all = (store) => + new Promise((resolve, reject) => { + const req = db.transaction(store).objectStore(store).getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const [photos, edits] = await Promise.all([all('photos'), all('edits')]); + const thumbs = []; + for (const photo of photos) { + const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null; + thumbs.push({ + id: photo.id, + bytes: photo.thumb?.size ?? 0, + jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false, + }); + } + return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) }; + }); + +// --- the run ---------------------------------------------------------------- +await page.goto(`${base}/library`); +await page.click('[data-key="lib-add"]'); +const tiles = '[data-key^="lib-photo-"]'; +await page.waitForSelector(tiles, { timeout: 120_000 }); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 }); +step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`); + +const indexed = await catalogue(); +for (const name of [JPG, RAW]) { + const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`)); + step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`); +} + +// One tap on a tile hands the frame to the studio. The RAW is the harder half — +// it has to be read back off the (stand-in) handle and developed — so it is the +// one that gets opened. +const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id; +await page.click(`[data-key="lib-open-${rawId}"]`); +await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 }); +await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 }); +step('studio developed the frame from its handle', true, 'stage no longer on the drop zone'); + +// The handover is an address, not a state: the studio puts the frame into its +// own store and drops the query, so a reload reopens it like any other photo. +step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`); + +const editsIn = () => + page.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return await new Promise((resolve) => { + const req = db.transaction('edits').objectStore('edits').getAll(); + req.onsuccess = () => resolve(req.result); + }); + }); +// The recipe is written 600ms after the last change (a slider drag fires it per +// frame), so the row arrives a beat after the frame does. +let filed = []; +for (let i = 0; i < 40 && !filed.length; i++) { + filed = await editsIn(); + if (!filed.length) await new Promise((r) => setTimeout(r, 250)); +} +step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed)); + +await page.goto(`${base}/library`); +await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`); +const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText(); +step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · ')); + +await browser.close(); +sampleServer.close(); +app.kill('SIGTERM'); +console.log(failures ? `\n${failures} failed` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 2a6dd49..c809cf3 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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([]); // The saved photo standing on the stage, if any — its own consent chip shows. const [openPhotoId, setOpenPhotoId] = useState(null); + // The catalogue frame on the stage, if any (`/library` hands one over as + // `/app?lib=`). 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(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([]); @@ -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=`) 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=`: 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) => { diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx new file mode 100644 index 0000000..06e602d --- /dev/null +++ b/docker/frontend/src/Library.tsx @@ -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=`). +// +// 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([]); + const [ready, setReady] = useState>(new Set()); + const [photos, setPhotos] = useState([]); + const [edited, setEdited] = useState>(new Set()); + const [pick, setPick] = useState(null); + const [progress, setProgress] = useState(null); + const [note, setNote] = useState(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(); + 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 ( +
+
+ + + RecipesCam + + {t('lib.title')} + + + {t('lib.count', { n: photos.length })} + + + {t('nav.studio')} + +
+ + {note ?

{note}

: null} + + {canBrowseFolders() ? ( +
+
+

{t('lib.hint')}

+
+ + {progress ? ( + + ) : null} + + {progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''} + +
+ + {folders.length === 0 ?

{t('lib.noFolders')}

: null} + +
+ {folders.length > 1 ? ( + + ) : null} + {folders.map((folder) => ( + + + {ready.has(folder.name) ? ( + <> + + + + ) : ( + + )} + + ))} +
+ + {shown.length === 0 && !busy ?

{t('lib.empty')}

: null} + +
+ {shown.map((photo) => ( +
+ {urls[photo.id] ? ( + void open(photo)} title={t('lib.open')} /> + ) : ( +
void open(photo)} title={t('lib.open')}> + RAW +
+ )} +
+ {photo.name} + + {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} + {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} + +
+ +
+ ))} +
+
+
+ ) : ( +

{t('lib.unsupported')}

+ )} +
+ ); +} + +// 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 { + const [urls, setUrls] = useState>({}); + useEffect(() => { + const next: Record = {}; + 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; +} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts new file mode 100644 index 0000000..a5d2b4f --- /dev/null +++ b/docker/frontend/src/engine/library.ts @@ -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 | null = null; + +function openDb(): Promise { + dbPromise ??= new Promise((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(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; +} + +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; +} + +// 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 { + 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((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 { + 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(); + } +} diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index d8729a3..288a22c 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -215,6 +215,27 @@ async function cameraPreview(raw: LibRaw): Promise { 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 { + 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); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index a1f954d..0011692 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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; } diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 844431a..78b4b16 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -98,6 +98,9 @@ export function TopBar({ + {/* 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. */} + {t('nav.library')}