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:
2026-05-17 22:46:10 +02:00
parent 9a3ad3e579
commit 6b8c7abc20
10 changed files with 300 additions and 49 deletions

View File

@@ -2,12 +2,14 @@ package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"github.com/gin-gonic/gin"
)
@@ -163,6 +165,114 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
}
}
type folderCountsBody struct {
Paths []string `json:"paths"`
}
// folderCountsRow is the minimal PhotoPrism photo projection the handler
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
// expands one photo into one row per File on disk). PhotoPrism returns a
// JSON array of much richer objects; unmarshalling into this small
// shape ignores everything we don't care about.
type folderCountsRow struct {
UID string `json:"UID"`
}
// handleFolderCounts returns photo counts for each PhotoPrism folder
// path in one round-trip. The web client used to fire one
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
// folder × N folders) to populate the left-sidebar tree. Moving the
// fan-out into the sidecar keeps the same correctness profile — same
// q-DSL, same `merged=false` UID dedupe, same 1000-row server cap —
// but the wire payload back to the browser collapses to a single small
// JSON object (`{path: count}`).
//
// We bounce off PhotoPrism with `count=1000` and dedupe UIDs server-
// side rather than trusting a count header: PhotoPrism's `/photos`
// X-Count is the *per-page* row count (per existing front-end
// comment), not the total-match count, so we'd silently undercount if
// we used it. Lifting the 1000 cap would mean either iterating offsets
// or growing PhotoPrism's response cap — both out of scope here.
//
// Bounded concurrency caps the fan-out so a library with hundreds of
// folders doesn't open hundreds of connections to PhotoPrism at once.
// Errors per-folder degrade to count=0 rather than failing the whole
// batch — the sidebar would rather show a missing badge for one folder
// than nothing for any.
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body folderCountsBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if len(body.Paths) == 0 {
c.JSON(http.StatusOK, gin.H{})
return
}
const maxInFlight = 8
var (
wg sync.WaitGroup
sem = make(chan struct{}, maxInFlight)
mu sync.Mutex
counts = make(map[string]int, len(body.Paths))
)
// Seed every input key so the response always carries the same
// shape the client posted, even for paths whose lookup failed.
for _, p := range body.Paths {
counts[p] = 0
}
for _, p := range body.Paths {
path := p
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
// `path:` is non-recursive in PhotoPrism's q-DSL: matches
// direct children only. `merged=false` returns one row per
// File on disk, so HEIC + companion JPG count twice unless
// we dedupe by UID — which is what the old client-side
// code did, and what we keep doing here.
q := url.QueryEscape("path:" + path)
resp, err := pp.call(c.Request.Context(), http.MethodGet,
"/api/v1/photos?count=1000&offset=0&merged=false&q="+q, token, nil)
if err != nil || !resp.OK {
slog.Warn("folder.counts: pp call failed",
"path", path,
"err", err,
"status", func() int {
if resp != nil {
return resp.Status
}
return 0
}())
return
}
var rows []folderCountsRow
if err := json.Unmarshal(resp.Body, &rows); err != nil {
slog.Warn("folder.counts: parse failed", "path", path, "err", err)
return
}
seen := make(map[string]struct{}, len(rows))
for _, r := range rows {
if r.UID == "" {
continue
}
seen[r.UID] = struct{}{}
}
mu.Lock()
counts[path] = len(seen)
mu.Unlock()
}()
}
wg.Wait()
c.JSON(http.StatusOK, counts)
}
}
// fireReindex wraps pp.reindex with logging and a detached context so
// it can run in a goroutine after the response has gone out. The Node
// prototype kicks reindex with `void reindex(...)` and never awaits;

View File

@@ -70,6 +70,7 @@ func main() {
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))

View File

@@ -28,11 +28,14 @@ func newPPClient(base string) *ppClient {
// ppResp is the trimmed projection of an HTTP response that callers
// actually consume. Status + raw body are exposed so handlers can mirror
// PhotoPrism's status code or parse the body themselves.
// PhotoPrism's status code or parse the body themselves. Header is
// retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on
// list endpoints — PhotoPrism exposes total-match counts there.
type ppResp struct {
OK bool
Status int
Body []byte
Header http.Header
}
// call issues an authenticated request against PhotoPrism. body is
@@ -78,6 +81,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
Status: resp.StatusCode,
Body: buf,
Header: resp.Header,
}, nil
}

View File

@@ -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;

View File

@@ -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 ?? {});

View File

@@ -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}

View File

@@ -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 ──────────────────────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -4,6 +4,7 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Component } from 'svelte';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
@@ -12,7 +13,7 @@
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import PreviewOverlay from '$lib/components/preview/PreviewOverlay.svelte';
import { preview } from '$lib/stores/preview.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
@@ -42,6 +43,23 @@
void goto('/login', { replaceState: true });
}
});
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
// pane, exif sidebar. Users who never click into a photo never need
// it, so we lazy-import the first time `preview.uid` flips non-null
// and keep the loaded module around for the rest of the session
// (re-opens skip the network round-trip). Closing the overlay leaves
// the component mounted but renders nothing — its internal
// `{#if preview.uid !== null}` guard collapses the DOM tree.
let PreviewOverlay = $state<Component | null>(null);
$effect(() => {
if (!browser) return;
if (preview.uid !== null && PreviewOverlay === null) {
void import('$lib/components/preview/PreviewOverlay.svelte').then((m) => {
PreviewOverlay = m.default as Component;
});
}
});
</script>
<svelte:head>
@@ -97,5 +115,7 @@
{:else}
{@render children?.()}
{/if}
<PreviewOverlay />
{#if PreviewOverlay}
<PreviewOverlay />
{/if}
</QueryClientProvider>

View File

@@ -143,16 +143,20 @@
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
* pages can repeat a photo when its file-row span straddles the offset
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
* preserves order. Downstream (`setOrder`, `rows`, preview, click
* handlers) treat this as the single source of truth.
* preserves order.
*
* Split into two derived values so the heavy dedup pass only runs
* when the query's pages array changes (i.e. a fetch landed). The
* cheap folder-scope filter then runs whenever the user flips the
* filter store — search-as-you-type, section toggle, root vs.
* subfolder — without re-walking every page on each keystroke.
*
* When the user picks the root entry in the folder tree we filter
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */
const photos = $derived<PpPhoto[]>(
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
);
const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return [];
const seen = new Set<string>();
@@ -287,6 +291,15 @@
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
);
// Hoist the selection reads out of the per-tile each-block. The same
// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
// just expressed at the loop level so the render reads as "given this
// (selected, focused) snapshot, here's each tile's state". No reactivity
// change — SvelteSet's `has` already subscribes per-key — but it removes
// the function-call indirection and keeps the hot loop body terse.
const selectedIds = $derived(selection.ids);
const focusedUid = $derived(selection.focused);
// Reset the window when the filter key changes — old indices from the
// previous photo list otherwise pin renderFirst/renderLast outside the
// new shorter list and the grid renders empty. Track `filtersToQ` as
@@ -878,7 +891,7 @@
use:tileRegister={i}
>
{#if inWindow}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID}
<PhotoTile
{photo}
selected={sel}