fix(showcase): reveal each tile only once its image is fully decoded
The buffered cascade revealed tiles on an 80ms timer regardless of image load, so the flip-in animation played on a gray placeholder and the thumbnail popped in afterward. Worse, MasonryGrid ALSO applied a per-index animation-delay (index×70ms) that compounded on top of the insert cadence, so the cascade visibly dragged and desynced as it grew. Now the producer preloads each queued thumbnail (decode pipelined ahead) and the consumer awaits that decode before pushing the item — every tile animates in fully loaded, strictly one at a time. Drop the compounding CSS stagger; the store's one-item-at-a-time push is the sole pacer, so each tile animates the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { preloadImage } from '../utils/preloadImage.js'
|
||||
|
||||
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
|
||||
// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
|
||||
@@ -9,15 +10,21 @@ import { useApi } from '../composables/useApi.js'
|
||||
// sometimes-cold blocks; RTT jitter) the animation starved and the view
|
||||
// "burped" out a clump of images. Now:
|
||||
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
|
||||
// target depth, refilling whenever the queue dips below BUFFER_MIN.
|
||||
// - CONSUMER (_drain): pops ONE item every CADENCE_MS, independent of when
|
||||
// fetches land. It only waits if the buffer genuinely starves.
|
||||
// As long as fetches keep up on average (a 3-row fetch is usually well under
|
||||
// the BUFFER_MIN×CADENCE runway), images appear at a perfectly even pace.
|
||||
// The very first image still waits on the first fetch (a cold TABLESAMPLE is a
|
||||
// separate, query-side concern); everything after it is buffer-smoothed.
|
||||
// target depth, refilling whenever the queue dips below BUFFER_MIN. It
|
||||
// also kicks off the image PRELOAD for each queued item so decoding
|
||||
// pipelines ahead of the reveal.
|
||||
// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
|
||||
// decoded, then reveals it — at most one per CADENCE_MS. Because the
|
||||
// producer preloads ahead, the decode-wait is usually already satisfied,
|
||||
// so the reveal stays evenly paced without idling the network.
|
||||
// The decode-gate is what makes the showcase's signature cascade land each
|
||||
// tile fully-loaded (the flip-in animates a real image, never a gray
|
||||
// placeholder); the single-item reveal keeps it strictly one-at-a-time
|
||||
// (operator-flagged 2026-06-04). The very first image still waits on the first
|
||||
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
|
||||
// everything after it is buffer-smoothed.
|
||||
const PAGE = 3
|
||||
const CADENCE_MS = 80 // ≈ the MasonryGrid 70ms stagger animation
|
||||
const CADENCE_MS = 80 // floor between fully-loaded reveals
|
||||
const PRIME = 6 // items buffered before the drain starts
|
||||
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
||||
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
|
||||
@@ -42,12 +49,21 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
||||
// Internal buffer (not reactive — the consumer is what feeds the UI via
|
||||
// images.value).
|
||||
let queue = []
|
||||
// id -> Promise that settles when the thumbnail is paint-ready. Started by
|
||||
// the producer so decoding runs ahead of the reveal; awaited by the consumer
|
||||
// so no tile is shown before its image is loaded.
|
||||
let _preloads = new Map()
|
||||
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
|
||||
// the drain bail instead of interleaving two runs into one images list.
|
||||
let _seq = 0
|
||||
let _filling = false
|
||||
let _draining = false
|
||||
|
||||
function _preload(item) {
|
||||
if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
|
||||
return _preloads.get(item.id)
|
||||
}
|
||||
|
||||
// One batch, retried while the random sample comes back all-duplicates.
|
||||
// Returns the fresh items, or null when the API is genuinely empty.
|
||||
async function _fetchFresh(mySeq) {
|
||||
@@ -79,6 +95,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
||||
if (mySeq !== _seq) return
|
||||
if (batch === null) { exhausted.value = true; return }
|
||||
queue.push(...batch)
|
||||
batch.forEach(_preload) // pipeline decoding ahead of the reveal
|
||||
}
|
||||
} finally {
|
||||
_filling = false
|
||||
@@ -104,7 +121,10 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
||||
}
|
||||
if (mySeq !== _seq) return
|
||||
if (queue.length === 0) return // exhausted + empty
|
||||
images.value.push(queue.shift())
|
||||
const item = queue.shift()
|
||||
await _preload(item) // reveal only once the thumbnail is fully decoded
|
||||
if (mySeq !== _seq) return
|
||||
images.value.push(item)
|
||||
await _sleep(CADENCE_MS)
|
||||
}
|
||||
} finally {
|
||||
@@ -120,6 +140,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
||||
const mySeq = _seq
|
||||
images.value = []
|
||||
queue = []
|
||||
_preloads = new Map()
|
||||
seen.clear()
|
||||
exhausted.value = false
|
||||
error.value = null
|
||||
|
||||
Reference in New Issue
Block a user