Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions OPN/GameServices/CatalogLaunchPrefetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Tests/Catalog/CatalogArtworkRungTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions View/Catalog/CatalogContentViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ struct CatalogContentView: View {
var seen = Set<String>()
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 }
Expand Down Expand Up @@ -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
Expand Down
16 changes: 6 additions & 10 deletions View/Catalog/CatalogHeroImageViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion View/Catalog/CatalogMediaViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion ViewModel/Catalog/CatalogGamePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion ViewModel/CatalogImagePrefetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down