perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview
Six-item frontend performance pass on the SvelteKit app.
P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.
P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.
P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".
P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.
P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.
P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,24 +63,51 @@ export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
|
||||
let lastLast = -1;
|
||||
let rafId: number | null = null;
|
||||
|
||||
function compute() {
|
||||
rafId = null;
|
||||
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
|
||||
if (shells.length === 0) return;
|
||||
const rootRect = node.getBoundingClientRect();
|
||||
// Sweep through shells (rendered in document order = photo order)
|
||||
// and find the first/last whose rect crosses the viewport. Bail
|
||||
// out the moment we pass the bottom edge — shells past the
|
||||
// viewport can't intersect, no point measuring them.
|
||||
// Between frames the visible band can shift by at most ~one viewport
|
||||
// of shells (any further and it's a programmatic jump, which falls
|
||||
// back to a full sweep below). 300 covers a fast-flick on the densest
|
||||
// thumbnail preset (XS) plus a buffer; tightening it further saves
|
||||
// little and risks missing the new band after a quick scroll-wheel
|
||||
// flick.
|
||||
const SCAN_MARGIN = 300;
|
||||
|
||||
function scanFrom(
|
||||
shells: NodeListOf<HTMLElement>,
|
||||
rootRect: DOMRect,
|
||||
start: number
|
||||
): [number, number] {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
for (let i = 0; i < shells.length; i++) {
|
||||
for (let i = start; i < shells.length; i++) {
|
||||
const r = shells[i].getBoundingClientRect();
|
||||
if (r.bottom < rootRect.top) continue;
|
||||
if (r.top > rootRect.bottom) break;
|
||||
if (first === -1) first = i;
|
||||
last = i;
|
||||
}
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
function compute() {
|
||||
rafId = null;
|
||||
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
|
||||
if (shells.length === 0) return;
|
||||
const rootRect = node.getBoundingClientRect();
|
||||
// Anchor the sweep around the previous result so a deep timeline
|
||||
// doesn't pay `getBoundingClientRect()` × (every-shell-above-the-
|
||||
// viewport) on every scroll tick. Previous loop scanned from 0
|
||||
// each time → quadratic-feeling on long sessions with 1000+
|
||||
// loaded photos.
|
||||
const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0;
|
||||
let [first, last] = scanFrom(shells, rootRect, startHint);
|
||||
// Bounded scan missed the band — user scrolled past the hint
|
||||
// margin (programmatic jump, filter-reset reflow, etc.). Fall
|
||||
// back to a single full sweep to re-anchor. Costs the same as
|
||||
// the old behavior on this one frame, then bounded scans take
|
||||
// over again.
|
||||
if (first === -1 && startHint > 0) {
|
||||
[first, last] = scanFrom(shells, rootRect, 0);
|
||||
}
|
||||
if (first === -1 || last === -1) return;
|
||||
if (first === lastFirst && last === lastLast) return;
|
||||
lastFirst = first;
|
||||
|
||||
@@ -127,16 +127,33 @@
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
||||
// in parallel. Key the query off the folder-path list so it refetches
|
||||
// when folders are added/renamed/deleted, and share the ['photos', …]
|
||||
// prefix so it invalidates alongside the other photo caches whenever a
|
||||
// mutation lands.
|
||||
// FileCount: 0 for every folder, so the sidecar /folders/counts
|
||||
// endpoint resolves them in one round-trip (see listFolderCounts).
|
||||
// Key the query off the folder-path list so it refetches when folders
|
||||
// are added/renamed/deleted, and share the ['photos', …] prefix so it
|
||||
// invalidates alongside the other photo caches whenever a mutation
|
||||
// lands.
|
||||
//
|
||||
// `countsReady` gates the query until just after the sidebar's first
|
||||
// paint. Even though the sidecar response is small, the per-folder
|
||||
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
|
||||
// blocking it on idle means the folder list paints immediately and
|
||||
// the count badges fade in instead of holding back the whole tree.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
let countsReady = $state(false);
|
||||
if (browser) {
|
||||
const kick = () => (countsReady = true);
|
||||
// requestIdleCallback isn't in Safari yet; fall back to a short
|
||||
// timeout so the deferral is still bounded.
|
||||
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
if (typeof ric === 'function') ric(kick);
|
||||
else setTimeout(kick, 200);
|
||||
}
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0,
|
||||
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Maximize2 } from 'lucide-svelte';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +30,14 @@
|
||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||
// discrete (100/224/500) so the rounding-up at the next variant
|
||||
// boundary swallows the difference.
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -68,9 +77,11 @@
|
||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
src={src1x}
|
||||
srcset={srcset}
|
||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
class:transition={!selected}
|
||||
class:group-hover:scale-105={!selected}
|
||||
|
||||
@@ -364,33 +364,32 @@ export async function getImportInfo(): Promise<ImportInfo> {
|
||||
|
||||
/**
|
||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
|
||||
* response has no total-rows header — X-Count is the per-page row count.
|
||||
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
|
||||
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
|
||||
* pair counts twice if we trusted `data.length`. Capped at the server's
|
||||
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
|
||||
* endpoint reports `FileCount: 0` even when populated, so the count has
|
||||
* to be derived from a `/photos?q=path:X` lookup per folder.
|
||||
*
|
||||
* We hand this off to the sidecar (`POST /api/sidecar/folders/counts`)
|
||||
* which fans out to PhotoPrism over loopback, dedupes by UID, and
|
||||
* returns a single `{path: count}` payload of <5 KB. The previous
|
||||
* client-side implementation issued one `/photos?count=1000` per folder
|
||||
* from the browser — on a library with 30 folders that's ≈30 MB of JSON
|
||||
* pulled across the wire on every cold sidebar mount.
|
||||
*
|
||||
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
|
||||
* children only, so summing the per-path counts (no double-counting from
|
||||
* nested folders) is the right way to derive the root-folder photo
|
||||
* count.
|
||||
* children only, so summing the per-path counts (no double-counting
|
||||
* from nested folders) is the right way to derive the root-folder
|
||||
* photo count. Capped at PhotoPrism's 1000-row ceiling; folders larger
|
||||
* than that under-report (pre-existing limitation, unchanged here).
|
||||
*
|
||||
* Returns a plain object keyed by the input paths to keep it JSON-friendly
|
||||
* for TanStack's structural sharing.
|
||||
* Returns a plain object keyed by the input paths to keep it JSON-
|
||||
* friendly for TanStack's structural sharing.
|
||||
*/
|
||||
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
||||
const entries = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
|
||||
});
|
||||
const uids = new Set<string>();
|
||||
for (const p of data) uids.add(p.UID);
|
||||
return [path, uids.size] as const;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
if (paths.length === 0) return {};
|
||||
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +88,55 @@ export function thumbUrl(hash: string, size = 'tile_500'): string {
|
||||
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PhotoPrism's square-cropped tile sizes (px). These are the variants
|
||||
* the indexer generates by default for the `tile_*` family. fit_* exists
|
||||
* for non-square sizing but is the wrong fit for grid cells with
|
||||
* `object-cover` — we always render a square.
|
||||
*/
|
||||
const TILE_SIZES = [100, 224, 500] as const;
|
||||
|
||||
/**
|
||||
* Pick the smallest PhotoPrism tile variant whose pixel count is at or
|
||||
* above the on-screen target. Falls through to the largest (500) for
|
||||
* anything bigger — we don't have a tile_720+ variant. Used by both the
|
||||
* 1x and 2x slots of the srcset helper below.
|
||||
*/
|
||||
function pickTileSize(targetPx: number): string {
|
||||
for (const s of TILE_SIZES) {
|
||||
if (s >= targetPx) return `tile_${s}`;
|
||||
}
|
||||
return `tile_${TILE_SIZES[TILE_SIZES.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a thumbnail `srcset` for a photo at a given on-screen tile
|
||||
* size. The browser picks the right variant for the current device
|
||||
* pixel ratio — on a 2x display we serve `tile_500` for a 272px XL
|
||||
* tile, on a 1x display the same tile gets `tile_500` only if no
|
||||
* smaller variant covers it, so most users save bandwidth.
|
||||
*
|
||||
* Returns the `srcset` value (no `src` attribute — pair with `thumbUrl`
|
||||
* for the 1x fallback). Two variants is enough: PhotoPrism only
|
||||
* indexes three square sizes (100/224/500), so 1x and 2x cover the
|
||||
* realistic DPR range without flooding the cache.
|
||||
*/
|
||||
export function thumbSrcSet(hash: string, targetPx: number): string {
|
||||
if (!session.previewToken) return '';
|
||||
const one = pickTileSize(targetPx);
|
||||
const two = pickTileSize(targetPx * 2);
|
||||
const url1x = thumbUrl(hash, one);
|
||||
const url2x = thumbUrl(hash, two);
|
||||
if (url1x === url2x) return `${url1x} 1x`;
|
||||
return `${url1x} 1x, ${url2x} 2x`;
|
||||
}
|
||||
|
||||
/** Companion to `thumbSrcSet` — the `src` attribute value (1x). */
|
||||
export function thumbSrc(hash: string, targetPx: number): string {
|
||||
if (!session.previewToken) return '';
|
||||
return thumbUrl(hash, pickTileSize(targetPx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a video stream URL. PhotoPrism's endpoint is
|
||||
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
|
||||
|
||||
Reference in New Issue
Block a user