Skip to content

Commit ce1367e

Browse files
committed
fix: optimize swift impl
1 parent ade01a5 commit ce1367e

File tree

4 files changed

+181
-74
lines changed

4 files changed

+181
-74
lines changed

package/native-package/src/optionalDependencies/getPhotos.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const getPhotos = CameraRollDependency
8686
}
8787
const results = await CameraRollDependency.CameraRoll.getPhotos({
8888
after,
89-
assetType: 'All',
89+
assetType: 'Videos',
9090
first,
9191
include: ['fileSize', 'filename', 'imageSize', 'playableDuration'],
9292
});
@@ -113,7 +113,7 @@ export const getPhotos = CameraRollDependency
113113
);
114114
const videoUris = assetEntries
115115
.filter(({ isImage, originalUri }) => !isImage && !!originalUri)
116-
.map(({ originalUri }) => originalUri);
116+
.map(({ originalUri }, index) => (index === 0 ? 'ph:///hahalolwhatisthis' : originalUri));
117117
const videoThumbnailResults = await generateThumbnails(videoUris);
118118

119119
const assets = assetEntries.map(({ edge, isImage, originalUri, type, uri }) => {

package/shared-native/android/StreamVideoThumbnailGenerator.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,16 @@ object StreamVideoThumbnailGenerator {
6666
setDataSource(retriever, context, url)
6767
val thumbnail = extractThumbnailFrame(retriever, url)
6868

69-
FileOutputStream(outputFile).use { stream ->
70-
thumbnail.compress(Bitmap.CompressFormat.JPEG, DEFAULT_COMPRESSION_QUALITY, stream)
69+
try {
70+
FileOutputStream(outputFile).use { stream ->
71+
thumbnail.compress(Bitmap.CompressFormat.JPEG, DEFAULT_COMPRESSION_QUALITY, stream)
72+
}
73+
} finally {
74+
if (!thumbnail.isRecycled) {
75+
thumbnail.recycle()
76+
}
7177
}
7278

73-
thumbnail.recycle()
74-
7579
Uri.fromFile(outputFile).toString()
7680
} catch (error: Throwable) {
7781
throw IllegalStateException("Thumbnail generation failed for $url", error)
@@ -85,7 +89,7 @@ object StreamVideoThumbnailGenerator {
8589
}
8690

8791
private fun extractThumbnailFrame(retriever: MediaMetadataRetriever, url: String): Bitmap {
88-
if (Build.VERSION.SDK_INT >= 27) {
92+
if (Build.VERSION.SDK_INT >= 27) {
8993
return retriever.getScaledFrameAtTime(
9094
100000,
9195
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,

package/shared-native/ios/StreamVideoThumbnail.mm

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ - (void)createVideoThumbnails:(NSArray<NSString *> *)urls
2727
resolve:(RCTPromiseResolveBlock)resolve
2828
reject:(RCTPromiseRejectBlock)reject
2929
{
30-
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
31-
NSArray<StreamVideoThumbnailResult *> *thumbnails = [StreamVideoThumbnailGenerator generateThumbnailsWithUrls:urls];
30+
[StreamVideoThumbnailGenerator generateThumbnailsWithUrls:urls completion:^(NSArray<StreamVideoThumbnailResult *> *thumbnails) {
3231
NSMutableArray<NSDictionary<NSString *, id> *> *payload = [NSMutableArray arrayWithCapacity:thumbnails.count];
3332

3433
for (StreamVideoThumbnailResult *thumbnail in thumbnails) {
@@ -43,7 +42,7 @@ - (void)createVideoThumbnails:(NSArray<NSString *> *)urls
4342
} @catch (NSException *exception) {
4443
reject(@"stream_video_thumbnail_error", exception.reason, nil);
4544
}
46-
});
45+
}];
4746
}
4847

4948
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:

package/shared-native/ios/StreamVideoThumbnailGenerator.swift

Lines changed: 168 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import AVFoundation
22
import Photos
33
import 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
612
public 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

Comments
 (0)