diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0350149..9f392ee 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1569,7 +1569,17 @@ pub async fn catalog_artist_socials( // v4: the alpha ramp now scales to the measured ink distance, so every cached // mask predates the anti-aliasing this pipeline relies on and must be redrawn. -const COVER_LOGO_RECIPE: &str = "cover-logo-v4-ink-scaled-alpha"; +// v5: three changes, each of which can turn a previously-empty result into a +// mark (or trim one that over-reached), so every v4 entry is stale by +// construction — the alpha ramp's ceiling can no longer exceed the ink's own +// distance, the dominance guard admits flat multi-tone crops, and a row walk +// that never met a gap falls back to the OCR box instead of running to the crop +// edge. Wand had four covers printing its name and v4 extracted none of them. +// v10: the winner's supersample scale is fractional instead of a floored +// integer. Every mask whose crop was wider than half the target was previously +// supersampled by exactly 1x — i.e. not at all — so the covers this rewrites are +// the LARGE printed wordmarks, which is where the staircase was worst. +const COVER_LOGO_RECIPE: &str = "cover-logo-v10-supersample-2400-fractional"; // A prolific artist's wordmark is often nowhere near the top of this list. The // rank puts albums first and penalises `remix|mix|edit|live` titles — but for // Poolside (74 releases) every one of the SEVEN covers that OCR reads as @@ -1903,11 +1913,29 @@ async fn generate_cover_logo( const PROMINENCE_W: f32 = 3.0; const MIN_PROMINENCE: f32 = 0.05; const MIN_MARK_H: u32 = 74; + // Mask long edge the WINNER is re-cut at. Matching the tracer's own raster + // is the whole rationale: one good Lanczos enlargement in Rust instead of + // Lanczos followed by the browser's bilinear, with the trace then running at + // roughly the mask's native resolution. + // + // Swept against 1200/3600/4800 over eight artists (see + // `research/cover-logo-superres/prototypes/supersample-batch.mjs`), and 2400 + // is where the curve flattens. 1200 is below several crops and leaves them + // untouched. Past 2400 the mask overshoots the trace raster, so the browser + // DOWNSCALES what Rust just built — every shape measure is flat from there + // (Poolside, Wand, Camping In Alaska, Turnover all identical at 2400 and + // 3600) while the data URL doubles: Mildlife 137KB -> 267KB, Camping 76KB -> + // 152KB, for nothing. The one artist that still gains above 2400 is Hot + // Toddy, and it gains from the downscale itself rather than from the extra + // detail — the same smoothing the tracer's blur term would give it. + const MASK_SUPERSAMPLE_EDGE: u32 = 2400; let extract_started = std::time::Instant::now(); let (best, winner) = tokio::task::spawn_blocking(move || { use base64::Engine as _; use image::{GenericImageView as _, ImageEncoder as _}; - let mut best: Option<(f32, usize, String, image::RgbaImage)> = None; + // The winner's INPUTS, not its mask: the mask is re-cut once below at + // full supersample, so scoring never pays for it and never sees it. + let mut best: Option<(f32, CoverLogoCandidate)> = None; for candidate in candidates { let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0); if prom_w < MIN_PROMINENCE { @@ -1923,13 +1951,13 @@ async fn generate_cover_logo( hw as f32 / candidate.dims_1000.0 as f32, hh as f32 / candidate.dims_1000.1 as f32, ); - crate::cover_logo::extract_wordmark(&hi, &scaled) + crate::cover_logo::extract_wordmark(&hi, &scaled, 0) }) .or_else(|| { image::load_from_memory(candidate.bytes_1000.as_slice()) .ok() .and_then(|image| { - crate::cover_logo::extract_wordmark(&image, &candidate.loc) + crate::cover_logo::extract_wordmark(&image, &candidate.loc, 0) }) }); let Some(mark) = mark else { continue }; @@ -1937,14 +1965,46 @@ async fn generate_cover_logo( continue; } let final_score = mark.score + PROMINENCE_W * prom_w; - if best - .as_ref() - .is_none_or(|(score, _, _, _)| final_score > *score) - { - best = Some((final_score, candidate.rank, candidate.title, mark.image)); + if best.as_ref().is_none_or(|(score, _)| final_score > *score) { + best = Some((final_score, candidate)); } } - let Some((_, rank, title, image)) = best else { + let Some((_, winner)) = best else { + return (None, None); + }; + let (rank, title) = (winner.rank, winner.title.clone()); + // Re-cut the winning mark, this time resampling the crop up before the + // alpha ramp so the ramp reads interpolated colour and places the edge + // between source pixels rather than on them. Selection above ran at + // native scale on purpose — see `try_extract_wordmark`'s `target_edge`. + // Falls back to the selection-scale mask, so the richer pass failing can + // never cost an artist its logo. + let recut = winner + .bytes_hi + .as_ref() + .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) + .and_then(|hi| { + let (hw, hh) = hi.dimensions(); + let scaled = winner.loc.scaled( + hw as f32 / winner.dims_1000.0 as f32, + hh as f32 / winner.dims_1000.1 as f32, + ); + crate::cover_logo::extract_wordmark(&hi, &scaled, MASK_SUPERSAMPLE_EDGE) + .or_else(|| crate::cover_logo::extract_wordmark(&hi, &scaled, 0)) + }) + .or_else(|| { + image::load_from_memory(winner.bytes_1000.as_slice()) + .ok() + .and_then(|img| { + crate::cover_logo::extract_wordmark( + &img, + &winner.loc, + MASK_SUPERSAMPLE_EDGE, + ) + .or_else(|| crate::cover_logo::extract_wordmark(&img, &winner.loc, 0)) + }) + }); + let Some(image) = recut.map(|mark| mark.image) else { return (None, None); }; let (width, height) = image.dimensions(); @@ -2124,6 +2184,13 @@ pub struct CoverLogoDiag { pub mark_w: u32, pub mark_h: u32, pub extract_score: f32, + pub reject_reason: String, + /// The extracted mask itself, as a `data:image/png` URL, or empty when the + /// cover produced none. The numbers above say a mark was found and how big + /// it is; only the pixels say whether its EDGES are worth tracing, which is + /// the whole question when sweeping `target_edge`. Empty on the OCR-matrix + /// paths that never extract. + pub mark_png: String, pub winner: bool, pub ocr: Vec, } @@ -2134,12 +2201,21 @@ pub async fn catalog_cover_logo_debug( artist_id: Option, artist: String, covers: Vec, + target_edge: Option, ) -> Result, String> { use image::GenericImageView as _; + // Mask supersample target to segment at. 0/absent means native, which is + // what candidate SELECTION uses in production; a value here is for sweeping + // "does more supersampling help" without a rebuild per level. + let target_edge = target_edge.unwrap_or(0); // Download 1000px covers for OCR. let mut downloaded = Vec::new(); - for cover in covers.into_iter().take(14) { + // Must match COVER_LOGO_MAX_COVERS, not undercut it. At 14 this diagnostic + // silently hid exactly the covers the real pipeline relies on — Poolside's + // seven POOLSIDE-readable covers are all remix singles ranked past 14, so + // the tool reported "one match" for an artist production finds several on. + for cover in covers.into_iter().take(COVER_LOGO_MAX_COVERS) { if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await { downloaded.push((cover.title, cover.url, bytes)); } @@ -2202,6 +2278,8 @@ pub async fn catalog_cover_logo_debug( mark_w: 0, mark_h: 0, extract_score: 0.0, + reject_reason: String::new(), + mark_png: String::new(), winner: false, }; if let Some((loc, text, mscore)) = found { @@ -2240,10 +2318,30 @@ pub async fn catalog_cover_logo_debug( row.hi_w = hw; row.hi_h = hh; if let Ok(img_ex) = image::load_from_memory(&bytes_ex) { - if let Some(mark) = crate::cover_logo::extract_wordmark(&img_ex, &loc_ex) { - row.mark_w = mark.image.width(); - row.mark_h = mark.image.height(); - row.extract_score = mark.score; + match crate::cover_logo::try_extract_wordmark(&img_ex, &loc_ex, target_edge) { + Ok(mark) => { + use base64::Engine as _; + use image::ImageEncoder as _; + row.mark_w = mark.image.width(); + row.mark_h = mark.image.height(); + row.extract_score = mark.score; + let mut png = Vec::new(); + if image::codecs::png::PngEncoder::new(&mut png) + .write_image( + mark.image.as_raw(), + row.mark_w, + row.mark_h, + image::ExtendedColorType::Rgba8, + ) + .is_ok() + { + row.mark_png = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png) + ); + } + } + Err(reason) => row.reject_reason = reason.to_string(), } } } diff --git a/src-tauri/src/cover_logo.rs b/src-tauri/src/cover_logo.rs index cc56cd5..4168db5 100644 --- a/src-tauri/src/cover_logo.rs +++ b/src-tauri/src/cover_logo.rs @@ -33,6 +33,54 @@ pub(crate) struct OcrLine { pub words: Vec, } +impl Rect { + fn scaled(&self, sx: f32, sy: f32) -> Rect { + Rect { + x: self.x * sx, + y: self.y * sy, + w: self.w * sx, + h: self.h * sy, + } + } +} + +impl OcrLine { + /// Re-express an OCR result read at one resolution in another's pixels. + fn scaled(&self, sx: f32, sy: f32) -> OcrLine { + OcrLine { + text: self.text.clone(), + rect: self.rect.scaled(sx, sy), + words: self + .words + .iter() + .map(|word| OcrWord { + text: word.text.clone(), + rect: word.rect.scaled(sx, sy), + }) + .collect(), + } + } +} + +/// Cover long edge to retry OCR at when the native read comes back BLIND. +/// +/// Windows OCR is markedly scale-sensitive on large display type: Wand's +/// "Machine Man / M.E." prints WAND in warm grey across the bottom corner and +/// is read at 512px and at NO other size tested (1000/768/640, raw or +/// binarized). A very large mark can overflow what the recogniser will treat as +/// one line, so SHRINKING the cover is what brings it into range — the opposite +/// of the usual "upscale to read small text" instinct. +/// +/// Deliberately one scale, and deliberately gated on a blind read rather than +/// on "no match": every escalating cover pays another full raw+binarized pass, +/// and an artist with no wordmark anywhere OCRs all forty of them. Gating this +/// way kept a 21-artist regeneration at 1m47s where "no match" took ~6min, and +/// 768/640 were never observed to rescue a cover 512 did not. A read that +/// returned LINES has already answered the question — the cover has text and +/// the name is not in it — while zero lines means OCR simply failed, which is +/// the signature this exists to rescue. +const OCR_ESCALATION_SCALE: u32 = 512; + /// Lowercase alphanumerics only — matches the normalization used elsewhere so a /// cover's "CAMPING IN ALASKA" lines up with the artist name "Camping In Alaska". fn normalize(s: &str) -> String { @@ -50,11 +98,46 @@ const BG_MIN_DOMINANCE: f32 = 0.5; // background must fill at least this share o /// the guard for why its verdict is otherwise resolution-dependent. const DOMINANCE_REF_COVER: f32 = 1000.0; const ALPHA_LO: f32 = 46.0; // distance-from-bg below which a pixel is background (transparent) +/// …but never more than this share of the ink's own distance. +/// +/// The ramp's TOP already tracks the measured ink distance; its BOTTOM stayed a +/// fixed constant, and on a faint mark that constant eats the edge. Wand's +/// "Machine Man / M.E." prints at an ink distance of 97, so a floor of 46 +/// discards the outer HALF of every edge gradient — measured across one glyph +/// edge, the pixel carrying ~20% ink coverage (distance 19) is forced fully +/// transparent. The boundary then quantises to whole source pixels, and that +/// ±1px wobble is what becomes ±8px of staircase once the tracer enlarges a +/// small mask 8x. +/// +/// Scaling the floor keeps sub-pixel edge position, which is the thing the +/// tracer actually needs. High-contrast marks are untouched by construction: +/// white on black sits near 438, and 0.15 x 438 is well past 46, so the +/// constant still wins for anything with an ink distance above ~307. +const ALPHA_LO_INK_SHARE: f32 = 0.15; const ALPHA_HI: f32 = 120.0; // floor for the opaque end of the ramp (low-contrast marks) /// Share of the measured ink-vs-background distance the alpha ramp spans. The /// ramp's top tracks the real ink distance so anti-aliased edge pixels keep /// partial alpha instead of saturating; 0.55 puts the midpoint near the middle /// of a genuine edge gradient. +/// Ceiling on the post-ramp alpha normalisation below. +/// +/// A faint mark needs its glyph BODY opaque — every test here counts ink at +/// `alpha > 200`, and Wand's "Machine Man / M.E." (warm grey on white, ink +/// distance 90) tops out near 64% against a ramp ceiling of 120, so it measured +/// NINE ink pixels against 1590 partial and was thrown away as empty. +/// +/// The fix is NOT to narrow the ramp onto the ink. That window is what carries +/// the source's anti-aliasing, which is the very thing the tracer recovers +/// curves from — and on a grainy source it also sits closer to the paper noise, +/// so narrowing lets grain bite the glyph edges (measured: paper at distance 60 +/// reads alpha 74 through a 46-81 window against 20 through 46-120). The result +/// was a near-binary, noise-eaten mask that stair-steps under the 8x +/// enlargement the trace runs at. +/// +/// So the ramp keeps its full width and the finished alpha is RESCALED so the +/// body lands at 255. Shape is preserved, grain stays near zero, and the mark +/// saturates. Bounded so an almost-invisible mark cannot be amplified into one. +const ALPHA_BODY_MAX_GAIN: f32 = 4.0; const ALPHA_INK_SPAN: f32 = 0.55; fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { @@ -107,7 +190,29 @@ impl BandLocation { pub(crate) fn extract_wordmark( image: &image::DynamicImage, loc: &BandLocation, + target_edge: u32, ) -> Option { + try_extract_wordmark(image, loc, target_edge).ok() +} + +/// [`extract_wordmark`] with the reason it gave up. +/// +/// Every gate below has fired on a cover that visibly prints the artist name, +/// and "it returned nothing" is not a usable answer when one goes wrong — +/// diagnosing Wand cost several rebuilds and a JS replication of this function +/// that silently drifted from it. The debug command reports this string. +pub(crate) fn try_extract_wordmark( + image: &image::DynamicImage, + loc: &BandLocation, + // Mask long edge to resample the crop up to before the alpha ramp, or 0 + // to segment at native resolution. Candidate SELECTION must pass 0: the + // mark's height and its aa/coverage/bleed score are all measured on the + // mask, so supersampling during scoring silently re-measures every + // candidate and changes which COVER wins (Hot Toddy swapped a clean bold + // wordmark for a thin speckled one that way). Only the winner is worth + // resampling, and by then the comparison is already settled. + target_edge: u32, +) -> Result { use image::GenericImageView as _; let rect = loc.rect; let (iw, ih) = image.dimensions(); @@ -128,7 +233,7 @@ pub(crate) fn extract_wordmark( .min(loc.clamp_bottom) .min(ih as f32)) as u32; if x1 <= x0 + 4 || y1 <= y0 + 4 { - return None; + return Err("crop-too-small"); } let crop = image.crop_imm(x0, y0, x1 - x0, y1 - y0).to_rgba8(); let (w, h) = crop.dimensions(); @@ -187,7 +292,7 @@ pub(crate) fn extract_wordmark( .count() as f32; let dominance = near / total; if dominance < BG_MIN_DOMINANCE { - return None; // busy background — text over a photo, not segmentable + return Err("bg-photographic"); // busy background — text over a photo, not segmentable } // Scale the alpha ramp's top to the ACTUAL ink-vs-background distance. @@ -218,6 +323,68 @@ pub(crate) fn extract_wordmark( .unwrap_or(ALPHA_HI) }; let alpha_hi = ALPHA_HI.max(ALPHA_INK_SPAN * ink_distance); + // The ramp's bottom scales with the ink too — see [`ALPHA_LO_INK_SHARE`]. + let alpha_lo = ALPHA_LO.min(ink_distance * ALPHA_LO_INK_SHARE); + // Where the glyph body lands on that ramp, and the gain that puts it at + // full opacity — see [`ALPHA_BODY_MAX_GAIN`]. + let body_alpha = smoothstep(alpha_lo, alpha_hi, ink_distance); + let alpha_gain = if body_alpha > 0.01 { + (1.0 / body_alpha).min(ALPHA_BODY_MAX_GAIN) + } else { + 1.0 + }; + + // Supersample the CROP before the ramp, not the finished mask after it. + // + // The tracer enlarges whatever it gets to 2400px, so a small mark is already + // being upscaled — but upscaling the MASK interpolates between alphas that + // have already been quantised by the ramp, which cannot recover an edge + // position finer than one source pixel. Wand's WAND is ~35px tall in its + // 1500px master and the source edge spans a single pixel (measured: distance + // 19 → 78 → 97 across three pixels), so that quantisation is ±1 source pixel + // of boundary wobble, and the trace multiplies it by 8. + // + // Resampling first means the ramp evaluates smoothstep on INTERPOLATED + // colour, which places the edge between source pixels instead of on them. + // Lanczos rather than the tracer's bilinear, and only up to a target size — + // a mark that is already large gains nothing and would just cost memory. + // + // The scale is FRACTIONAL, and that is the whole point of the target. An + // integer factor is `floor(target / crop)`, which is 1 for every crop wider + // than half the target — so at target 2400 a crop of 1201px was supersampled + // by exactly nothing while a crop of 1200px got 2x. That cliff is not + // hypothetical and it does not land on marks that can spare the quality: a + // big printed wordmark on a 1500px master crops to 1200-1800px, so the + // artists it silenced were Hot Toddy (zig 0.312, a visible staircase around + // the O) and Crazy P (0.220) — the two worst edges measured across eight + // artists, both of which fall to ~0.00 the moment any supersampling happens. + // Raising the constant only moves the cliff to 1801px; scaling to the target + // removes it. + // + // The 8x ceiling now binds only below a ~300px crop, where the ramp has so + // few real source pixels to interpolate between that more of them stops + // paying (measured on Poolside, the smallest mark here, at 4x vs 8x). + let (crop, w, h, supersample) = { + let factor = if target_edge == 0 { + 1.0 + } else { + (target_edge as f32 / w.max(h) as f32).clamp(1.0, 8.0) + }; + if factor > 1.01 { + let (sw, sh) = ( + (w as f32 * factor).round().max(1.0) as u32, + (h as f32 * factor).round().max(1.0) as u32, + ); + ( + image::imageops::resize(&crop, sw, sh, image::imageops::FilterType::Lanczos3), + sw, + sh, + factor, + ) + } else { + (crop, w, h, 1.0) + } + }; // Alpha ramp by distance from the background colour, tracking per-row ink so // we can trim adjacent text lines the padding caught (the album title above / @@ -229,15 +396,16 @@ pub(crate) fn extract_wordmark( // walk sailed through the gap and swallowed the line above -- the mask read // "WITH / POOLSIDE". Restricted to the name's columns, that gap is empty // again and the walk stops where it should. - let name_left = (rect.x - x0 as f32).clamp(0.0, (w - 1) as f32) as u32; - let name_right = (rect.x + rect.w - x0 as f32).clamp(0.0, (w - 1) as f32) as u32; + let name_left = ((rect.x - x0 as f32) * supersample).clamp(0.0, (w - 1) as f32) as u32; + let name_right = + ((rect.x + rect.w - x0 as f32) * supersample).clamp(0.0, (w - 1) as f32) as u32; let mut full = image::RgbaImage::new(w, h); let mut row_ink = vec![0u32; h as usize]; for y in 0..h { for x in 0..w { let p = crop.get_pixel(x, y).0; let d = dist([p[0] as f32, p[1] as f32, p[2] as f32], bg); - let a = smoothstep(ALPHA_LO, alpha_hi, d); + let a = (smoothstep(alpha_lo, alpha_hi, d) * alpha_gain).min(1.0); let alpha = (a * 255.0).round() as u8; // Flat white ink (not the cover's own colour). The frontend logo pass // only traces the ALPHA silhouette and tints it to the accent, and it @@ -263,11 +431,12 @@ pub(crate) fn extract_wordmark( // A near-fully-inked row is a solid rule/bar touching the text with no gap — // also a boundary (glyph rows always leave inter-letter gaps, never ~full). let solid = |r: usize| row_ink[r] as f32 / name_span > 0.85; - let center = (((rect.y + rect.h / 2.0) as u32).saturating_sub(y0)).min(h - 1) as usize; + let center = + ((((rect.y + rect.h / 2.0 - y0 as f32).max(0.0)) * supersample) as u32).min(h - 1) as usize; // 2 transparent rows = a real gap (bridges a 1px anti-aliasing fluke). let gap_stop = 2usize; // Safety cap so a pathological run can't swallow the whole cover. - let max_half = (rect.h * 1.5).round().max(6.0) as usize; + let max_half = (rect.h * 1.5 * supersample).round().max(6.0) as usize; let lo = center.saturating_sub(max_half); let hi = (center + max_half).min(h as usize - 1); let mut top = center; @@ -316,7 +485,7 @@ pub(crate) fn extract_wordmark( let band_bottom = (bottom + pad).min(h as usize - 1) as u32; let band_h = band_bottom - band_top + 1; if band_h < 6 { - return None; + return Err("band-too-short"); } // COLUMN trim — the horizontal counterpart of the row walk above, which the @@ -363,7 +532,7 @@ pub(crate) fn extract_wordmark( let band_right = ((right + col_pad).min(w as usize - 1)) as u32; let band_w = band_right - band_left + 1; if band_w < 8 { - return None; + return Err("band-too-narrow"); } // Crop to the wordmark's row band and tally ink/partial for scoring. @@ -382,8 +551,11 @@ pub(crate) fn extract_wordmark( } } let coverage = ink as f32 / (band_w * band_h) as f32; - if coverage < 0.008 || coverage > 0.6 { - return None; + if coverage < 0.008 { + return Err("coverage-low"); + } + if coverage > 0.6 { + return Err("coverage-high"); } // Recipe D: median despeckle removes the ragged spurs a lo-fi print leaves on // the letter edges before the frontend supersamples/blurs/traces it. @@ -422,7 +594,7 @@ pub(crate) fn extract_wordmark( } let perimeter = (2 * (band_w + band_h)) as f32; let bleed = (edge_ink as f32 / perimeter).min(1.0); - Some(Wordmark { + Ok(Wordmark { image: out, score: aa + aspect + cover_score - bleed, }) @@ -604,7 +776,34 @@ pub(crate) fn locate_wordmark_named( return Ok(None); } // Two-pass OCR (raw + binarized) so stylized colour wordmarks are read. - let lines = ocr_lines_multi(image)?; + let mut lines = ocr_lines_multi(image)?; + // Retry a BLIND read once, smaller — see [`OCR_ESCALATION_SCALE`]. + if lines.is_empty() { + let (iw, ih) = { + use image::GenericImageView as _; + image.dimensions() + }; + if iw.max(ih) > OCR_ESCALATION_SCALE { + let small = image.resize( + OCR_ESCALATION_SCALE, + OCR_ESCALATION_SCALE, + image::imageops::FilterType::Lanczos3, + ); + if let Ok(found) = ocr_lines_multi(&small) { + // Back to source pixels: the clamp midpoints, the extraction + // crop and the hi-res rescale all work in the coordinates of + // the image that was passed in. + let (sw, sh) = { + use image::GenericImageView as _; + small.dimensions() + }; + lines = found + .into_iter() + .map(|line| line.scaled(iw as f32 / sw as f32, ih as f32 / sh as f32)) + .collect(); + } + } + } // Prefer a precise word-run match (the box is JUST the name); among those, // take the widest — a cover's big printed wordmark over a tiny corner label. diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 1d62cd1..9084393 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1561,6 +1561,24 @@ async function applyArtistIdentity(artistId, active) { }; catalog.artistPage = merged; if (merged.fused_record) void disownFusedPortrait(artistId, merged); + // The wordmark is lifted from THESE albums' covers, and this pass is + // fired beside the one above rather than after it — so on a fused record + // it normally wins the race and reads the unfiltered list. Wand's page + // wore a "wand." lifted from an Indonesian co-tenant's single that way. + // Re-run whenever ownership actually changed the covers on offer; the + // request is keyed by the exact list, so an unchanged one is a cache hit + // and costs nothing. + const coversChanged = + (current.albums ?? []).length !== merged.albums.length + || (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover); + if (coversChanged && !merged.logos?.length) { + void resolveCoverLogoAvailability( + artistId, + merged, + Promise.resolve(merged.logos ?? []), + active, + ); + } // Write the supplemented page back into the session cache: a warm // revisit must not render less than the cold visit did. Overwriting // a pending entry is safe — cachedArtistRequest only installs its