diff --git a/OPN/GameServices/CatalogLaunchPrefetch.swift b/OPN/GameServices/CatalogLaunchPrefetch.swift index 5a2e143a..2aff4ea3 100644 --- a/OPN/GameServices/CatalogLaunchPrefetch.swift +++ b/OPN/GameServices/CatalogLaunchPrefetch.swift @@ -304,12 +304,12 @@ final class CatalogLaunchPrefetch { // during the current slide's five seconds, so decoding the rest now would only move // work into the launch spike. for game in games.prefix(1) { - append(game.bestMarqueeHeroImageURL, width: 1920, into: &urls, seen: &seen) + append(game.bestMarqueeHeroImageURL, width: CatalogMarqueeArtwork.requestWidth, into: &urls, seen: &seen) append(game.bestLogoImageURL, width: 620, into: &urls, seen: &seen) } // Retains the compressed bytes: the hero reads its scrim colour out of them, so an // entry without them is a miss and a second decode of the largest artwork in the app. - imageCache.prefetchPriority(urls, maxPixelSize: 1920, retainingSourceData: true) + imageCache.prefetchPriority(urls, maxPixelSize: CatalogMarqueeArtwork.decodePixelSize, retainingSourceData: true) case .main: guard !didPrefetchRailImages else { return } didPrefetchRailImages = true diff --git a/Tests/Catalog/CatalogArtworkRungTests.swift b/Tests/Catalog/CatalogArtworkRungTests.swift index 80ab2ed1..c1af5928 100644 --- a/Tests/Catalog/CatalogArtworkRungTests.swift +++ b/Tests/Catalog/CatalogArtworkRungTests.swift @@ -29,4 +29,13 @@ struct CatalogArtworkRungTests { #expect(CatalogStoreIconArtwork.decodeRung(scale: 1, displayScale: 2) == 84) #expect(CatalogStoreIconArtwork.decodeRung(scale: 1.5, displayScale: 2) == 126) } + + /// The marquee hero is the one asset whose CDN width and decode rung are both baked into a load + /// key that carries the rung: the launch prefetch and the rotation prewarm warm one entry, and a + /// view reading at any other rung misses it and decodes the largest artwork on the home screen + /// again. The view used to take the shared 3840 default while both warmers decoded 1920. + @Test func theMarqueeHeroDecodeRungIsTheWidthItsURLIsRequestedAt() { + #expect(CatalogMarqueeArtwork.decodePixelSize == CGFloat(CatalogMarqueeArtwork.requestWidth)) + #expect(CatalogMarqueeArtwork.decodePixelSize == 1920) + } } diff --git a/View/Catalog/CatalogContentViews.swift b/View/Catalog/CatalogContentViews.swift index 86602942..0f0b4a40 100644 --- a/View/Catalog/CatalogContentViews.swift +++ b/View/Catalog/CatalogContentViews.swift @@ -260,7 +260,7 @@ struct CatalogContentView: View { var seen = Set() for offset in 1...min(2, games.count - 1) { let game = games[(index + offset) % games.count] - appendHeroPrefetchURL(game.bestMarqueeHeroImageURL, width: 1920, urls: &heroURLs, seen: &seen) + appendHeroPrefetchURL(game.bestMarqueeHeroImageURL, width: CatalogMarqueeArtwork.requestWidth, urls: &heroURLs, seen: &seen) appendHeroPrefetchURL(game.bestLogoImageURL, width: CatalogLogoArtwork.requestWidth, urls: &wordmarkURLs, seen: &seen) } guard !heroURLs.isEmpty || !wordmarkURLs.isEmpty else { return } @@ -379,7 +379,7 @@ struct CatalogHeroView: View { ZStack(alignment: .bottom) { CatalogHeroVendorBackgroundScrim(color: scrimColor) CatalogHeroRemoteImage( - url: viewModel.optimizedImageURL(game.bestMarqueeHeroImageURL, width: 1920), + url: viewModel.optimizedImageURL(game.bestMarqueeHeroImageURL, width: CatalogMarqueeArtwork.requestWidth), contentMode: .fill, maxPixelSize: CatalogMarqueeArtwork.decodePixelSize ) { color in diff --git a/View/Catalog/CatalogHeroImageViews.swift b/View/Catalog/CatalogHeroImageViews.swift index 97b76a5b..0ef07f4f 100644 --- a/View/Catalog/CatalogHeroImageViews.swift +++ b/View/Catalog/CatalogHeroImageViews.swift @@ -5,20 +5,13 @@ import CryptoKit import ImageIO import SwiftUI -/// The marquee banner is one asset with one rung. The URL is requested at 1920px and both warmers -/// (`CatalogLaunchPrefetch`, `CatalogImagePrefetch.prewarmHeroRotation`) decode it at 1920, and the -/// memory cache is keyed by URL rather than by rung, so the view asks for 1920 as well. It used to -/// take the shared 3840 default - four times the pixels the prefetch had already decoded, or a -/// duplicate full-size decode when the view won the race. W3-3 tracks the cache side of the same -/// mismatch. -enum CatalogMarqueeArtwork { - static let decodePixelSize: CGFloat = 1920 -} - struct CatalogHeroRemoteImage: View { let imageCache: any CatalogImageServing = CatalogImageCache.shared let url: URL? let contentMode: ContentMode + /// Required rather than defaulted, and passed from `CatalogMarqueeArtwork`: this view used to + /// inherit the cache's 3840 default, so it and the launch prefetch disagreed about the rung for + /// the same URL and whichever ran first decided the hero's decode for the session. let maxPixelSize: CGFloat let onScrimColorChange: (CatalogMarqueeScrimColor) -> Void @@ -56,6 +49,9 @@ struct CatalogHeroRemoteImage: View { // that needs the compressed bytes kept alongside the decoded image. if let cached = await imageCache.firstFrameImage(for: url, maxPixelSize: maxPixelSize, retainingSourceData: true) { guard !Task.isCancelled else { return } + // The decoded size is the thing this fix is about: a hero rung that disagrees with the + // prefetch's shows up here as a second, larger decode of the same URL at launch. + OPNLog.info(.cache, "Catalog hero artwork url=\(url.absoluteString) rung=\(Int(maxPixelSize)) decodedBytes=\(cached.decodedByteCount)") image = cached.image hasFailed = false isLoading = false diff --git a/View/Catalog/CatalogMediaViews.swift b/View/Catalog/CatalogMediaViews.swift index a8159ff4..9454c491 100644 --- a/View/Catalog/CatalogMediaViews.swift +++ b/View/Catalog/CatalogMediaViews.swift @@ -363,7 +363,6 @@ enum CatalogArtworkResolution { /// decides their raster size. The memory cache is keyed by URL rather than by rung, and one icon is /// drawn at 14-20pt in the store picker and at 42pt in Settings, so every surface asks for the /// largest surface's rung: a per-surface rung would let whichever decoded first decide for the rest. -/// W3-3 tracks making the cache rung-aware, which would let the small ones ask for less. enum CatalogStoreIconArtwork { static let maximumPointSize: CGFloat = 42 diff --git a/ViewModel/Catalog/CatalogGamePresentation.swift b/ViewModel/Catalog/CatalogGamePresentation.swift index 93b114b1..929091e9 100644 --- a/ViewModel/Catalog/CatalogGamePresentation.swift +++ b/ViewModel/Catalog/CatalogGamePresentation.swift @@ -29,11 +29,21 @@ enum CatalogArtworkType { /// The same reasoning fixes the decode rung, which is why every logo surface decodes at this width /// rather than at its own drawn size: the memory cache is keyed by URL, not by rung, so a 160pt /// overlay asking for less than the 390pt hero title would hand the hero whichever decoded first. -/// W3-3 tracks making the cache rung-aware, which would let the small surfaces ask for less. enum CatalogLogoArtwork { static let requestWidth = 620 } +/// The marquee hero band is one asset with one rung: the width baked into its CDN URL and the +/// `maxPixelSize` its decode is asked for are the same number, and they have to be. The memory cache +/// is keyed by URL but the load key carries the rung, so a view decoding at any other rung misses +/// the entry the launch prefetch and the rotation prewarm warmed and the largest artwork on the home +/// screen is fetched and decoded again during launch. The view used to take the shared 3840 default +/// while both warmers decoded 1920. +enum CatalogMarqueeArtwork { + static let requestWidth = 1920 + static let decodePixelSize: CGFloat = CGFloat(requestWidth) +} + extension OPNCatalogGameObject { var cardBadgeLabel: String? { if isLaunchPatching { return patchStatusPrimaryDisplayText } diff --git a/ViewModel/CatalogImagePrefetch.swift b/ViewModel/CatalogImagePrefetch.swift index 59b2a251..75a03190 100644 --- a/ViewModel/CatalogImagePrefetch.swift +++ b/ViewModel/CatalogImagePrefetch.swift @@ -55,7 +55,7 @@ extension CatalogViewModel { /// source bytes the hero band reads them; wordmarks do not need the bytes. Deferred rather than /// priority: these are not on the first frame, so they decode one at a time behind it. func prewarmHeroRotation(heroURLs: [URL], wordmarkURLs: [URL]) { - imageCache.prewarmDeferred(heroURLs, maxPixelSize: 1920, retainingSourceData: true) + imageCache.prewarmDeferred(heroURLs, maxPixelSize: CatalogMarqueeArtwork.decodePixelSize, retainingSourceData: true) imageCache.prewarmDeferred(wordmarkURLs, maxPixelSize: CGFloat(CatalogLogoArtwork.requestWidth), retainingSourceData: false) }