fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading

This commit is contained in:
2026-10-05 06:34:37 +07:00
parent 554e121e41
commit 91722861f2
12 changed files with 625 additions and 160 deletions
+18
View File
@@ -936,6 +936,24 @@ export function Workspace() {
void forgetPhoto();
}, [clearPreview]);
// Clean up Object URLs when App unmounts (e.g. SPA navigation to Library)
useEffect(() => {
return () => {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setCompareUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setSource((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return null;
});
};
}, []);
// What the phone app gets from the OS geocoder, the browser has no twin of,
// so the name behind a coordinate comes from the API. Fire and forget: the
// stamp is already readable without it, and a name that arrives late (or not
+10 -1
View File
@@ -667,7 +667,16 @@ export function Landing() {
aria-label={t('nav.language')}
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
>
{lang.toUpperCase()}
{/* On a phone the button wears a globe; on a wide screen it wears
the code, where there is room to say which language is up.
Both live in the DOM — neither is the button's name (`aria-label`
above is), so which one is drawn is a stylesheet's business. */}
<svg className="lp-tool-globe" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<ellipse cx="12" cy="12" rx="4.2" ry="9" />
<path d="M3.6 9h16.8M3.6 15h16.8" />
</svg>
<span className="lp-tool-code">{lang.toUpperCase()}</span>
</button>
</span>
{/* Sign in is the default: the dialog opens here, on the page the
+204 -55
View File
@@ -4,11 +4,14 @@ import { go } from './nav';
import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, rawThumbnail } from './engine/rawDevelop';
import {
cachedPhotos,
canBrowseFolders,
clearLibraryCache,
ensurePermission,
getPhoto,
getPhotoThumbs,
HEAD_BYTES,
jumpScan,
listEditedIds,
@@ -71,6 +74,7 @@ const FRAME_KEY = 'recipescam.library.frame';
const COLUMN_KEY = 'recipescam.library.column';
const OPEN_KEY = 'recipescam.library.open';
const DEEP_KEY = 'recipescam.library.deep';
const VIEW_KEY = 'recipescam.library.view';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
@@ -112,8 +116,17 @@ interface Shot {
// back on a timer while a scan runs, which hands this screen a new frame object
// every few hundred milliseconds, so what keeps the file from being read again is
// the read in flight rather than the object under it.
const SHOTS_MAX = 50;
const shots = new Map<string, Promise<Shot | null>>();
function keepShot(id: string, promise: Promise<Shot | null>): void {
if (shots.size >= SHOTS_MAX) {
const oldestKey = shots.keys().next().value;
if (oldestKey) shots.delete(oldestKey);
}
shots.set(id, promise);
}
// How often a running scan is allowed to have its catalogue read back, and how
// often the tab may walk the folder again just for being raised. Both are the
// same trade — the reading is watched, not driven — and both are seconds for the
@@ -162,15 +175,15 @@ export function Library() {
// tree the way the last one was left.
const [expanded, setExpanded] = useState<Set<string>>(() => {
const saved = recall(OPEN_KEY);
// Nothing remembered means this is the first visit under that rule, and the
// node the last visit left would then open behind a shut row: its own rows
// are opened for it. A visit that folded everything remembers the empty set,
// which is not this, and is drawn as it was left.
const nodeParts = (recall(NODE_KEY) ?? '').split('/');
const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/'));
if (saved === null) {
const parts = (recall(NODE_KEY) ?? '').split('/');
return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
return new Set(nodeParents);
}
return new Set(saved.split(',').filter(Boolean));
const set = new Set(saved.split(',').filter(Boolean));
for (const parent of nodeParents) set.add(parent);
return set;
});
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
@@ -183,7 +196,7 @@ export function Library() {
// first one the node happens to hold. A frame that is gone falls through to
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
// these hours — and in the order the reader picked. A way of looking, not part
@@ -472,25 +485,37 @@ export function Library() {
};
}, [reload]);
// 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.
// 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 () => {
// 1. Trigger catalog reload immediately in parallel
const reloadPromise = reload();
// 2. Fetch list of directory folders
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
// The remembered folder only stands while the folder it belongs to does: a
// folder that was removed is not a folder to open on.
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
const ok = new Set<string>();
for (const folder of rows) {
if (await ensurePermission(folder.handle)) ok.add(folder.name);
}
// 3. Query directory permissions in parallel
const permResults = await Promise.all(
rows.map(async (folder) => ({
name: folder.name,
ok: await ensurePermission(folder.handle),
}))
);
if (!alive) return;
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
setReady(ok);
await reload();
// 4. Await photos reload if still running
await reloadPromise;
if (!alive) return;
setLoaded(true);
})();
@@ -517,6 +542,17 @@ export function Library() {
useEffect(() => {
keep(DEEP_KEY, String(deep));
}, [deep]);
useEffect(() => {
keep(VIEW_KEY, view);
}, [view]);
// Clear global memory caches when leaving Library screen
useEffect(() => {
return () => {
shots.clear();
clearLibraryCache();
};
}, []);
// The menu closes on the next click anywhere but its own items, and on Escape.
// Escape is the whole reason this listens on the document instead of sitting
@@ -710,17 +746,11 @@ export function Library() {
// numbers drawn over the column would count a roll down to a branch of itself.
// The column falls back on the catalogue until the reading is through.
const read = live && !live.progress.from ? live.progress.counts : null;
const nodes = useMemo<Node[]>(() => {
// Memoize photo count map derived from photos and dirs array separately so that
// live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
const baseTreeInfo = useMemo(() => {
const hits = new Map<string, number>();
// The reading's own count is a floor, never a ceiling: a row says at least
// what the catalogue already holds of it. A roll being read again says its
// own size while the reading is still walking up to it — where the reading's
// number alone would have the head of the tree count 29980 while the line
// above it counts a hundred thousand, one question with two answers.
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const foldersUnder = new Map<string, Set<string>>();
// One path per folder between the picked one and `rel`, so a row is drawn for
// every folder on the way to a frame as well as for the frame's own.
const add = (folder: string, rel: string, counted: boolean) => {
const set = foldersUnder.get(folder) ?? new Set<string>();
foldersUnder.set(folder, set);
@@ -735,9 +765,13 @@ export function Library() {
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
if (photo.dir) add(photo.folder, photo.dir, true);
}
// A folder the walk found is a row before it holds a frame: the names arrive
// a pass ahead of the thumbnails.
for (const dir of dirs) add(dir.folder, dir.rel, false);
return { hits, foldersUnder };
}, [photos, dirs]);
const nodes = useMemo<Node[]>(() => {
const { hits, foldersUnder } = baseTreeInfo;
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
@@ -754,7 +788,7 @@ export function Library() {
}
}
return out;
}, [dirs, folders, photos, read]);
}, [baseTreeInfo, folders, read]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
@@ -840,6 +874,53 @@ export function Library() {
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
const scrolledNodeRef = useRef<string | null>(null);
useEffect(() => {
if (!node || !loaded) return;
if (scrolledNodeRef.current === node) return;
const scrollToNode = () => {
const treeEl = treeRef.current;
if (!treeEl) return;
const activeBtn = treeEl.querySelector<HTMLElement>('.adm-album.on');
if (activeBtn) {
activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
scrolledNodeRef.current = node;
}
};
scrollToNode();
const timer = setTimeout(scrollToNode, 120);
return () => clearTimeout(timer);
}, [node, loaded, rows]);
// Auto-scroll timeline filmstrip to center the active selected thumbnail
const scrolledFrameRef = useRef<string | null>(null);
useEffect(() => {
if (view !== 'preview' || !frame || !shown.length) return;
const idx = shown.findIndex((p) => p.id === frame.id);
if (idx < 0) return;
const scrollStripToFrame = () => {
const el = strip.ref.current;
if (!el) return;
const width = el.clientWidth || 800;
const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2);
el.scrollLeft = targetLeft;
};
const key = `${node}:${frame.id}`;
if (scrolledFrameRef.current !== key) {
scrolledFrameRef.current = key;
scrollStripToFrame();
requestAnimationFrame(scrollStripToFrame);
}
}, [view, frame?.id, shown, node, strip.ref]);
// A frame's score: click a star to give it, click the star it already has to
// take the score back. The catalogue holds it, so it is the same score the next
// visit reads — and the list on screen is told, rather than read back whole for
@@ -886,7 +967,7 @@ export function Library() {
return null;
}
})();
shots.set(id, read);
keepShot(id, read);
}
void read.then((shot) => {
if (live) setShot(shot);
@@ -902,6 +983,59 @@ export function Library() {
setZoom(1);
}, [frame?.id]);
const [stageUrl, setStageUrl] = useState<string | null>(null);
const stageUrlRef = useRef<string | null>(null);
const revokeStageUrl = () => {
if (stageUrlRef.current) {
URL.revokeObjectURL(stageUrlRef.current);
stageUrlRef.current = null;
}
};
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
useEffect(() => {
revokeStageUrl();
setStageUrl(null);
if (!frame) return;
let live = true;
void (async () => {
try {
const file = await readPhotoFile(frame);
if (!live) return;
let url: string | null = null;
if (isRawName(file.name)) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!live) return;
const previewBytes = await rawThumbnail(bytes);
if (previewBytes && live) {
const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
url = URL.createObjectURL(blob);
}
} else {
url = URL.createObjectURL(file);
}
if (live && url) {
revokeStageUrl();
stageUrlRef.current = url;
setStageUrl(url);
} else if (url) {
URL.revokeObjectURL(url);
}
} catch {
// Fall back to thumb tile URL if file reading fails
}
})();
return () => {
live = false;
revokeStageUrl();
};
}, [frame?.id]);
// A wheel tick over the frame is the zoom — a negative is the one thing on this
// screen worth filling the window with — and the point under the pointer is the
// point that stays put, which is how a reader aims at what to magnify. The
@@ -1100,6 +1234,7 @@ export function Library() {
column is the folder control, so one more folder is added
where folders are. */}
<aside
ref={treeRef}
className="adm-album-list"
data-key="lib-tree"
title={t('lib.colHint')}
@@ -1348,12 +1483,12 @@ export function Library() {
</>
) : frame ? (
<>
{urls[frame.id] ? (
{stageUrl || urls[frame.id] ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
ref={frameRef}
src={urls[frame.id]}
src={stageUrl || urls[frame.id]!}
alt=""
onClick={() => void open(frame)}
title={t('lib.open')}
@@ -1619,40 +1754,54 @@ export function Library() {
// one on the stage, whose tile may be nowhere near the strip's window.
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
// The URL a row was given, kept for as long as the row is on screen. A scan lands
// in batches, and each batch hands the screen the whole list again — so a URL
// made afresh per list is a picture the browser decodes afresh per batch:
// thousands of bitmaps over a roll, for tiles that have not moved. A row that
// leaves the list takes its URL with it.
const made = useRef(new Map<string, string>());
const made = useRef(new Map<string, { url: string; blob: Blob }>());
useEffect(() => {
const list = also && !photos.includes(also) ? [...photos, also] : photos;
const next: Record<string, string> = {};
for (const photo of list) {
if (!photo.thumb) continue;
const had = made.current.get(photo.id);
if (had) next[photo.id] = had;
else {
const url = URL.createObjectURL(photo.thumb);
made.current.set(photo.id, url);
next[photo.id] = url;
let alive = true;
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
if (!list.length) {
setUrls({});
return;
}
const ids = list.map((p) => p.id);
void getPhotoThumbs(ids).then((thumbMap) => {
if (!alive) return;
const next: Record<string, string> = {};
for (const photo of list) {
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
if (!blob) continue;
const had = made.current.get(photo.id);
if (had && had.blob === blob) {
next[photo.id] = had.url;
} else {
if (had) URL.revokeObjectURL(had.url);
const url = URL.createObjectURL(blob);
made.current.set(photo.id, { url, blob });
next[photo.id] = url;
}
}
}
for (const [id, url] of made.current) {
if (id in next) continue;
URL.revokeObjectURL(url);
made.current.delete(id);
}
setUrls(next);
for (const [id, item] of made.current) {
if (id in next) continue;
URL.revokeObjectURL(item.url);
made.current.delete(id);
}
setUrls(next);
});
return () => {
alive = false;
};
}, [photos, also]);
// The screen going away takes every URL with it.
useEffect(
() => () => {
for (const url of made.current.values()) URL.revokeObjectURL(url);
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
made.current.clear();
},
[]
);
return urls;
}
+180 -57
View File
@@ -20,7 +20,9 @@ import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library';
const FOLDERS = 'folders';
const PHOTOS = 'photos';
const THUMBS = 'thumbs';
const EDITS = 'edits';
const DB_VERSION = 2;
// The tile the catalogue keeps is the same tile the grid cell and the strip both
// paint, and there is one of them per frame of a roll that reaches six figures.
@@ -30,8 +32,8 @@ const EDITS = 'edits';
// lower quality put the same picture in the same cell: the tile is now ~12KB, and
// the roll a quarter of the disk it was. The quality is the one Lightroom keeps
// its own grid previews at, and at this size the eye cannot tell it from 0.75.
const THUMB_MAX = 256;
const THUMB_QUALITY = 0.6;
const THUMB_MAX = 640;
const THUMB_QUALITY = 0.8;
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
// than 3000 of them.
const BATCH = 50;
@@ -138,16 +140,33 @@ export function canBrowseFolders(): boolean {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
}
const THUMB_CACHE_MAX = 500;
const thumbCache = new Map<string, Blob>();
export function cacheThumb(id: string, blob: Blob): void {
if (thumbCache.has(id)) thumbCache.delete(id);
else if (thumbCache.size >= THUMB_CACHE_MAX) {
const first = thumbCache.keys().next().value;
if (first) thumbCache.delete(first);
}
thumbCache.set(id, blob);
}
export function getCachedThumb(id: string): Blob | null {
const blob = thumbCache.get(id);
if (blob) {
thumbCache.delete(id);
thumbCache.set(id, blob);
return blob;
}
return null;
}
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);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
@@ -156,6 +175,9 @@ function openDb(): Promise<IDBDatabase> {
photos.createIndex('folder', 'folder');
photos.createIndex('taken', 'taken');
}
if (!db.objectStoreNames.contains(THUMBS)) {
db.createObjectStore(THUMBS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
};
req.onsuccess = () => resolve(req.result);
@@ -213,12 +235,18 @@ 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');
const hasThumbs = db.objectStoreNames.contains(THUMBS);
const stores = hasThumbs ? [FOLDERS, PHOTOS, THUMBS, EDITS] : [FOLDERS, PHOTOS, EDITS];
const tx = db.transaction(stores, 'readwrite');
tx.objectStore(FOLDERS).delete(name);
const photos = tx.objectStore(PHOTOS);
const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
const edits = tx.objectStore(EDITS);
for (const id of ids) {
photos.delete(id);
tx.objectStore(EDITS).delete(id);
thumbs?.delete(id);
edits.delete(id);
thumbCache.delete(id);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
@@ -239,12 +267,13 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
// against, and the two values are the whole type.
type PermissionMode = 'read' | 'readwrite';
const mode: PermissionMode = write ? 'readwrite' : 'read';
if (!handle) return false;
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
};
try {
if (!askable.queryPermission) return false;
if (!askable || !askable.queryPermission) return false;
if ((await askable.queryPermission({ mode })) === 'granted') return true;
return (await askable.requestPermission?.({ mode })) === 'granted';
} catch {
@@ -322,7 +351,10 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null)
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return null;
ctx.drawImage(bitmap, 0, 0);
return await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
const res = await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
canvas.width = 0;
canvas.height = 0;
return res;
} finally {
bitmap.close();
}
@@ -670,18 +702,23 @@ export async function scanFolder(
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);
const hasThumbs = db.objectStoreNames.contains(THUMBS);
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
const tx = db.transaction(stores, 'readwrite');
const photoStore = tx.objectStore(PHOTOS);
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
for (const row of rows) {
const { thumb, ...meta } = row;
photoStore.put(meta);
if (thumb) {
cacheThumb(row.id, thumb);
thumbStore?.put({ id: row.id, thumb });
}
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
progress.written += rows.length;
// Out of the batch and onto the strip in one move, before the position is
// written: the frames are in the catalogue now, and the screen that draws
// them needs no read to find them. Ordered by nothing — the order they were
// read in is the order the roll was shot in, near enough, and the sort that
// settles it comes when the reading is through.
if (added.length) {
const fresh = added;
added = [];
@@ -689,41 +726,23 @@ export async function scanFolder(
}
await write();
};
// One frame, end to end: its bytes, its tile, its shutter time, and the row the
// catalogue keeps. A frame that has not moved is dropped on its size and its
// time before a byte of it is read, which is what makes a second scan of the
// same folder cheap; a frame that will not read at all is skipped by the lane
// that ran it — one bad file in a folder is not a failed folder.
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise<void> => {
const file = await handle.getFile();
const id = photoId(folder.name, rel);
const seen = known.get(id);
// Unchanged means the same size and the same write time: the frame is left
// exactly as the catalogue already holds it. This is what makes a second scan
// of the same folder cheap, and what finishes a roll whose first scan was cut
// short — the frames it got through are skipped, the rest are read.
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
// A row restored from a backup has no handle: a handle is not a thing that
// can be written to a file, so the row comes back with everything but the
// one thing that opens the frame, and the walk is where it gets it back.
// The row is put again — the bytes of the frame are still never read.
if (seen && seen.size === file.size && seen.mtime === file.lastModified) {
if (!seen.handle) {
batch.push({ ...seen, handle });
if (batch.length >= BATCH) await flush();
}
return;
}
// A RAW is read whole because that is the only way LibRaw can seek to the
// preview the camera wrote inside it; a JPEG is handed to the decoder as it
// is, and only its header is read for the date. Both tiles come off what the
// camera wrote — a RAW's preview is a copy, where a develop is every pixel.
const raw = isRawName(file.name);
const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer()));
const RAW_HEADER_BYTES = 4 * 1024 * 1024;
const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer();
const bytes = new Uint8Array(sliceBuffer);
const preview = raw ? await rawThumbnail(bytes) : null;
// The tile is the preview the camera wrote inside a RAW, or the frame itself.
// Both carry their own size in their first few hundred bytes — the same bytes
// the date comes out of — so the decoder is handed the size instead of being
// asked with a second decode of the whole frame to learn which edge is long.
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
const thumb = src ? await tile(src, size) : null;
@@ -739,16 +758,10 @@ export async function scanFolder(
taken,
size: file.size,
mtime: file.lastModified,
// The reader's own score is not the file's: a frame that is read again
// keeps the stars it was given.
star: seen?.star,
addedAt: seen?.addedAt ?? Date.now(),
};
batch.push(row);
// A frame the catalogue already held is on the strip already: its row takes
// the place of the one there, at the end of the reading. Only the frame the
// reading met for the first time is handed over as it lands — that is the
// one the reader is waiting to see.
if (!seen) added.push(row);
progress.added++;
inHand = inHand.filter((r) => r !== rel);
@@ -764,6 +777,7 @@ export async function scanFolder(
// no more than one batch.
const drain = async () => {
const lanes = new Set<Promise<void>>();
let processed = 0;
while (entries.length) {
if (shouldStop?.()) {
stop = true;
@@ -793,6 +807,12 @@ export async function scanFolder(
lanes.add(lane);
if (lanes.size >= LANES) await Promise.race(lanes);
publish();
// Micro-yield to the browser main thread event loop so scanning never blocks UI rendering or user interactions
processed++;
if (processed % 12 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
await Promise.all(lanes);
};
@@ -846,6 +866,8 @@ export async function scanFolder(
publish(true);
}
await drain();
// Yield between pass iterations to maintain UI fluidness
await new Promise((resolve) => setTimeout(resolve, 0));
} while (!walked && !stop);
await flush();
// A reading that came to its end has no position worth keeping: the next one
@@ -1223,16 +1245,99 @@ export function cachedPhotos(): LibraryPhoto[] {
return cached ?? [];
}
export function clearLibraryCache(): void {
cached = null;
}
// Null, and not an empty list: a read that failed is not a catalogue with
// nothing in it, and the screen that would draw an empty strip on one is the
// screen that loses the frames it was showing — under a scan of its own, where
// the read is the thing that gives way first, that is every time.
export async function getPhotoThumb(id: string): Promise<Blob | null> {
const cached = getCachedThumb(id);
if (cached) return cached;
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return null;
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
if (row?.thumb) {
cacheThumb(id, row.thumb);
return row.thumb;
}
} catch {}
return null;
}
export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>> {
const res = new Map<string, Blob>();
const missing: string[] = [];
for (const id of ids) {
const cached = getCachedThumb(id);
if (cached) {
res.set(id, cached);
} else {
missing.push(id);
}
}
if (!missing.length) return res;
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return res;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(THUMBS, 'readonly');
const store = tx.objectStore(THUMBS);
for (const id of missing) {
const req = store.get(id);
req.onsuccess = () => {
const row = req.result as { id: string; thumb: Blob } | undefined;
if (row?.thumb) {
cacheThumb(id, row.thumb);
res.set(id, row.thumb);
}
};
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {}
return res;
}
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
try {
const db = await openDb();
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.
if (db.objectStoreNames.contains(THUMBS)) {
const legacyPhotosToUpdate: LibraryPhoto[] = [];
const legacyThumbsToPut: { id: string; thumb: Blob }[] = [];
for (const row of rows) {
if (row.thumb) {
const t = row.thumb;
cacheThumb(row.id, t);
legacyThumbsToPut.push({ id: row.id, thumb: t });
const { thumb, ...meta } = row;
legacyPhotosToUpdate.push(meta as LibraryPhoto);
}
}
if (legacyThumbsToPut.length > 0) {
void (async () => {
try {
const tx = db.transaction([PHOTOS, THUMBS], 'readwrite');
const pStore = tx.objectStore(PHOTOS);
const tStore = tx.objectStore(THUMBS);
for (const item of legacyThumbsToPut) tStore.put(item);
for (const item of legacyPhotosToUpdate) pStore.put(item);
} catch {}
})();
}
}
for (let i = 0; i < rows.length; i++) {
if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb;
}
rows.sort((a, b) => b.taken - a.taken);
if (!folder) cached = rows;
return rows;
@@ -1247,7 +1352,12 @@ export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
try {
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
const photo = (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
if (photo && photo.thumb) {
cacheThumb(id, photo.thumb);
delete (photo as { thumb?: Blob | null }).thumb;
}
return photo;
} catch {
return null;
}
@@ -1350,22 +1460,35 @@ export async function restoreCatalogue(
const db = await openDb();
const size = 200;
let put = 0;
const hasThumbs = db.objectStoreNames.contains(THUMBS);
for (let at = 0; at < photos.length; at += size) {
const slice = photos.slice(at, at + size);
const had = await rowsAt(slice.map((r) => r.id));
const want = slice.filter((_, i) => !had[i]?.handle);
if (!want.length) continue;
const rows = await Promise.all(
want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto)
const items = await Promise.all(
want.map(async (row) => {
const blob = thumb ? await thumb(row.id) : null;
return { row, blob };
})
);
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);
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
const tx = db.transaction(stores, 'readwrite');
const photoStore = tx.objectStore(PHOTOS);
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
for (const { row, blob } of items) {
const { thumb: _, ...meta } = row as any;
photoStore.put(meta);
if (blob) {
cacheThumb(row.id, blob);
thumbStore?.put({ id: row.id, thumb: blob });
}
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
put += rows.length;
put += items.length;
}
for (let at = 0; at < edits.length; at += size) {
const slice = edits.slice(at, at + size);
+7 -15
View File
@@ -23,6 +23,7 @@
import type { LibraryEdit, LibraryFolder } from './library';
import {
ensurePermission,
getPhotoThumb,
listEdits,
listFolders,
listPhotos,
@@ -125,6 +126,7 @@ async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
// without a click behind it is a prompt the browser may refuse outright, and the
// buttons on the screen are where the click is.
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
if (!handle) return false;
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
};
@@ -314,32 +316,22 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi
let done = 0;
const rows: { id: string; size: number }[] = [];
for (const photo of photos) {
// Progress counts frames and not tiles: a folder of frames read without one
// is still a reading, and a bar that does not move is a bar that reads as a
// screen that has stopped.
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
if (!photo.thumb) continue;
if (had.get(photo.id) === photo.thumb.size) continue;
const blob = photo.thumb ?? (await getPhotoThumb(photo.id));
if (!blob) continue;
if (had.get(photo.id) === blob.size) continue;
const parts = photo.id.split('/');
parts.pop();
const rel = [THUMBS, ...parts, photo.name].join('/');
try {
await put(root, rel, photo.thumb);
await put(root, rel, blob);
} catch (err) {
// A frame the browser will not write is not a library that is never
// written: the run goes on without it, and the name it stopped on goes to
// the console, since the line on the screen is one line over a strip of
// photographs. Whatever survives `spell` is a name longer than a folder
// will hold, and a name spelled back is a name the restore still finds.
if (skipped < 5) console.warn('backup: could not write', rel, err);
skipped++;
continue;
}
rows.push({ id: photo.id, size: photo.thumb.size });
rows.push({ id: photo.id, size: blob.size });
written++;
// The list is kept in step with the writes: a run that is interrupted
// continues from the frame it stopped at instead of writing the folder
// again.
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
}
await keep(WRITTEN, rows);
+69 -6
View File
@@ -215,17 +215,78 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
return new Uint8Array(thumb.data);
}
// Fast pure JS binary scanner to extract embedded JPEG preview from RAW files (ARW, NEF, CR2, CR3, DNG, RAF, RW2, ORF, PEF).
// Runs in ~1-3ms without loading full file or initializing LibRaw WebAssembly.
export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
const len = buf.length;
for (let i = 0; i < len - 4; i++) {
if (buf[i] === 0xff && buf[i + 1] === 0xd8 && buf[i + 2] === 0xff) {
// Found JPEG SOI marker
let pos = i + 2;
let eoiPos = -1;
let valid = true;
while (pos < len - 1) {
if (buf[pos] !== 0xff) {
pos++;
continue;
}
const marker = buf[pos + 1];
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
pos += 2;
continue;
}
if (marker === 0xd9) {
eoiPos = pos + 2;
break;
}
if (marker === 0xd8) {
pos += 2;
continue;
}
if (pos + 3 >= len) break;
const segLen = (buf[pos + 2] << 8) | buf[pos + 3];
if (segLen < 2) {
valid = false;
break;
}
if (marker === 0xda) {
// Start of scan: entropy-coded data until EOI
pos += 2 + segLen;
while (pos < len - 1) {
if (buf[pos] === 0xff) {
const m = buf[pos + 1];
if (m === 0xd9) {
eoiPos = pos + 2;
break;
}
if (m !== 0x00 && !(m >= 0xd0 && m <= 0xd7)) {
break;
}
}
pos++;
}
if (eoiPos !== -1) break;
} else {
pos += 2 + segLen;
}
}
if (valid && eoiPos > i && (eoiPos - i) > 10000) {
return buf.subarray(i, eoiPos);
}
}
}
return null;
}
// 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 — and with the scan's lanes, a few of those opens at once on a
// folder of RAW. 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 fast = extractEmbeddedJpeg(bytes);
if (fast) return fast;
const raw = new LibRaw();
try {
await raw.open(bytes as unknown as BufferSource, SETTINGS);
@@ -233,7 +294,9 @@ export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null
} catch {
return null;
} finally {
raw.dispose();
try {
raw.dispose();
} catch {}
}
}
+10 -1
View File
@@ -102,7 +102,16 @@ function rasterSurface(w: number, h: number) {
let grContext: any = null;
function ensureGrContext(): any {
if (grContext) return grContext;
if (grContext) {
if (typeof grContext.isContextLost === 'function' && grContext.isContextLost()) {
try {
if (typeof grContext.delete === 'function') grContext.delete();
} catch {}
grContext = null;
} else {
return grContext;
}
}
if (typeof OffscreenCanvas === 'undefined') return null;
try {
grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8)));
+1 -6
View File
@@ -37,18 +37,13 @@ function pick(route: string) {
);
}
// Links are plain `<a href>`, so following one throws the page away — fine
// everywhere except while a roll is being read: that scan runs for minutes in
// this page's memory, and only the studio needs to be reachable from the
// catalogue. So the click is taken over *only* while a scan is in flight, and
// the browser keeps doing the navigating the rest of the time.
// Intercept internal link clicks for instant SPA transitions without full page reload
function Shell() {
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (!scanSession()) return;
const target = e.target as Element | null;
const link = target?.closest?.('a');
if (!(link instanceof HTMLAnchorElement)) return;
+4 -12
View File
@@ -1,16 +1,8 @@
import { scanSession } from './engine/library';
// Internal navigation, in one place. The app has no router: a route is a page
// load, and that is fine for everything except a route change made while a scan
// is reading a roll, because the reading lives in this page's memory and a reload
// throws it away. So while a scan is in flight the route changes in place —
// push the address, tell whoever draws it — and with no scan running the browser
// navigates exactly as it always has.
// Internal navigation, in one place. SPA in-place navigation preserves
// in-memory photo and folder caches when transitioning between Web Studio
// (/app) and Library (/library).
export function go(url: string): void {
if (!scanSession()) {
window.location.href = url;
return;
}
history.pushState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}
+93
View File
@@ -1842,3 +1842,96 @@ button.hint:disabled { cursor: default; }
transform: translate(-50%, -50%);
}
}
/* --- the phone's moderation page -----------------------------------------
Portrait, for /admin only (`.adm:not(.lib-root)`): one column, every control
full width, and nothing that asks for a pointer. The bar keeps its mark and
its name and wears its two commands as 44px icons — a finger's target, the
floor the workspace's own bars use; font-size 0 takes the word out of the
paint, not out of the button, so the accessible name and the busy state stand.
The section menu becomes the swipeable row of pills the studio's rail is, and
the shelf of albums stands over the frame it opens instead of beside it. */
@media (max-width: 860px) {
.adm:not(.lib-root) { padding: 0 12px 32px; }
.adm:not(.lib-root) .adm-bar {
flex-wrap: nowrap;
gap: 8px;
margin: 0 -12px 12px;
padding: 8px 12px;
min-height: 58px;
}
/* The count is the first thing to go: every album lists its own. */
.adm:not(.lib-root) .adm-bar .hint { display: none; }
/* The word goes, the mark stays — it is still the way home, and the name is
the title's job on this row. */
.adm:not(.lib-root) .adm-brand span { display: none; }
.adm:not(.lib-root) .adm-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"],
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"] {
flex: 0 0 auto;
width: 44px;
height: 44px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0;
}
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before,
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { font-size: 20px; line-height: 1; }
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before { content: '⤒'; }
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { content: '✕'; }
/* The section menu: one row of pills that scrolls, the studio rail's shape. */
.adm:not(.lib-root) .adm-side {
flex-direction: row;
flex-wrap: nowrap;
gap: 6px;
padding: 8px 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.adm:not(.lib-root) .adm-side::-webkit-scrollbar { display: none; }
.adm:not(.lib-root) .adm-side-title { display: none; }
.adm:not(.lib-root) .adm-side-item {
flex: 0 0 auto;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 16px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.adm:not(.lib-root) .adm-side-item.on { border-color: var(--accent); }
.adm:not(.lib-root) .adm-pane { padding: 12px; }
/* Portrait: the albums stand over the frame they open, and the shelf stops
pretending to be a sidebar — it scrolls with the page. */
.adm:not(.lib-root) .adm-split { grid-template-columns: minmax(0, 1fr); }
.adm:not(.lib-root) .adm-album-list { max-height: 40vh; }
.adm:not(.lib-root) .adm-album { min-height: 44px; }
/* A destination chip is a control before it is a label. */
.adm:not(.lib-root) .adm-choice { min-height: 44px; padding: 8px 12px; }
/* The strip's tiles: two and a bit to a screen, so a thumb is worth aiming at. */
.adm:not(.lib-root) .adm-thumb { flex: 0 0 152px; }
.adm:not(.lib-root) .adm-bulk { flex-wrap: wrap; }
.adm:not(.lib-root) .adm-bulk .btn { min-height: 44px; }
/* The accounts table is wider than a phone, so it scrolls as one piece; the
rows grow to a thumb. */
.adm:not(.lib-root) .adm-table-wrap { -webkit-overflow-scrolling: touch; }
.adm:not(.lib-root) .adm-table th,
.adm:not(.lib-root) .adm-table td { padding: 9px 10px; }
.adm:not(.lib-root) .adm-table .btn { min-height: 40px; }
}
+28 -6
View File
@@ -185,6 +185,17 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
cursor: pointer;
}
.lp-tool:hover { color: var(--lp-text); border-color: var(--accent-line); }
/* The language button carries a globe and the code at once; which one is drawn
is the stylesheet's call (the phone block below swaps them). */
.lp-tool-globe {
display: none;
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
}
/* The theme popover borrows the workspace's .popover/.chip shell, re-skinned for
the landing palette. */
.lp-theme-pop { background: var(--lp-card); border-color: var(--lp-line); }
@@ -211,16 +222,29 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
@media (max-width: 1160px) {
.lp-subnav, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; }
/* The bar has collapsed, so it is the phone's bar: every control is a glyph
on a finger's target. 44 is the floor the studio's own bars use — a 34px
circle with an 11px word in it was neither drawn for a thumb nor legible
on one. The language button swaps its code for a globe here; both live in
the DOM, and `aria-label` is the name either way. */
.lp-tool { width: 44px; height: 44px; font-size: 20px; }
.lp-tool-code { display: none; }
.lp-tool-globe { display: block; }
.lp-burger {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 44px;
height: 44px;
background: none;
border: 0;
padding: 8px;
border: 1px solid var(--lp-line);
border-radius: 999px;
padding: 0;
cursor: pointer;
}
.lp-burger i { display: block; width: 20px; height: 2px; background: var(--lp-text); }
.lp-burger i { display: block; width: 22px; height: 2px; background: var(--lp-text); }
.lp-sheet {
display: flex;
flex-direction: column;
@@ -601,11 +625,9 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
}
@media (max-width: 680px) {
/* logo + theme + language + burger have to fit a 360px phone */
.lp-nav .lp-wrap { gap: 10px; }
.lp-nav .lp-wrap { gap: 8px; padding: 0 14px; }
.lp-logo { font-size: 19px; }
.lp-logo-mark { width: 24px; height: 24px; }
.lp-tool { width: 30px; height: 30px; font-size: 11px; }
.lp-burger { padding: 6px; }
.lp-sec { padding: 64px 0; }
/* The subnav is gone under 1160px, so only the notice has to be paid for. */
.lp-hero { padding: 144px 0 52px; }