feat(diashow): guarantee all eligible photos shown + 2048px display derivative
Diashow completeness rewrite so every eligible upload is shown regardless of
bursts, disconnects, or library size:
- queue.ts: SlideQueue with live/shuffle queues, allKnown map, recentlyShown
ring; merge(dedup, live-first), remove/removeByUser (prunes recentlyShown),
knownIds for reconcile-eviction. Adds queue.test.ts (burst/completeness/race).
- diashow/+page.svelte: reconcile (full paginate + evict, pre-scan snapshot to
spare concurrent uploads) on mount/reconnect/periodic; catchUpNew paginate-
until-known for bursts with debounced maxWait; hard-cut removals; decode
timeout + candidate fallback + bounded skip so a broken image never stalls.
New ~2048px "display" derivative for big-screen sharpness, decoupled from the
data-saver preview (800px) used on phones:
- migration 016: upload.display_path + v_feed rebuilt (DROP+CREATE, not REPLACE,
to slot the column beside preview/thumbnail).
- compression: generate_image_derivatives emits preview+display (downscale-only
guard, no upscaling); backfill_missing_display regenerates on startup (safe:
logs on error, never soft-deletes).
- upload.rs/main.rs: GET /upload/{id}/display (mirrors preview auth/cache),
/media/displays direct-serve blocked.
- feed.rs + types.ts: display_url in feed/delta DTOs.
- diashow candidate chain: display -> original -> preview.
Verified on the running stack: migration applied, 10/10 existing images
backfilled (2048px cap honoured, small images not upscaled), /display serves
200, /feed returns display_url, diashow cycles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,18 @@
|
||||
// (minus the most recent N items) once it empties. A new live post pushes onto the
|
||||
// live queue and waits for the next slide transition — it never interrupts the
|
||||
// current slide.
|
||||
//
|
||||
// Completeness is the whole point: `allKnown` is meant to converge to the server's full
|
||||
// set of eligible uploads. The page feeds it via `merge` (incremental / paginated) and
|
||||
// `retainOnly` (evict anything the server no longer returns — deletions, bans, hides),
|
||||
// so every eligible image is eventually shown and no ineligible one lingers.
|
||||
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
|
||||
const RECENT_RING_SIZE = 5;
|
||||
|
||||
export class SlideQueue {
|
||||
/** Live queue — FIFO. New uploads land here via `pushLive`. */
|
||||
/** Live queue — FIFO. New uploads land here via `merge(_, { live: true })`. */
|
||||
private liveQueue: FeedUpload[] = [];
|
||||
/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
|
||||
private shuffleQueue: FeedUpload[] = [];
|
||||
@@ -20,19 +25,35 @@ export class SlideQueue {
|
||||
/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
|
||||
private recentlyShown: string[] = [];
|
||||
|
||||
/** Seed the shuffle pool from an initial fetch of the feed. */
|
||||
seed(initial: FeedUpload[]): void {
|
||||
for (const slide of initial) {
|
||||
this.allKnown.set(slide.id, slide);
|
||||
/**
|
||||
* Add uploads, de-duplicated by id. Only *displayable* items (preview- or
|
||||
* thumbnail-ready) are kept — a still-compressing upload has no image yet, so it's
|
||||
* skipped until a later sync brings it with a preview. `live` items jump onto the live
|
||||
* queue (shown next); otherwise they join the shuffle pool. Returns the count newly added.
|
||||
*/
|
||||
merge(uploads: FeedUpload[], opts: { live: boolean }): number {
|
||||
let added = 0;
|
||||
for (const u of uploads) {
|
||||
if (!u.preview_url && !u.thumbnail_url) continue;
|
||||
if (this.allKnown.has(u.id)) continue;
|
||||
this.allKnown.set(u.id, u);
|
||||
if (opts.live) this.liveQueue.push(u);
|
||||
else this.shuffleQueue.push(u);
|
||||
added++;
|
||||
}
|
||||
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Add a slide pushed by a new SSE upload-processed event. */
|
||||
pushLive(slide: FeedUpload): void {
|
||||
if (this.allKnown.has(slide.id)) return;
|
||||
this.allKnown.set(slide.id, slide);
|
||||
this.liveQueue.push(slide);
|
||||
/** True if this id is already in the known set — used to stop a catch-up scan once it
|
||||
* reaches territory we've already ingested. */
|
||||
has(id: string): boolean {
|
||||
return this.allKnown.has(id);
|
||||
}
|
||||
|
||||
/** Re-randomise the not-yet-shown shuffle pool. Called once after the initial paginated
|
||||
* load so the first cycle isn't just newest-first (merge appends in feed order). */
|
||||
reshuffle(): void {
|
||||
this.shuffleQueue = shuffle(this.shuffleQueue);
|
||||
}
|
||||
|
||||
/** Pop the next slide. Returns null while both queues are empty (event has no posts). */
|
||||
@@ -85,9 +106,21 @@ export class SlideQueue {
|
||||
}
|
||||
this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId);
|
||||
this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId);
|
||||
this.recentlyShown = this.recentlyShown.filter((sid) => this.allKnown.has(sid));
|
||||
return { wasCurrent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all currently-known ids. The page captures this BEFORE a reconcile scan,
|
||||
* then evicts only ids that were known before AND the server no longer lists — the
|
||||
* reconciliation half of `merge`. Snapshotting first means an upload that lands *during*
|
||||
* the scan (not in the snapshot) is never wrongly evicted, so a busy event doesn't
|
||||
* flicker freshly-added slides out and back in.
|
||||
*/
|
||||
knownIds(): string[] {
|
||||
return Array.from(this.allKnown.keys());
|
||||
}
|
||||
|
||||
/** Look up a slide by id — for the diashow page to render the current slide. */
|
||||
get(id: string): FeedUpload | undefined {
|
||||
return this.allKnown.get(id);
|
||||
|
||||
Reference in New Issue
Block a user