web: walk a roll layer by layer, and let the strip drop the branch

This commit is contained in:
2026-09-28 19:55:02 +07:00
parent bd206a8be9
commit d0fddba1e8
7 changed files with 282 additions and 63 deletions
+55 -58
View File
@@ -15,6 +15,7 @@
import type { Recipe } from '../../shared/types';
import { readCapturedAt } from './imageOps';
import { isRawName, rawThumbnail } from './rawDevelop';
import { walkPass, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library';
const FOLDERS = 'folders';
@@ -235,32 +236,13 @@ export interface ScanProgress {
added: number;
}
// The walk goes all the way down: a picked folder is usually a roll of
// date-named subfolders, and a catalogue that only saw its top level would be
// empty. Names beginning with `.` or `@` are skipped — that is `.thumbnails`,
// `.git`, and the `@eaDir` a Synology writes beside every frame.
async function collectFiles(
dir: FileSystemDirectoryHandle,
prefix: string,
out: { handle: FileSystemFileHandle; rel: string }[]
): Promise<void> {
for await (const entry of dir.values()) {
if (/^[.@]/.test(entry.name)) continue;
if (entry.kind === 'directory') {
try {
await collectFiles(entry as FileSystemDirectoryHandle, `${prefix}${entry.name}/`, out);
} catch {
// A subfolder that will not open is skipped; the rest of the roll stands.
}
} else if (isSupportedPhoto(entry.name)) {
out.push({ handle: entry as FileSystemFileHandle, rel: `${prefix}${entry.name}` });
}
}
}
// 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.
//
// The walk itself lives in `rollWalk`: layer by layer from the picked folder
// down, one bounded pass at a time, with the folder the reader just clicked
// pulled to the front of the queue. `jump` is that click.
//
// 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
@@ -268,12 +250,13 @@ async function collectFiles(
export async function scanFolder(
folder: LibraryFolder,
onProgress?: (p: ScanProgress) => void,
shouldStop?: () => boolean
shouldStop?: () => boolean,
jump?: () => string | null
): Promise<ScanProgress> {
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
const entries: { handle: FileSystemFileHandle; rel: string }[] = [];
await collectFiles(folder.handle, '', entries);
const progress: ScanProgress = { folder: folder.name, total: entries.length, done: 0, added: 0 };
const pending = [{ dir: folder.handle, rel: '' }];
const entries: WalkFile[] = [];
const progress: ScanProgress = { folder: folder.name, total: 0, 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.
@@ -290,39 +273,53 @@ export async function scanFolder(
tx.onerror = () => reject(tx.error);
});
};
for (const { handle, rel } of entries) {
if (shouldStop?.()) break;
progress.done++;
try {
const file = await handle.getFile();
const id = photoId(folder.name, rel);
const seen = known.get(id);
if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) {
onProgress?.(progress);
continue;
let stop = false;
let walked = false;
// One bounded pass at a time, and the pass yields between frames, so a folder
// clicked while the scan runs is walked on the next jump rather than after the
// whole tree. Nothing clicked: parents first, then each layer below, deepest
// last — the order `pending` is filled in.
do {
const from = entries.length;
walked = await walkPass(pending, entries, isSupportedPhoto, jump ?? (() => null));
progress.total += entries.length - from;
for (const { handle, rel } of entries.slice(from)) {
if (shouldStop?.()) {
stop = true;
break;
}
const bytes = new Uint8Array(await file.arrayBuffer());
const [thumb, taken] = [await makeThumbnail(file, bytes), (await readCapturedAt(bytes)) ?? file.lastModified];
const cut = rel.lastIndexOf('/');
batch.push({
id,
folder: folder.name,
dir: cut < 0 ? '' : rel.slice(0, cut),
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.
progress.done++;
try {
const file = await handle.getFile();
const id = photoId(folder.name, rel);
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];
const cut = rel.lastIndexOf('/');
batch.push({
id,
folder: folder.name,
dir: cut < 0 ? '' : rel.slice(0, cut),
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);
}
onProgress?.(progress);
}
} while (!walked && !stop);
await flush();
return progress;
}