Files
Oxicloud/frontend/src/lib/utils/photoTimeline.ts
T
Claude 7626dc95c1 perf: round 7 — photos timeline O(N²)→incremental, range-seek authz duplication, resources row-map clone
Benchmark-gated (equivalence + BEFORE/AFTER; results + reproduce commands in
benches/ROUND7.md):

- Photos timeline re-grouped + re-laid-out the whole accumulated library on
  every 60-item page (both `groups` and `photoRows` were $derived over the
  full list), Σ ≈ O(N²/60) main-thread work during a scroll. Pages arrive
  newest-first so grouping is append-only: the new PhotoTimeline
  (lib/utils/photoTimeline.ts) re-buckets only the fresh page and re-lays-out
  only changed groups, reusing untouched groups' cached rows, falling back to
  a full rebuild on any config/deletion/non-append change. The pure
  buildPhotoRows is the verbatim reference the gate holds it equal to at every
  page. 50×60 drain: 76 500 → 3 000 grouping ops (25.5x), 23.0 → 2.2 ms
  (10.6x).

- Range downloads paid authz + access-notify twice: download_file_impl
  resolves the file via get_file_with_perms, then the Range branch re-ran
  require_file + notify_file_accessed per request. Media/PDF viewers fetch
  exclusively via Range (one request per seek), so every seek in a scrub
  re-authorized an already-cleared file. Now routed through the non-perms
  get_file_range_preloaded (matching the share-landing + WebDAV range paths);
  the unused _with_perms range method is removed. The request-level gate still
  denies before the branch runs (bench asserts member granted, outsider
  denied). Per seek removed: WARM 0.67 µs, COLD 1362.66 µs — a grant-cascade
  drive-resolve query per seek for a shared-drive recipient on a cold cache.

- /api/folders/{id}/resources row→DTO mapping cloned row.name into the DTO
  though the row is owned; folders move it (fixed icons), files compute the
  name-derived icon/category classes first then move it. 500-row page:
  10.004 → 9.004 allocs/row (500 clones removed), output identical.

