Skip to content

Commit 4a20e15

Browse files
authored
Add video support to AsyncImageKit (#25000)
1 parent 091475a commit 4a20e15

File tree

10 files changed

+358
-31
lines changed

10 files changed

+358
-31
lines changed

Modules/Sources/AsyncImageKit/ImageDownloader.swift

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import UIKit
2+
import CryptoKit
3+
import AVFoundation
24

35
/// The system that downloads and caches images, and prepares them for display.
46
@ImageDownloaderActor
@@ -27,21 +29,43 @@ public final class ImageDownloader {
2729
self.cache = cache
2830
}
2931

30-
public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage {
32+
public func image(
33+
from url: URL,
34+
host: MediaHostProtocol? = nil,
35+
options: ImageRequestOptions = ImageRequestOptions()
36+
) async throws -> UIImage {
3137
try await image(for: ImageRequest(url: url, host: host, options: options))
3238
}
3339

3440
public func image(for request: ImageRequest) async throws -> UIImage {
3541
let options = request.options
3642
let key = makeKey(for: request.source.url, size: options.size)
37-
if options.isMemoryCacheEnabled, let image = cache[key] {
43+
44+
if let cachedImage = try self.fetch(key, options: request.options) {
45+
return cachedImage
46+
}
47+
48+
if case .video(let url, let mediaHost) = request.source {
49+
let asset: AVAsset = try await mediaHost?.authenticatedAsset(for: url) ?? AVURLAsset(url: url)
50+
let generator = AVAssetImageGenerator(asset: asset)
51+
52+
if let size = options.size {
53+
generator.maximumSize = CGSize(width: size.width, height: size.height)
54+
}
55+
56+
let result = try await generator.image(at: .zero)
57+
let image = UIImage(cgImage: result.image)
58+
59+
try store(image, for: key, options: options)
60+
3861
return image
3962
}
63+
4064
let data = try await data(for: request)
4165
let image = try await ImageDecoder.makeImage(from: data, size: options.size.map(CGSize.init))
42-
if options.isMemoryCacheEnabled {
43-
cache[key] = image
44-
}
66+
67+
try store(image, for: key, options: options)
68+
4569
return image
4670
}
4771

@@ -63,6 +87,8 @@ public final class ImageDownloader {
6387
return request
6488
case .urlRequest(let urlRequest):
6589
return urlRequest
90+
case .video:
91+
preconditionFailure("Cannot make URLRequest for video – use AVFoundation APIs instead")
6692
}
6793
}
6894

@@ -151,6 +177,53 @@ public final class ImageDownloader {
151177
throw ImageDownloaderError.unacceptableStatusCode(response.statusCode)
152178
}
153179
}
180+
181+
// MARK: Manual caching
182+
private func fetch(_ key: String, options: ImageRequestOptions) throws -> UIImage? {
183+
184+
if options.isMemoryCacheEnabled, let image = cache[key] {
185+
return image
186+
}
187+
188+
guard options.isDiskCacheEnabled else {
189+
return nil
190+
}
191+
192+
let path = path(for: key)
193+
194+
guard FileManager.default.fileExists(atPath: path.path()) else {
195+
return nil
196+
}
197+
198+
let pngData = try Data(contentsOf: path)
199+
return UIImage(data: pngData)
200+
}
201+
202+
private func store(_ image: UIImage, for key: String, options: ImageRequestOptions) throws {
203+
204+
if options.isMemoryCacheEnabled {
205+
cache[key] = image
206+
}
207+
208+
// If the image is immutable, store it in the disk cache "forever"
209+
guard options.isDiskCacheEnabled, options.mutability == .immutable else {
210+
return
211+
}
212+
213+
guard let data = image.pngData() else {
214+
return
215+
}
216+
217+
let path = self.path(for: key)
218+
219+
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
220+
try data.write(to: path)
221+
}
222+
223+
private func path(for key: String) -> URL {
224+
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
225+
return URL.cachesDirectory.appending(path: "image-cache").appending(path: hash)
226+
}
154227
}
155228

