From 1efb351bbde93680a6577fa0ebc6c42c936bde66 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 5 Aug 2026 14:23:53 +0200 Subject: [PATCH 1/3] fix: thumbnail generation failures on missing poster images --- .../ios/StreamVideoThumbnailGenerator.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift index a92b880441..acd91e0c78 100644 --- a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift +++ b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift @@ -209,7 +209,16 @@ public final class StreamVideoThumbnailGenerator: NSObject { // serves a locally cached thumbnail even for iCloud only assets, so this // avoids downloading the entire video the way requestAVAsset(forVideo:) would. let options = PHImageRequestOptions() - options.deliveryMode = .fastFormat + // `.highQualityFormat` instead of `.fastFormat` here, as the latter only serves + // an already cached poster derivative and will NOT generate one on demand, so + // it fails (PHPhotosError 3303) for assets that have no cached thumbnail yet, + // i.e videos added to a Simulator via `simctl addmedia`, which never get + // their derivatives generated. `.highQualityFormat` still delivers a single + // final image (matching the "accept the first delivered image" logic below) + // and only fetches the poster frame, not the whole video. If a poster frame does + // not exist, it will generate one on the fly (only the first time, all subsequent + // request will return the cached one). + options.deliveryMode = .highQualityFormat options.resizeMode = .fast options.isNetworkAccessAllowed = true options.isSynchronous = false @@ -262,8 +271,8 @@ public final class StreamVideoThumbnailGenerator: NSObject { contentMode: .aspectFit, options: options ) { image, info in - // Accept the first delivered image (.fastFormat sends exactly one, - // thumbnail quality, possibly flagged degraded and that's what we want). + // Accept the first delivered image (.highQualityFormat sends exactly + // one and we still guard against any extra deliveries). state.lock.lock() if state.didResume { state.lock.unlock() From 40f3573900c2332b7b769fb7a8cbea11e7cc7461 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 5 Aug 2026 15:31:45 +0200 Subject: [PATCH 2/3] fix: settle on lower size --- .../ios/StreamVideoThumbnailGenerator.swift | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift index acd91e0c78..72fe0b73fe 100644 --- a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift +++ b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift @@ -22,7 +22,12 @@ public final class StreamVideoThumbnailResult: NSObject { @objcMembers public final class StreamVideoThumbnailGenerator: NSObject { private static let compressionQuality: CGFloat = 0.8 - private static let maxDimension: CGFloat = 512 + // iOS uses 256: its high-quality PhotoKit scaler stays crisp at the picker cell + // at that size, for ~1/4 the decoded RAM of 512. The Android counterpart + // (shared-native/android) intentionally keeps 512 — its `getScaledFrameAtTime` + // scaler is cruder and needs more source pixels. The per-platform values differ + // on purpose; don't "align" them. + private static let maxDimension: CGFloat = 256 private static let cacheVersion = "v1" private static let cacheDirectoryName = "@stream-io-stream-video-thumbnails" private static let maxConcurrentGenerations = 5 @@ -205,19 +210,13 @@ public final class StreamVideoThumbnailGenerator: NSObject { throw thumbnailError(code: 7, message: "Failed to find photo library asset for \(url)") } - // Request a thumbnail sized image instead of the full AVAsset: PhotoKit - // serves a locally cached thumbnail even for iCloud only assets, so this - // avoids downloading the entire video the way requestAVAsset(forVideo:) would. + // `.highQualityFormat` (not `.fastFormat`): fastFormat returns a fixed, small + // pre-cached rendition (~120px on the long side, ignores targetSize entirely) + // and returns nothing (PHPhotosError 3303) for assets with no cached + // derivative yet — e.g. videos added to a Simulator via `simctl addmedia`. + // highQualityFormat renders the poster on demand at the requested targetSize + // and only fetches the poster frame, not the whole video. let options = PHImageRequestOptions() - // `.highQualityFormat` instead of `.fastFormat` here, as the latter only serves - // an already cached poster derivative and will NOT generate one on demand, so - // it fails (PHPhotosError 3303) for assets that have no cached thumbnail yet, - // i.e videos added to a Simulator via `simctl addmedia`, which never get - // their derivatives generated. `.highQualityFormat` still delivers a single - // final image (matching the "accept the first delivered image" logic below) - // and only fetches the poster frame, not the whole video. If a poster frame does - // not exist, it will generate one on the fly (only the first time, all subsequent - // request will return the cached one). options.deliveryMode = .highQualityFormat options.resizeMode = .fast options.isNetworkAccessAllowed = true From 92e3f63a8ac2a094a72c138132a46d9f91127553 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 5 Aug 2026 16:19:12 +0200 Subject: [PATCH 3/3] chore: add comments --- .../ios/StreamVideoThumbnailGenerator.swift | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift index 72fe0b73fe..55e46db189 100644 --- a/package/shared-native/ios/StreamVideoThumbnailGenerator.swift +++ b/package/shared-native/ios/StreamVideoThumbnailGenerator.swift @@ -22,11 +22,10 @@ public final class StreamVideoThumbnailResult: NSObject { @objcMembers public final class StreamVideoThumbnailGenerator: NSObject { private static let compressionQuality: CGFloat = 0.8 - // iOS uses 256: its high-quality PhotoKit scaler stays crisp at the picker cell - // at that size, for ~1/4 the decoded RAM of 512. The Android counterpart - // (shared-native/android) intentionally keeps 512 — its `getScaledFrameAtTime` - // scaler is cruder and needs more source pixels. The per-platform values differ - // on purpose; don't "align" them. + // We intentionally use a lower maxDimension for iOS, as we are trying to mimic + // what a .fastFormat delivery type of the image would do. Although theoretically + // smaller (around 120px) after some benchmarking we figured that 256 was perfectly + // fine and adds just a tiny bit of overhead for a visible quality increase. private static let maxDimension: CGFloat = 256 private static let cacheVersion = "v1" private static let cacheDirectoryName = "@stream-io-stream-video-thumbnails" @@ -210,13 +209,19 @@ public final class StreamVideoThumbnailGenerator: NSObject { throw thumbnailError(code: 7, message: "Failed to find photo library asset for \(url)") } - // `.highQualityFormat` (not `.fastFormat`): fastFormat returns a fixed, small - // pre-cached rendition (~120px on the long side, ignores targetSize entirely) - // and returns nothing (PHPhotosError 3303) for assets with no cached - // derivative yet — e.g. videos added to a Simulator via `simctl addmedia`. - // highQualityFormat renders the poster on demand at the requested targetSize - // and only fetches the poster frame, not the whole video. + // Request a thumbnail sized image instead of the full AVAsset: PhotoKit + // serves a locally cached thumbnail even for iCloud only assets, so this + // avoids downloading the entire video the way requestAVAsset(forVideo:) would. let options = PHImageRequestOptions() + // `.highQualityFormat` instead of `.fastFormat` here, as the latter only serves + // an already cached poster derivative and will NOT generate one on demand, so + // it fails (PHPhotosError 3303) for assets that have no cached thumbnail yet, + // i.e videos added to a Simulator via `simctl addmedia`, which never get + // their derivatives generated. `.highQualityFormat` still delivers a single + // final image (matching the "accept the first delivered image" logic below) + // and only fetches the poster frame, not the whole video. If a poster frame does + // not exist, it will generate one on the fly (only the first time, all subsequent + // request will return the cached one). options.deliveryMode = .highQualityFormat options.resizeMode = .fast options.isNetworkAccessAllowed = true