@@ -2,6 +2,12 @@ import AVFoundation
22import Photos
33import UIKit
44
5+ private final class StreamPhotoLibraryAssetRequestState : @unchecked Sendable {
6+ let lock = NSLock ( )
7+ var didResume = false
8+ var requestID : PHImageRequestID = PHInvalidImageRequestID
9+ }
10+
511@objcMembers
612public final class StreamVideoThumbnailResult : NSObject {
713 public let error : String ?
@@ -20,49 +26,70 @@ public final class StreamVideoThumbnailGenerator: NSObject {
2026 private static let cacheVersion = " v1 "
2127 private static let cacheDirectoryName = " @stream-io-stream-video-thumbnails "
2228 private static let maxConcurrentGenerations = 5
29+ private static let photoLibraryAssetResolutionTimeout : TimeInterval = 3
30+
31+ @objc ( generateThumbnailsWithUrls: completion: )
32+ public static func generateThumbnails(
33+ urls: [ String ] ,
34+ completion: @escaping ( [ StreamVideoThumbnailResult ] ) -> Void
35+ ) {
36+ Task ( priority: . userInitiated) {
37+ completion ( await generateThumbnailsAsync ( urls: urls) )
38+ }
39+ }
2340
24- @objc ( generateThumbnailsWithUrls: )
25- public static func generateThumbnails( urls: [ String ] ) -> [ StreamVideoThumbnailResult ] {
26- if urls. count <= 1 {
27- return urls. map { url in
28- generateThumbnailResult ( url: url)
29- }
41+ private static func generateThumbnailsAsync( urls: [ String ] ) async -> [ StreamVideoThumbnailResult ] {
42+ guard !urls. isEmpty else {
43+ return [ ]
3044 }
3145
32- var thumbnails = Array < StreamVideoThumbnailResult ? > ( repeating: nil , count: urls. count)
33- let lock = NSLock ( )
34- let group = DispatchGroup ( )
35- let semaphore = DispatchSemaphore ( value: min ( maxConcurrentGenerations, urls. count) )
46+ if urls. count == 1 {
47+ return [ await generateThumbnailResult ( url: urls [ 0 ] ) ]
48+ }
49+
50+ let parallelism = min ( maxConcurrentGenerations, urls. count)
51+
52+ return await withTaskGroup (
53+ of: ( Int, StreamVideoThumbnailResult) . self,
54+ returning: [ StreamVideoThumbnailResult ] . self
55+ ) { group in
56+ var thumbnails = Array < StreamVideoThumbnailResult ? > ( repeating: nil , count: urls. count)
57+ var nextIndexToSchedule = 0
3658
37- for (index, url) in urls. enumerated ( ) {
38- group. enter ( )
39- DispatchQueue . global ( qos: . userInitiated) . async {
40- semaphore. wait ( )
41- defer {
42- semaphore. signal ( )
43- group. leave ( )
59+ while nextIndexToSchedule < parallelism {
60+ let index = nextIndexToSchedule
61+ let url = urls [ index]
62+ group. addTask {
63+ ( index, await generateThumbnailResult ( url: url) )
4464 }
65+ nextIndexToSchedule += 1
66+ }
4567
46- let thumbnail = generateThumbnailResult ( url: url)
47- lock. lock ( )
68+ while let ( index, thumbnail) = await group. next ( ) {
4869 thumbnails [ index] = thumbnail
49- lock. unlock ( )
50- }
51- }
5270
53- group. wait ( )
71+ if nextIndexToSchedule < urls. count {
72+ let nextIndex = nextIndexToSchedule
73+ let nextURL = urls [ nextIndex]
74+ group. addTask {
75+ ( nextIndex, await generateThumbnailResult ( url: nextURL) )
76+ }
77+ nextIndexToSchedule += 1
78+ }
79+ }
5480
55- return thumbnails. enumerated ( ) . map { index, thumbnail in
56- thumbnail ?? StreamVideoThumbnailResult (
57- error: " Thumbnail generation produced no output for index \( index) " ,
58- uri: nil
59- )
81+ return thumbnails. enumerated ( ) . map { index, thumbnail in
82+ thumbnail ?? StreamVideoThumbnailResult (
83+ error: " Thumbnail generation produced no output for index \( index) " ,
84+ uri: nil
85+ )
86+ }
6087 }
6188 }
6289
63- private static func generateThumbnailResult( url: String ) -> StreamVideoThumbnailResult {
90+ private static func generateThumbnailResult( url: String ) async -> StreamVideoThumbnailResult {
6491 do {
65- return StreamVideoThumbnailResult ( uri: try generateThumbnail ( url: url) )
92+ return StreamVideoThumbnailResult ( uri: try await generateThumbnail ( url: url) )
6693 } catch {
6794 return StreamVideoThumbnailResult (
6895 error: error. localizedDescription,
@@ -71,7 +98,7 @@ public final class StreamVideoThumbnailGenerator: NSObject {
7198 }
7299 }
73100
74- private static func generateThumbnail( url: String ) throws -> String {
101+ private static func generateThumbnail( url: String ) async throws -> String {
75102 let outputDirectory = try thumbnailCacheDirectory ( )
76103 let outputURL = outputDirectory
77104 . appendingPathComponent ( buildCacheFileName ( url: url) )
@@ -86,14 +113,7 @@ public final class StreamVideoThumbnailGenerator: NSObject {
86113 return outputURL. absoluteString
87114 }
88115
89- guard let asset = resolveAsset ( url: url) else {
90- throw NSError (
91- domain: " StreamVideoThumbnail " ,
92- code: 1 ,
93- userInfo: [ NSLocalizedDescriptionKey: " Failed to resolve video asset for \( url) " ]
94- )
95- }
96-
116+ let asset = try await resolveAsset ( url: url)
97117 let generator = AVAssetImageGenerator ( asset: asset)
98118 generator. appliesPreferredTrackTransform = true
99119 generator. maximumSize = CGSize ( width: maxDimension, height: maxDimension)
@@ -104,23 +124,13 @@ public final class StreamVideoThumbnailGenerator: NSObject {
104124 let cgImage = try generator. copyCGImage ( at: requestedTime, actualTime: nil )
105125 let image = UIImage ( cgImage: cgImage)
106126 guard let data = image. jpegData ( compressionQuality: compressionQuality) else {
107- throw NSError (
108- domain: " StreamVideoThumbnail " ,
109- code: 2 ,
110- userInfo: [ NSLocalizedDescriptionKey: " Failed to encode JPEG thumbnail for \( url) " ]
111- )
127+ throw thumbnailError ( code: 2 , message: " Failed to encode JPEG thumbnail for \( url) " )
112128 }
113129
114130 try data. write ( to: outputURL, options: . atomic)
115131 return outputURL. absoluteString
116132 } catch {
117- throw NSError (
118- domain: " StreamVideoThumbnail " ,
119- code: 3 ,
120- userInfo: [
121- NSLocalizedDescriptionKey: " Thumbnail generation failed for \( url) : \( error. localizedDescription) " ,
122- ]
123- )
133+ throw thumbnailError ( error, code: 3 , message: " Thumbnail generation failed for \( url) " )
124134 }
125135 }
126136
@@ -160,49 +170,130 @@ public final class StreamVideoThumbnailGenerator: NSObject {
160170 return . zero
161171 }
162172
163- private static func resolveAsset( url: String ) -> AVAsset ? {
173+ private static func resolveAsset( url: String ) async throws -> AVAsset {
164174 if isPhotoLibraryURL ( url) {
165- return resolvePhotoLibraryAsset ( url: url)
175+ return try await resolvePhotoLibraryAsset ( url: url)
166176 }
167177
168178 if let normalizedURL = normalizeLocalURL ( url) {
169179 return AVURLAsset ( url: normalizedURL)
170180 }
171181
172- return nil
182+ throw thumbnailError ( code : 5 , message : " Unsupported video URL for thumbnail generation: \( url ) " )
173183 }
174184
175185 private static func isPhotoLibraryURL( _ url: String ) -> Bool {
176186 url. lowercased ( ) . hasPrefix ( " ph:// " )
177187 }
178188
179- private static func resolvePhotoLibraryAsset( url: String ) -> AVAsset ? {
189+ private static func resolvePhotoLibraryAsset( url: String ) async throws -> AVAsset {
180190 let identifier = photoLibraryIdentifier ( from: url)
181191
182192 guard !identifier. isEmpty else {
183- return nil
193+ throw thumbnailError ( code : 6 , message : " Missing photo library identifier for \( url ) " )
184194 }
185195
186196 let fetchResult = PHAsset . fetchAssets ( withLocalIdentifiers: [ identifier] , options: nil )
187197 guard let asset = fetchResult. firstObject else {
188- return nil
198+ throw thumbnailError ( code : 7 , message : " Failed to find photo library asset for \( url ) " )
189199 }
190200
191201 let options = PHVideoRequestOptions ( )
192202 options. deliveryMode = . highQualityFormat
193203 options. isNetworkAccessAllowed = true
194204 options. version = . current
195205
196- let semaphore = DispatchSemaphore ( value: 0 )
197- var resolvedAsset : AVAsset ?
206+ return try await withThrowingTaskGroup ( of: AVAsset . self) { group in
207+ group. addTask {
208+ try await requestPhotoLibraryAsset ( url: url, asset: asset, options: options)
209+ }
210+ group. addTask {
211+ try await Task . sleep ( nanoseconds: UInt64 ( photoLibraryAssetResolutionTimeout * 1_000_000_000 ) )
212+ throw thumbnailError (
213+ code: 11 ,
214+ message: " Timed out resolving photo library asset for \( url) "
215+ )
216+ }
217+
218+ guard let resolvedAsset = try await group. next ( ) else {
219+ throw thumbnailError (
220+ code: 10 ,
221+ message: " Failed to resolve photo library asset for \( url) "
222+ )
223+ }
198224
199- PHImageManager . default ( ) . requestAVAsset ( forVideo: asset, options: options) { avAsset, _, _ in
200- resolvedAsset = avAsset
201- semaphore. signal ( )
225+ group. cancelAll ( )
226+ return resolvedAsset
202227 }
228+ }
229+
230+ private static func requestPhotoLibraryAsset(
231+ url: String ,
232+ asset: PHAsset ,
233+ options: PHVideoRequestOptions
234+ ) async throws -> AVAsset {
235+ let imageManager = PHImageManager . default ( )
236+ let state = StreamPhotoLibraryAssetRequestState ( )
237+
238+ return try await withTaskCancellationHandler ( operation: {
239+ try await withCheckedThrowingContinuation { continuation in
240+ let requestID = imageManager. requestAVAsset ( forVideo: asset, options: options) {
241+ avAsset, _, info in
242+ state. lock. lock ( )
243+ if state. didResume {
244+ state. lock. unlock ( )
245+ return
246+ }
247+ state. didResume = true
248+ state. lock. unlock ( )
249+
250+ if let isCancelled = ( info ? [ PHImageCancelledKey] as? NSNumber ) ? . boolValue, isCancelled {
251+ continuation. resume (
252+ throwing: thumbnailError (
253+ code: 8 ,
254+ message: " Photo library asset request was cancelled for \( url) "
255+ )
256+ )
257+ return
258+ }
259+
260+ if let error = info ? [ PHImageErrorKey] as? Error {
261+ continuation. resume (
262+ throwing: thumbnailError (
263+ error,
264+ code: 9 ,
265+ message: " Photo library asset request failed for \( url) "
266+ )
267+ )
268+ return
269+ }
270+
271+ guard let avAsset else {
272+ continuation. resume (
273+ throwing: thumbnailError (
274+ code: 10 ,
275+ message: " Failed to resolve photo library asset for \( url) "
276+ )
277+ )
278+ return
279+ }
280+
281+ continuation. resume ( returning: avAsset)
282+ }
283+
284+ state. lock. lock ( )
285+ state. requestID = requestID
286+ state. lock. unlock ( )
287+ }
288+ } , onCancel: {
289+ state. lock. lock ( )
290+ let requestID = state. requestID
291+ state. lock. unlock ( )
203292
204- semaphore. wait ( )
205- return resolvedAsset
293+ if requestID != PHInvalidImageRequestID {
294+ imageManager. cancelImageRequest ( requestID)
295+ }
296+ } )
206297 }
207298
208299 private static func photoLibraryIdentifier( from url: String ) -> String {
@@ -231,4 +322,17 @@ public final class StreamVideoThumbnailGenerator: NSObject {
231322
232323 return URL ( fileURLWithPath: url)
233324 }
325+
326+ private static func thumbnailError(
327+ _ error: Error ? = nil ,
328+ code: Int ,
329+ message: String
330+ ) -> Error {
331+ let description = error. map { " \( message) : \( $0. localizedDescription) " } ?? message
332+ return NSError (
333+ domain: " StreamVideoThumbnail " ,
334+ code: code,
335+ userInfo: [ NSLocalizedDescriptionKey: description]
336+ )
337+ }
234338}
0 commit comments