diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 81ea633..b871282 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -943,25 +943,30 @@ function Catalogue() { // lies under it, with nothing at all to say about the rest of the roll: its // 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 read = live ? live.progress.counts : null; // 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(); const foldersUnder = new Map>(); const add = (folder: string, rel: string, counted: boolean) => { - const set = foldersUnder.get(folder) ?? new Set(); - foldersUnder.set(folder, set); + const normF = (folder || '').replace(/\\/g, '/'); + const normRel = (rel || '').replace(/\\/g, '/'); + const set = foldersUnder.get(normF) ?? new Set(); + foldersUnder.set(normF, set); let path = ''; - for (const part of rel.split('/')) { + for (const part of normRel.split('/')) { + if (!part) continue; path = path ? `${path}/${part}` : part; set.add(path); - if (counted) hits.set(`${folder}/${path}`, (hits.get(`${folder}/${path}`) ?? 0) + 1); + if (counted) hits.set(`${normF}/${path}`, (hits.get(`${normF}/${path}`) ?? 0) + 1); } }; for (const photo of photos) { - hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1); - if (photo.dir) add(photo.folder, photo.dir, true); + const normF = (photo.folder || '').replace(/\\/g, '/'); + const normDir = (photo.dir || '').replace(/\\/g, '/'); + hits.set(normF, (hits.get(normF) ?? 0) + 1); + if (normDir) add(normF, normDir, true); } for (const dir of dirs) add(dir.folder, dir.rel, false); return { hits, foldersUnder }; @@ -969,19 +974,24 @@ function Catalogue() { const nodes = useMemo(() => { const { hits, foldersUnder } = baseTreeInfo; - const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0); + const counted = (key: string, hit: number | undefined) => { + const normKey = (key || '').replace(/\\/g, '/'); + return Math.max(hit ?? 0, read?.[normKey] ?? 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)) }); + const normF = (folder.name || '').replace(/\\/g, '/'); + out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF, hits.get(normF)) }); // Path order puts a folder before its own children, which is the order the // indented rows have to come in. - for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) { + for (const dir of [...(foldersUnder.get(normF) ?? [])].sort((a, b) => a.localeCompare(b))) { const parts = dir.split('/'); + const key = `${normF}/${dir}`; out.push({ - key: `${folder.name}/${dir}`, + key, label: parts[parts.length - 1], depth: parts.length, - count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)), + count: counted(key, hits.get(key)), }); } } diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 4b4153e..c5cb2ed 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -790,13 +790,15 @@ export async function scanFolder( // Count the frame the moment the scan gets to it, before it knows whether the // frame is new: the rows say how far the reading has come, not what it kept. const count = (rel: string) => { - const cut = rel.lastIndexOf('/'); - counts[folder.name] = (counts[folder.name] ?? 0) + 1; + const relNorm = rel.replace(/\\/g, '/'); + const cut = relNorm.lastIndexOf('/'); + const normRoot = normPath(folder.name); + counts[normRoot] = (counts[normRoot] ?? 0) + 1; let path = ''; - for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) { + for (const part of (cut < 0 ? '' : relNorm.slice(0, cut)).split('/')) { if (!part) continue; path = path ? `${path}/${part}` : part; - const key = `${folder.name}/${path}`; + const key = `${normRoot}/${path}`; counts[key] = (counts[key] ?? 0) + 1; } }; @@ -928,25 +930,31 @@ export async function scanFolder( let preview: Uint8Array | null = null; if (heic) { - preview = await heicThumbnail(bytes); - if (!preview) { - try { - const fullBytes = new Uint8Array(await file.arrayBuffer()); - preview = await heicToJpeg(fullBytes, 320); - } catch { - preview = null; + preview = await oneRawAtATime(async () => { + let p = await heicThumbnail(bytes); + if (!p) { + try { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + p = await heicToJpeg(fullBytes, 320); + } catch { + p = null; + } } - } + return p; + }); } else if (raw || isTiff) { - preview = await rawThumbnail(bytes, file.name); - if (!preview && isTiff) { - try { - const fullBytes = new Uint8Array(await file.arrayBuffer()); - preview = tiffThumbnail(fullBytes); - } catch { - preview = null; + preview = await oneRawAtATime(async () => { + let p = await rawThumbnail(bytes, file.name); + if (!p && isTiff) { + try { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + p = tiffThumbnail(fullBytes); + } catch { + p = null; + } } - } + return p; + }); } const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : (raw || heic || isTiff) ? null : file; @@ -998,16 +1006,9 @@ export async function scanFolder( // folder is being read while it is being read. const at = rel.lastIndexOf('/'); progress.at = at < 0 ? '' : rel.slice(0, at); - // A frame a reload handed back that was counted already is read without - // being counted twice; the ones behind it are frames that reading found and - // never reached, and they are counted here as they are reached. - if (restored) restored--; - else { - progress.done++; - count(rel); - } - // Queue image decoding one at a time so memory usage stays low and UI stays smooth - const read = oneRawAtATime(() => readOne(handle, rel)); + progress.done++; + count(rel); + const read = readOne(handle, rel); const lane = read .catch(() => undefined) .finally(() => lanes.delete(lane)); @@ -1015,14 +1016,15 @@ export async function scanFolder( 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 + // Micro-yield to main thread every 64 files to keep scan fast while keeping UI responsive processed++; - if (processed % 3 === 0) { + if (processed % 64 === 0) { await new Promise((resolve) => setTimeout(resolve, 0)); } } await Promise.all(lanes); await flush(); + publish(true); }; // How many of the frames in hand were counted by the reading that handed them