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
+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)));