From 69f64c11ebdcb1220f8d77e4750dda29cacecc8f Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:11:17 -0500 Subject: [PATCH] previews: the glow goes, the eq steps, and every clip lands before you scroll to it The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows. --- src-tauri/src/diagnostics.rs | 38 ++- src-tauri/src/lib.rs | 1 + src-tauri/src/preview.rs | 201 ++++++++++++---- src/App.svelte | 9 + src/components/DebugOverlay.svelte | 5 +- src/components/PreviewOverlay.svelte | 282 +++++++++++++++++------ src/lib/api.js | 4 + src/lib/preview/previewSession.svelte.js | 16 ++ 8 files changed, 440 insertions(+), 116 deletions(-) diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 2d799a0..2bd4039 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -61,7 +61,12 @@ pub struct DebugStats { pub cpu_process: f32, pub cpu_system: f32, /// GPU 3D-engine utilization (Task-Manager-style), if counters opened. + /// MACHINE-WIDE — includes DWM, browsers, Discord, everything. pub gpu_percent: Option, + /// Same counters, restricted to OUR process tree — the app's true GPU + /// share, matching Task Manager's per-process rows. The machine-wide + /// number above routinely reads 2× this on a busy desktop. + pub gpu_app_percent: Option, /// Process tree working set / private commit, bytes. pub mem_working_set: u64, pub mem_private: u64, @@ -159,10 +164,14 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS None => (None, None, None), }; + let tree_u32: Vec = tree.iter().map(|p| p.as_u32()).collect(); + let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32)); + DebugStats { cpu_process, cpu_system: inner.sys.global_cpu_usage(), - gpu_percent: inner.gpu.as_mut().and_then(|g| g.sample()), + gpu_percent: gpu_sample.map(|(sys, _)| sys), + gpu_app_percent: gpu_sample.map(|(_, app)| app), mem_working_set, mem_private, mem_system_used: inner.sys.used_memory(), @@ -257,7 +266,10 @@ impl GpuCounter { } } - fn sample(&mut self) -> Option { + /// Returns (machine_total, app_tree_total). Instance names carry the + /// owning pid — "pid_1234_luid_..._engtype_3D" — so the app's share is + /// the sum over instances whose pid is in our process tree. + fn sample(&mut self, app_pids: &[u32]) -> Option<(f32, f32)> { use windows::Win32::System::Performance::{ PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, @@ -292,14 +304,32 @@ impl GpuCounter { return None; } let mut total = 0.0f64; + let mut app = 0.0f64; for i in 0..count as usize { - total += (*items.add(i)).FmtValue.Anonymous.doubleValue; + let item = &*items.add(i); + let v = item.FmtValue.Anonymous.doubleValue; + total += v; + if !item.szName.is_null() { + if let Some(pid) = instance_pid(item.szName) { + if app_pids.contains(&pid) { + app += v; + } + } + } } - Some(total.min(100.0) as f32) + Some((total.min(100.0) as f32, app.min(100.0) as f32)) } } } +/// Parse the pid out of a "pid_1234_luid_…" counter-instance name. +unsafe fn instance_pid(name: windows::core::PWSTR) -> Option { + let s = name.to_string().ok()?; + let rest = s.strip_prefix("pid_")?; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + impl Drop for GpuCounter { fn drop(&mut self) { use windows::Win32::System::Performance::PdhCloseQuery; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2f63a2..ded732e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,6 +113,7 @@ pub fn run() { commands::preload_cached_stream, commands::stream_waveform, preview::preview_resolve, + preview::preview_prewarm, preview::preview_load, preview::preview_control, preview::preview_dispose, diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index dc2a1c5..2f88bf7 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -47,10 +47,28 @@ const SAFETY_MARGIN_MS: i64 = 20_000; const FALLBACK_SAFE_MS: i64 = 60_000; const MAX_SAFE_MS: i64 = 30 * 60_000; -/// Clip files older than this are swept on init and on each resolve — a -/// preview session never legitimately needs an hour-old clip. +/// Clip files older than this are swept on init and periodically from the +/// resolve path — a preview session never legitimately needs an hour-old clip. const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// The resolve-path sweep runs at most this often. Sweeping on every resolve +/// was a directory walk + stat per step of a scrolling session for files +/// that age out hourly. +const SWEEP_MIN_INTERVAL: Duration = Duration::from_secs(10 * 60); + +/// Pace between prewarm downloads. Rides ON TOP of the metadata crate's own +/// Deezer throttle; this keeps the walker from ever competing with a live +/// step's resolve for the gate. +const PREWARM_SPACING: Duration = Duration::from_millis(250); + +/// The walker starts after the session's opening resolve (and its ±1 +/// neighbour prefetches) have had the network to themselves. +const PREWARM_INITIAL_DELAY: Duration = Duration::from_millis(1500); + +/// Hard cap on one prewarm call, mirroring the frontend preloader's cap — a +/// 500-item manifest warms its head, not the whole tail. +const PREWARM_CAP: usize = 60; + /// Re-resolving a track whose clip bytes are already on disk skips the /// network entirely within this window (the bytes are immutable audio — only /// the signed URL goes stale, and a local file needs no URL). This is what @@ -96,6 +114,12 @@ pub struct PreviewState { recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, + /// Generation guard for the session prewarm walker: bumping it (a new + /// prewarm call, or dispose) halts any walker still in flight. + prewarm_gen: Arc, + /// The resolve-path clip sweep runs at most once per SWEEP_MIN_INTERVAL — + /// it used to walk the clip directory on EVERY resolve. + last_sweep: Mutex, } impl PreviewState { @@ -251,6 +275,8 @@ pub fn init(app: &tauri::AppHandle) { recent_clips: Mutex::new(HashMap::new()), clip_dir, lease_counter: AtomicU64::new(1), + prewarm_gen: Arc::new(AtomicU64::new(0)), + last_sweep: Mutex::new(Instant::now()), }); // One forwarder per voice. Only the ACTIVE voice's events reach the UI: @@ -425,25 +451,28 @@ fn measure_clip_gain(app: &tauri::AppHandle, path: &std::path::Path) -> f32 { } } -#[tauri::command] -pub async fn preview_resolve( - app: tauri::AppHandle, - library: State<'_, LibraryState>, - state: State<'_, PreviewState>, +/// A clip fetched, validated, stored on disk and loudness-measured — the +/// shared core of the live resolve and the session prewarm. Registers the +/// clip in `recent_clips`; minting a lease over it is the caller's business. +struct StoredClip { + path: PathBuf, + gain: f32, + byte_length: usize, + safe_for_ms: i64, +} + +async fn fetch_store_clip( + app: &tauri::AppHandle, + library: &LibraryState, + state: &PreviewState, track_id: u64, -) -> Result { - sweep_old_clips(&state.clip_dir); - - if let Some(cached) = mint_from_recent_clip(&state, track_id) { - return Ok(cached); - } - +) -> Result { let Some(body) = library.metadata.deezer_track_uncached(track_id).await else { - return Ok(fail("network", true)); + return Err(fail("network", true)); }; if let Some(err) = body.get("error") { let class = err.get("type").and_then(|t| t.as_str()).unwrap_or(""); - return Ok(match class { + return Err(match class { // Deezer's 200-with-error-body shapes, from Phase 0. "DataException" | "InvalidQueryException" => fail("not_found", false), _ => fail("temporarily_failed", true), @@ -455,16 +484,16 @@ pub async fn preview_resolve( .unwrap_or("") .to_string(); if preview_url.is_empty() { - return Ok(fail("not_provided", false)); + return Err(fail("not_provided", false)); } let media = match metadata::fetch_preview_media(&preview_url).await { Ok(m) => m, - Err(metadata::PreviewFetchError::Status(403)) => return Ok(fail("stale_lease", true)), - Err(metadata::PreviewFetchError::Status(_)) => return Ok(fail("temporarily_failed", true)), - Err(metadata::PreviewFetchError::DisallowedUrl) => return Ok(fail("invalid_media", false)), - Err(metadata::PreviewFetchError::TooLarge) => return Ok(fail("invalid_media", false)), - Err(metadata::PreviewFetchError::Network) => return Ok(fail("network", true)), + Err(metadata::PreviewFetchError::Status(403)) => return Err(fail("stale_lease", true)), + Err(metadata::PreviewFetchError::Status(_)) => return Err(fail("temporarily_failed", true)), + Err(metadata::PreviewFetchError::DisallowedUrl) => return Err(fail("invalid_media", false)), + Err(metadata::PreviewFetchError::TooLarge) => return Err(fail("invalid_media", false)), + Err(metadata::PreviewFetchError::Network) => return Err(fail("network", true)), }; // MIME gate first (the stale-lease body is an HTML error page), then the @@ -477,21 +506,21 @@ pub async fn preview_resolve( .as_deref() .is_some_and(|m| m.starts_with("audio/mpeg")); if !mime_ok { - return Ok(fail("invalid_media", false)); + return Err(fail("invalid_media", false)); } let audio = strip_id3v2(&media.bytes); if !starts_with_mpeg_sync(audio) { - return Ok(fail("invalid_media", false)); + return Err(fail("invalid_media", false)); } let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); - let lease_id = format!("pv-{track_id}-{nonce}"); - let path = state.clip_dir.join(format!("{lease_id}.mp3")); + let path = state.clip_dir.join(format!("pv-{track_id}-{nonce}.mp3")); if std::fs::write(&path, audio).is_err() { - return Ok(fail("temporarily_failed", true)); + return Err(fail("temporarily_failed", true)); } // Measure loudness off the async worker (a 30 s clip decodes in tens of - // ms). On a prefetched neighbour this is fully off the playback path. + // ms). On a prefetched or prewarmed clip this is fully off the playback + // path. let gain = { let app2 = app.clone(); let path2 = path.clone(); @@ -499,26 +528,61 @@ pub async fn preview_resolve( .await .unwrap_or(1.0) }; - state - .leases - .lock() - .expect("preview lease lock") - .insert(lease_id.clone(), (path.clone(), gain)); + let byte_length = audio.len(); state .recent_clips .lock() .expect("preview clip lock") - .insert(track_id, (path, gain, Instant::now())); + .insert(track_id, (path.clone(), gain, Instant::now())); - let safe_for_ms = - compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()); + Ok(StoredClip { + path, + gain, + byte_length, + safe_for_ms: compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()), + }) +} + +#[tauri::command] +pub async fn preview_resolve( + app: tauri::AppHandle, + library: State<'_, LibraryState>, + state: State<'_, PreviewState>, + track_id: u64, +) -> Result { + { + // Throttled sweep: a directory walk per resolve was pure waste in a + // scrolling session; hour-old files don't need minute-fresh reaping. + let mut last = state.last_sweep.lock().expect("preview sweep lock"); + if last.elapsed() > SWEEP_MIN_INTERVAL { + *last = Instant::now(); + sweep_old_clips(&state.clip_dir); + } + } + + if let Some(cached) = mint_from_recent_clip(&state, track_id) { + return Ok(cached); + } + + let stored = match fetch_store_clip(&app, &library, &state, track_id).await { + Ok(s) => s, + Err(failure) => return Ok(failure), + }; + + let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); + let lease_id = format!("pv-{track_id}-{nonce}"); + state + .leases + .lock() + .expect("preview lease lock") + .insert(lease_id.clone(), (stored.path, stored.gain)); Ok(PreviewResolveResult { ok: true, lease: Some(PreviewLeaseDto { opaque_lease_id: lease_id, - safe_for_ms, - byte_length: audio.len(), + safe_for_ms: stored.safe_for_ms, + byte_length: stored.byte_length, mime_type: "audio/mpeg".into(), clip_duration_ms: 30_000, }), @@ -527,6 +591,50 @@ pub async fn preview_resolve( }) } +/// Best-effort session prewarm: walk the manifest's Deezer track ids in +/// session order and pull every clip onto disk ahead of the user, so a step +/// landing ANYWHERE in the session resolves from `recent_clips` without +/// touching the network — the audio-side twin of the overlay's artwork +/// preloader (Kyle's call 2026-08-01: decode once at open, never during +/// scrolling). Paced gently (PREWARM_SPACING between clips, on top of the +/// metadata crate's own Deezer throttle), capped at PREWARM_CAP, and halted +/// by the generation guard the moment the session closes or a newer session +/// opens. Failures are skipped silently — the live resolve path still owns +/// error classification for anything the user actually lands on. +#[tauri::command] +pub async fn preview_prewarm( + app: tauri::AppHandle, + library: State<'_, LibraryState>, + state: State<'_, PreviewState>, + track_ids: Vec, +) -> Result<(), String> { + let my_gen = state.prewarm_gen.fetch_add(1, Ordering::Relaxed) + 1; + let gen_ctr = state.prewarm_gen.clone(); + let fresh = move || gen_ctr.load(Ordering::Relaxed) == my_gen; + + tokio::time::sleep(PREWARM_INITIAL_DELAY).await; + for track_id in track_ids.into_iter().take(PREWARM_CAP) { + if !fresh() { + return Ok(()); + } + let cached = { + let clips = state.recent_clips.lock().expect("preview clip lock"); + clips + .get(&track_id) + .is_some_and(|(_, _, at)| at.elapsed() < CLIP_REUSE_TTL) + }; + if cached { + continue; + } + let _ = fetch_store_clip(&app, &library, &state, track_id).await; + if !fresh() { + return Ok(()); + } + tokio::time::sleep(PREWARM_SPACING).await; + } + Ok(()) +} + // ------------------------------------------------------------ transport --- /// Load a resolved clip into the preview player. `start_paused` uses the @@ -664,6 +772,8 @@ pub fn preview_control( /// call repeatedly (close, navigation, hot reload all funnel here). #[tauri::command] pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { + // Halt any in-flight session prewarm walker before touching the caches. + state.prewarm_gen.fetch_add(1, Ordering::Relaxed); state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); @@ -672,11 +782,16 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { state.set_clip_gain(1, 1.0); state.set_vol(0, state.target()); state.set_vol(1, state.target()); - state - .recent_clips - .lock() - .expect("preview clip lock") - .clear(); + // Drain BOTH maps and delete the files each references — prewarmed clips + // exist only in recent_clips (no lease was ever minted over them), so + // clearing the map without deleting orphaned them until the hourly + // sweep. Shared paths get two remove attempts; the second is a no-op. + { + let mut clips = state.recent_clips.lock().expect("preview clip lock"); + for (_, (path, _gain, _at)) in clips.drain() { + let _ = std::fs::remove_file(path); + } + } let mut leases = state.leases.lock().expect("preview lease lock"); for (_, (path, _gain)) in leases.drain() { let _ = std::fs::remove_file(path); diff --git a/src/App.svelte b/src/App.svelte index ab4de62..d08ac28 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -110,6 +110,15 @@ pointer-events: none; } + /* NOTE (2026-08-01 perf audit): do NOT try to move the preview scrim's + backdrop blur onto these panels as `filter: blur(6px)`. It was tried: + filtered panels do not get cached render passes here (the content + pane's canvas/composited subtree defeats caching), so every presented + frame re-rastered five blurred panels — measured ~2× the cost of the + single backdrop-filter on the scrim. The cheap lever is the modal's + PRESENT RATE (see the eq animation note in PreviewOverlay.svelte), + not where the blur lives. */ + .app { display: grid; grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width); diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 912aa51..7b2c77c 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -135,7 +135,10 @@ {#if stats}
CPU app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%
-
GPU {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}
+ +
GPU {stats.gpuAppPercent != null ? `app ${stats.gpuAppPercent.toFixed(1)}%` : "app n/a"} · sys {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}%` : "n/a"} (3D)
RAM ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB
system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs
NET ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}
diff --git a/src/components/PreviewOverlay.svelte b/src/components/PreviewOverlay.svelte index ed931ec..b8192dc 100644 --- a/src/components/PreviewOverlay.svelte +++ b/src/components/PreviewOverlay.svelte @@ -85,21 +85,129 @@ }); // ----------------------------------------------------- backdrop slots -- - // Two crossfading bleed layers, exactly the mockup's setBackdrop(). The - // effect depends ONLY on the active artwork; the slot bookkeeping is read - // and written under untrack() so it can never invalidate the effect - // (a read-and-write of the same $state is a self-triggering loop). - let bleedA = $state(""); - let bleedB = $state(""); + // Two crossfading bleed layers, the mockup's setBackdrop() — but drawn + // as PRE-BLURRED tiny canvases rather than full-size imgs under a CSS + // blur(70px). A live 70px blur is a render pass over the whole modal; + // static it is cached, but through every step's 420ms crossfade BOTH + // slots were live and the compositor re-ran two full-size blurs per + // presented frame — the bulk of the stepping cost. The blur now happens + // once per artwork on a 96×58 canvas (4px there ≈ 70px at panel scale; + // the GPU's bilinear upscale supplies the rest of the softness), and the + // crossfade blends two small cached textures. Saturate/brightness are + // baked into the same draw. The effect depends ONLY on the active + // artwork; slot bookkeeping is read and written under untrack() so it + // can never invalidate the effect. + const BLEED_W = 96; + const BLEED_H = 58; // modal max aspect 1660×1000 + let bleedElA = $state(null); + let bleedElB = $state(null); + let bleedDrawnA = $state(false); + let bleedDrawnB = $state(false); let bleedSlot = $state(0); + let bleedShownArt = ""; + let bleedGen = 0; + // art URL → pre-filtered 96×58 offscreen canvas. Stepping back to a + // track becomes a tiny canvas blit instead of a full artwork re-decode — + // the decode was the dominant per-step cost of rapid scrolling. Tainted + // canvases (cross-origin art) blit fine; they just can't be read back. + const bleedCache = new Map(); + async function renderBleed(art) { + if (bleedCache.has(art)) return bleedCache.get(art); + const img = new Image(); + img.decoding = "async"; + img.src = art; + try { + await img.decode(); + } catch { + return null; // keep the previous wash rather than flash blank + } + const sw = img.naturalWidth; + const sh = img.naturalHeight; + if (!sw || !sh) return null; + const off = document.createElement("canvas"); + off.width = BLEED_W; + off.height = BLEED_H; + // Cover-crop the artwork to the canvas aspect (the old object-fit). + const scale = Math.max(BLEED_W / sw, BLEED_H / sh); + const w = BLEED_W / scale; + const h = BLEED_H / scale; + const ctx = off.getContext("2d"); + ctx.filter = "saturate(1.2) brightness(0.82) blur(4px)"; + ctx.drawImage(img, (sw - w) / 2, (sh - h) / 2, w, h, 0, 0, BLEED_W, BLEED_H); + bleedCache.set(art, off); + return off; + } + async function drawBleed(canvas, art) { + const off = await renderBleed(art); + if (!off) return false; + const ctx = canvas.getContext("2d"); + ctx.filter = "none"; + ctx.clearRect(0, 0, BLEED_W, BLEED_H); + ctx.drawImage(off, 0, 0); + return true; + } + // -------------------------------------------------------- preloader ---- + // Decode-once, at open: every step needs three things from the active + // artwork — the card's decoded image, the 96×58 wash, the accent pair — + // and none of them should be produced while the user is scrolling + // (per-step decodes were the whole cost of rapid stepping, measured + // 11.9% → 6.1% of a core just from caching). This walks the manifest + // ONCE when a session opens, nearest-to-active first, and fills every + // cache ahead of the user. One item at a time with a yield between, so + // the open animation and first clip resolve are never starved; ~20 + // items are fully warm within a couple of seconds. + $effect(() => { + const m = manifest; + if (!m || !previewUi.open) return; + const start = untrack(() => activeIndex ?? 0); + let cancelled = false; + const arts = []; + const seen = new Set(); + // nearest-first: start, start±1, start±2, … capped at 60 artworks + // so a 500-item manifest doesn't churn for minutes — beyond the cap + // the per-step lazy path (renderBleed's own cache) still applies. + const CAP = 60; + outer: for (let d = 0; d < m.items.length; d++) { + for (const idx of d === 0 ? [start] : [start + d, start - d]) { + const art = m.items[idx]?.artwork; + if (art && !seen.has(art)) { + seen.add(art); + arts.push(art); + if (arts.length >= CAP) break outer; + } + } + } + (async () => { + for (const art of arts) { + if (cancelled) return; + await renderBleed(art); // decodes the image, caches the wash + if (cancelled) return; + if (!accentCache.has(art)) { + const c = await accentFromImage(art); + if (c) accentCache.set(art, c); + } + // Yield so the warm-up never competes with a live step. + await new Promise((r) => setTimeout(r, 60)); + } + })(); + return () => (cancelled = true); + }); + $effect(() => { const art = activeItem?.artwork ?? ""; untrack(() => { - const showing = bleedSlot === 0 ? bleedB : bleedA; - if (art === showing) return; - if (bleedSlot === 0) bleedA = art; - else bleedB = art; - bleedSlot = bleedSlot === 0 ? 1 : 0; + if (!art || art === bleedShownArt) return; + const gen = ++bleedGen; + // slot 0 shows B, so A is hidden and takes the incoming draw. + const target = bleedSlot === 0 ? bleedElA : bleedElB; + if (!target) return; + drawBleed(target, art).then((ok) => { + if (!ok || gen !== bleedGen) return; + bleedShownArt = art; + if (bleedSlot === 0) bleedDrawnA = true; + else bleedDrawnB = true; + bleedSlot = bleedSlot === 0 ? 1 : 0; + }); }); }); @@ -244,6 +352,11 @@ let announce = $state(""); // ------------------------------------------------------- direction ----- + // Steps keep their full animations at EVERY rate (a jump-cut fast path + // was tried 2026-08-01 and rejected on sight). What makes rapid + // stepping affordable instead is the session preloader below: by the + // time a step lands, its wash, accent and artwork are already decoded, + // so the step's cost is the (compositor-cheap) transitions themselves. function step(dir) { stepDir = dir; previewCommands.step(dir); @@ -346,6 +459,19 @@ const MAX_AMP = 15; let waveCanvas = $state(null); let waveWrap = $state(null); + // Width comes from a ResizeObserver, not a per-draw clientWidth read — + // position events arrive 10×/s and each read was a forced layout. + let waveW = $state(0); + $effect(() => { + if (!waveWrap) return; + waveW = waveWrap.clientWidth; + const ro = new ResizeObserver((entries) => { + const w = entries[entries.length - 1]?.contentRect.width; + if (w != null) waveW = Math.round(w); + }); + ro.observe(waveWrap); + return () => ro.disconnect(); + }); const peaksCache = new Map(); function peaksFor(key) { if (peaksCache.has(key)) return peaksCache.get(key); @@ -365,13 +491,14 @@ void st?.positionMs; void activeItem?.sessionKey; void pvAccent; + void waveW; drawWave(); }); function drawWave() { if (!waveCanvas || !waveWrap || !activeItem) return; const dpr = window.devicePixelRatio || 1; - const W = waveWrap.clientWidth; + const W = waveW; if (W <= 0) return; const pw = Math.max(1, Math.round(W * dpr)); const ph = Math.max(1, Math.round(WAVE_H * dpr)); @@ -489,7 +616,6 @@ >