Deferred with rationale in ROUND7.md: thumbnail ACL-before-304 (security
posture — needs a security review, not a perf tweak), batch_operations
Arc<str>→String widening, list-view O(N²) on smaller lists, and the serial→
join! pairs (decide-by-bench with injected latency, per the round-6 rejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
2026-07-18 13:11:41 +00:00

280 lines
9.3 KiB
TypeScript

/**
* Photo-timeline grouping + row layout, extracted from the photos view so the
* O(N²) accumulation of its `groups`/`photoRows` derives can be replaced with
* an incremental builder (and unit/benchmark-tested off the Svelte reactive
* graph).
*
* Photos arrive newest-first (`media_sort_date DESC`), so each fetched page
* only ever extends the last date bucket or appends new buckets after it —
* never mutates an earlier group. {@link PhotoTimeline} exploits that: an
* append re-buckets only the new page and recomputes rows only for the groups
* that actually changed, keeping a full scroll O(N) instead of O(N²).
*
* The pure {@link buildPhotoRows} is the verbatim reference (what the old
* `groups`→`photoRows` derive chain produced); the benchmark gate asserts the
* incremental builder stays byte-for-byte equal to it.
*/
import type { PhotoItem } from '$lib/api/endpoints/photos';
export type GroupMode = 'day' | 'month' | 'year';
export type LayoutMode = 'square' | 'justified';
export interface JustifiedTile {
file: PhotoItem;
w: number;
h: number;
}
export type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
/** Layout constants — mirror the photos view's original values exactly. */
export const SQUARE_GAP = 4; // .25rem, matches the old grid gap
export const SQUARE_MIN = 144; // 9rem minmax floor
export const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
export const HEADER_H = 44;
export interface TimelineConfig {
groupMode: GroupMode;
layoutMode: LayoutMode;
/** Usable content width of the grid, in px. */
width: number;
/** `(max-width: 768px)` — selects the 150px vs 200px justified target. */
mobile: boolean;
/** EXIF-aware capture timestamp (ms). Injected so the module stays pure. */
timestampOf: (p: PhotoItem) => number;
/** Locale-aware bucket label for a group's representative date. */
labelOf: (d: Date, mode: GroupMode) => string;
}
interface Group {
key: string;
label: string;
photos: PhotoItem[];
}
/** Year/month/day bucket key for a date under `groupMode` (verbatim). */
export function bucketKey(d: Date, groupMode: GroupMode): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
/**
* Pack files into justified rows (Flickr-style): each full row is scaled to
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
* fall back to 1:1. Verbatim port of the photos view's `justifiedRows`, with
* the `matchMedia` read hoisted to the `mobile` flag so it's testable.
*/
export function justifiedRows(
files: PhotoItem[],
width: number,
mobile: boolean
): Array<{ height: number; tiles: JustifiedTile[] }> {
const gap = 8;
const target = mobile ? 150 : 200;
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * h)),
h: Math.round(h)
}))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * target)),
h: target
}))
});
}
return rows;
}
/** Columns + cell size for the square layout at width `W` (verbatim). */
function squareGeometry(W: number): { cols: number; cell: number } {
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
return { cols, cell };
}
/** Flatten one group into its header + tile rows (verbatim per-group body). */
function groupToRows(g: Group, cfg: TimelineConfig, cols: number, cell: number): PhotoRow[] {
const rows: PhotoRow[] = [
{ kind: 'header', key: `h:${g.key}`, height: HEADER_H, label: g.label, count: g.photos.length }
];
if (cfg.layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, cfg.width, cfg.mobile);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
return rows;
}
/** Bucket `items` into date groups, first-appearance order (verbatim). */
function buildGroups(items: PhotoItem[], cfg: TimelineConfig): Group[] {
const out: Group[] = [];
const index = new Map<string, number>();
for (const p of items) {
const d = new Date(cfg.timestampOf(p));
const key = bucketKey(d, cfg.groupMode);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [] });
}
out[i].photos.push(p);
}
return out;
}
/**
* Verbatim reference: the flat `PhotoRow[]` the old `groups`→`photoRows`
* derive chain produced for `items` under `cfg`. Returns `[]` for a
* non-positive width, matching the old guard. The benchmark gate holds the
* incremental builder equal to this.
*/
export function buildPhotoRows(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
if (cfg.width <= 0) return [];
const { cols, cell } = squareGeometry(cfg.width);
const rows: PhotoRow[] = [];
for (const g of buildGroups(items, cfg)) {
rows.push(...groupToRows(g, cfg, cols, cell));
}
return rows;
}
function configEq(a: TimelineConfig, b: TimelineConfig): boolean {
return (
a.groupMode === b.groupMode &&
a.layoutMode === b.layoutMode &&
a.width === b.width &&
a.mobile === b.mobile &&
a.timestampOf === b.timestampOf &&
a.labelOf === b.labelOf
);
}
/**
* Incremental photo-timeline builder. Call {@link sync} with the current item
* list and config on every change; it detects the common case — the list grew
* by appending a page while config is unchanged — and re-buckets only the new
* items + re-lays-out only the groups that changed, reusing every untouched
* group's cached rows. Any other change (config, deletion, filter toggle,
* non-append) falls back to a full rebuild, so the result is always identical
* to {@link buildPhotoRows}.
*/
export class PhotoTimeline {
#cfg: TimelineConfig | null = null;
#groups: Group[] = [];
/** Items already bucketed — the append cursor into the last synced list. */
#groupedItems: PhotoItem[] = [];
/** group.key → its cached rows for the current config. */
#rowCache = new Map<string, PhotoRow[]>();
#geom = { cols: 1, cell: 0 };
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
#isAppend(prev: PhotoItem[], next: PhotoItem[]): boolean {
if (next.length <= prev.length) return false;
// Prefix identity via the boundary object — O(1), the list is only ever
// mutated by appending or by replacing with a filtered copy.
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
}
#rebuild(items: PhotoItem[], cfg: TimelineConfig): void {
this.#cfg = cfg;
this.#groups = cfg.width > 0 ? buildGroups(items, cfg) : [];
this.#groupedItems = items;
this.#rowCache.clear();
this.#geom = squareGeometry(cfg.width);
}
#extend(items: PhotoItem[], cfg: TimelineConfig): void {
const fresh = items.slice(this.#groupedItems.length);
// The last existing group may grow, so its cached rows are stale.
if (this.#groups.length > 0) {
this.#rowCache.delete(this.#groups[this.#groups.length - 1].key);
}
for (const p of fresh) {
const d = new Date(cfg.timestampOf(p));
const key = bucketKey(d, cfg.groupMode);
const last = this.#groups[this.#groups.length - 1];
if (last && last.key === key) {
last.photos.push(p);
} else {
this.#groups.push({ key, label: cfg.labelOf(d, cfg.groupMode), photos: [p] });
}
}
this.#groupedItems = items;
}
sync(items: PhotoItem[], cfg: TimelineConfig): PhotoRow[] {
if (cfg.width <= 0) {
// Keep the item cursor so a later positive width rebuilds from scratch.
this.#cfg = cfg;
this.#groups = [];
this.#groupedItems = items;
this.#rowCache.clear();
return [];
}
if (this.#cfg && configEq(this.#cfg, cfg) && this.#isAppend(this.#groupedItems, items)) {
this.#extend(items, cfg);
} else {
this.#rebuild(items, cfg);
}
const { cols, cell } = this.#geom;
const out: PhotoRow[] = [];
for (const g of this.#groups) {
let rows = this.#rowCache.get(g.key);
if (rows === undefined) {
rows = groupToRows(g, cfg, cols, cell);
this.#rowCache.set(g.key, rows);
}
for (const r of rows) out.push(r);
}
return out;
}
}