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)
This commit is contained in:
Lee, Hyo jun
2026-10-15 14:32:48 +03:00
committed by Codex
parent 3b04c1c4dc
commit 42c84fdb2c
3 changed files with 36 additions and 7 deletions
+7 -4
View File
@@ -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
)
}
}
+26 -2
View File
@@ -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)
}
@@ -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