156229
@ImageDownloaderActor
@@ -196,4 +269,5 @@ private extension URLSession {
196269

197270
public protocol MediaHostProtocol: Sendable {
198271
@MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest
272+
@MainActor func authenticatedAsset(for url: URL) async throws -> AVURLAsset
199273
}

Modules/Sources/AsyncImageKit/ImagePrefetcher.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import UIKit
1+
import Foundation
22
import Collections
33

44
@ImageDownloaderActor

Modules/Sources/AsyncImageKit/ImageRequest.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@ public final class ImageRequest: Sendable {
44
public enum Source: Sendable {
55
case url(URL, MediaHostProtocol?)
66
case urlRequest(URLRequest)
7+
case video(URL, MediaHostProtocol?)
78

89
var url: URL? {
910
switch self {
1011
case .url(let url, _): url
1112
case .urlRequest(let request): request.url
13+
case .video(let url, _): url
14+
}
15+
}
16+
17+
var host: MediaHostProtocol? {
18+
switch self {
19+
case .url(_, let host): host
20+
case .urlRequest: nil
21+
case .video(_, let host): host
1222
}
1323
}
1424
}
@@ -25,6 +35,34 @@ public final class ImageRequest: Sendable {
2535
self.source = .urlRequest(urlRequest)
2636
self.options = options
2737
}
38+
39+
public init(videoUrl: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = ImageRequestOptions()) {
40+
self.source = .video(videoUrl, host)
41+
self.options = options
42+
}
43+
}
44+
45+
/// Defines the mutability characteristics of a resource for caching purposes.
46+
///
47+
/// This affects how aggressively the resource is cached:
48+
/// - `.mutable`: Resources are cached in memory and URLSession's disk cache (with eviction policies)
49+
/// - `.immutable`: Resources are additionally cached persistently on disk (never evicted automatically)
50+
public enum ResourceMutability: Sendable {
51+
/// Items that might change over time while keeping the same URL.
52+
///
53+
/// These resources are cached in memory and URLSession's disk cache, but not persistently.
54+
/// The cache may be evicted based on system policies.
55+
///
56+
/// **Example**: Site Icons, Gravatars - the same URL might return different images as users update their profiles
57+
case mutable
58+
59+
/// Items that will never be modified after creation.
60+
///
61+
/// These resources are cached persistently on disk in addition to in-memory caching.
62+
/// Once downloaded, they remain cached indefinitely since the content at the URL will never change.
63+
///
64+
/// **Example**: Support ticket attachments - these URLs point to immutable content
65+
case immutable
2866
}
2967

3068
public struct ImageRequestOptions: Hashable, Sendable {
@@ -38,14 +76,25 @@ public struct ImageRequestOptions: Hashable, Sendable {
3876
/// with a relatively high disk capacity. By default, `true`.
3977
public var isDiskCacheEnabled = true
4078

79+
/// Indicates how this asset should be cached based on whether the content can change.
80+
///
81+
/// Use `.mutable` (default) for resources that might change over time (like user avatars).
82+
/// Use `.immutable` for resources that never change (like support attachments) to enable
83+
/// persistent disk caching that survives app restarts and system cache evictions.
84+
///
85+
/// - Note: Only applies when `isDiskCacheEnabled` is `true`
86+
public let mutability: ResourceMutability
87+
4188
public init(
4289
size: ImageSize? = nil,
4390
isMemoryCacheEnabled: Bool = true,
44-
isDiskCacheEnabled: Bool = true
91+
isDiskCacheEnabled: Bool = true,
92+
mutability: ResourceMutability = .mutable
4593
) {
4694
self.size = size
4795
self.isMemoryCacheEnabled = isMemoryCacheEnabled
4896
self.isDiskCacheEnabled = isDiskCacheEnabled
97+
self.mutability = mutability
4998
}
5099
}
51100

0 commit comments

Comments
 (0)