From 42c84fdb2caec4c5322d72837d0a5173ab086f5d Mon Sep 17 00:00:00 2001 From: "Lee, Hyo jun" Date: Thu, 15 Oct 2026 06:40:01 +0900 Subject: [PATCH] fix(mobile): render random widget images at the widget's display resolution (#32215) * fix(mobile): render random widget images at the widget's display resolution * fix(mobile): cap widget image decode size for panoramas (cherry picked from commit 3d07c061a38275f8b0d406e1ecc67446497be0e2) --- mobile/ios/WidgetExtension/ImageEntry.swift | 11 +++++--- mobile/ios/WidgetExtension/ImmichAPI.swift | 28 +++++++++++++++++-- .../widgets/RandomWidget.swift | 4 ++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index 58f89339a..4f7b36455 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -20,7 +20,8 @@ struct ImageEntry: TimelineEntry { asset: Asset, dateOffset: Int, subtitle: String? = nil, - forceFullColor: Bool = true + forceFullColor: Bool = true, + targetSize: CGSize? = nil ) async throws -> Self { @@ -29,7 +30,7 @@ struct ImageEntry: TimelineEntry { value: dateOffset * 20, to: Date.now )! - let image = try await api.fetchImage(asset: asset) + let image = try await api.fetchImage(asset: asset, targetSize: targetSize) return Self( date: entryDate, @@ -133,7 +134,8 @@ func generateRandomEntries( count: Int, filter: SearchFilter = Album.NONE.filter, subtitle: String? = nil, - forceFullColor: Bool = true + forceFullColor: Bool = true, + targetSize: CGSize? = nil ) async throws -> [ImageEntry] { @@ -150,7 +152,8 @@ func generateRandomEntries( asset: asset, dateOffset: dateOffset, subtitle: subtitle, - forceFullColor: forceFullColor + forceFullColor: forceFullColor, + targetSize: targetSize ) } } diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index 7e9dcd904..91beed92d 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -227,7 +227,7 @@ class ImmichAPI { return try JSONDecoder().decode([MemoryResult].self, from: data) } - func fetchImage(asset: Asset) async throws(FetchError) -> UIImage { + func fetchImage(asset: Asset, targetSize: CGSize? = nil) async throws(FetchError) -> UIImage { let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")] let assetEndpoint = "/assets/" + asset.id + "/thumbnail" @@ -245,10 +245,23 @@ class ImmichAPI { else { throw .invalidURL } + var maxPixelSize = 512 + // Decoding to fill the widget, a panorama's long edge can exceed the memory limit. + // To prevent this, cap the long side at 2x the widget. + let maxDecodeMultiplier = 2 + + if let targetSize, + let props = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int { + let scale = max(targetSize.width / CGFloat(width), targetSize.height / CGFloat(height)) + maxPixelSize = Int(ceil(CGFloat(max(width, height)) * scale)) + maxPixelSize = min(maxPixelSize, Int(max(targetSize.width, targetSize.height)) * maxDecodeMultiplier) + } let decodeOptions: [NSString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 512, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, kCGImageSourceCreateThumbnailWithTransform: true, ] @@ -262,6 +275,17 @@ class ImmichAPI { throw .fetchFailed } + if let targetSize { + let ratio = targetSize.width / targetSize.height + let cropWidth = min(CGFloat(thumbnail.width), CGFloat(thumbnail.height) * ratio) + let cropHeight = min(CGFloat(thumbnail.height), CGFloat(thumbnail.width) / ratio) + let x = (CGFloat(thumbnail.width) - cropWidth) / 2 + let y = (CGFloat(thumbnail.height) - cropHeight) / 2 + if let cropped = thumbnail.cropping(to: CGRect(x: x, y: y, width: cropWidth, height: cropHeight)) { + return UIImage(cgImage: cropped) + } + } + return UIImage(cgImage: thumbnail) } diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index d98746415..f7b508ba3 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -113,13 +113,15 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { // this must be a do/catch since we need to // distinguish between a network fail and an empty search do { + let targetSize = CGSize(width: context.displaySize.width * UIScreen.main.scale, height: context.displaySize.height * UIScreen.main.scale) let search = try await generateRandomEntries( api: api, now: now, count: 12, filter: album.filter, subtitle: configuration.showAlbumName ? albumName : nil, - forceFullColor: configuration.forceFullColor + forceFullColor: configuration.forceFullColor, + targetSize: targetSize ) // Load or save a cached asset for when network conditions are bad