diff --git a/WordPress/Classes/Services/MediaImageService.swift b/WordPress/Classes/Services/MediaImageService.swift index 9383cf50deb4..56f4fb5ef21d 100644 --- a/WordPress/Classes/Services/MediaImageService.swift +++ b/WordPress/Classes/Services/MediaImageService.swift @@ -1,7 +1,7 @@ -import Foundation +import UIKit +import CoreData -/// A service for handling the process of retrieving and generating thumbnail images -/// for existing Media objects, whether remote or locally available. +/// A service for retrieval and caching of thumbnails for Media objects. final class MediaImageService: NSObject { static let shared = MediaImageService() @@ -24,29 +24,20 @@ final class MediaImageService: NSObject { // MARK: - Thumbnails - /// Returns a small thumbnail for the given media asset. - /// - /// The thumbnail size is different on different devices, but it's suitable - /// for presentation in collection views. The returned images are decompressed - /// (bitmapped) and are ready to be displayed. + /// Returns a thumbnail for the given media asset. The images are decompressed + /// (or bitmapped) and are ready to be displayed. @MainActor - func thumbnail(for media: Media) async throws -> UIImage { - let size = ThumbnailSize.small + func thumbnail(for media: Media, size: ThumbnailSize = .small) async throws -> UIImage { guard media.remoteStatus != .stub else { let media = try await fetchStubMedia(for: media) - guard media.remoteStatus != .stub else { - assertionFailure("The fetched media still has a .stub status") - throw MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL - } return try await _thumbnail(for: media, size: size) } - return try await _thumbnail(for: media, size: size) } @MainActor private func _thumbnail(for media: Media, size: ThumbnailSize) async throws -> UIImage { - if let image = await cachedThumbnail(for: media, size: size) { + if let image = await cachedThumbnail(for: media.objectID, size: size) { return image } if let image = await localThumbnail(for: media, size: size) { @@ -58,33 +49,29 @@ final class MediaImageService: NSObject { // MARK: - Cached Thumbnail /// Returns a local thumbnail for the given media object (if available). - @MainActor - private func cachedThumbnail(for media: Media, size: ThumbnailSize) async -> UIImage? { - let objectID = media.objectID + private func cachedThumbnail(for mediaID: NSManagedObjectID, size: ThumbnailSize) async -> UIImage? { return try? await Task.detached { - let imageURL = try self.getCachedThumbnailURL(for: objectID, size: size) + let imageURL = try self.getCachedThumbnailURL(for: mediaID, size: size) let data = try Data(contentsOf: imageURL) return try decompressedImage(from: data) }.value } // The save is performed asynchronously to eliminate any delays. It's - // exceedingly unlikely it'll result in any duplicated work thanks to the + // exceedingly unlikely it will result in any duplicated work thanks to the // memore caches. - @MainActor - private func saveThumbnail(for media: Media, size: ThumbnailSize, _ closure: @escaping (URL) throws -> Void) { - let objectID = media.objectID + private func saveThumbnail(for mediaID: NSManagedObjectID, size: ThumbnailSize, _ closure: @escaping (URL) throws -> Void) { ioQueue.async { - if let targetURL = try? self.getCachedThumbnailURL(for: objectID, size: size) { + if let targetURL = try? self.getCachedThumbnailURL(for: mediaID, size: size) { try? closure(targetURL) } } } - private func getCachedThumbnailURL(for objectID: NSManagedObjectID, size: ThumbnailSize) throws -> URL { - let objectID = objectID.uriRepresentation().lastPathComponent + private func getCachedThumbnailURL(for mediaID: NSManagedObjectID, size: ThumbnailSize) throws -> URL { + let mediaID = mediaID.uriRepresentation().lastPathComponent return try mediaFileManager.makeLocalMediaURL( - withFilename: "\(objectID)-\(size.rawValue)-thumbnail", + withFilename: "\(mediaID)-\(size.rawValue)-thumbnail", fileExtension: nil, // We don't know ahead of time incremented: false ) @@ -122,7 +109,7 @@ final class MediaImageService: NSObject { }.value // The order is important to ensure `export.url` still exists when creating an image - saveThumbnail(for: media, size: size) { targetURL in + saveThumbnail(for: media.objectID, size: size) { targetURL in try FileManager.default.moveItem(at: export.url, to: targetURL) } @@ -135,7 +122,7 @@ final class MediaImageService: NSObject { @MainActor private func remoteThumbnail(for media: Media, size: ThumbnailSize) async throws -> UIImage { let targetSize = MediaImageService.getThumbnailSize(for: media, size: size) - guard let imageURL = remoteThumbnailURL(for: media, targetSize: targetSize) else { + guard let imageURL = media.getRemoteThumbnailURL(targetSize: targetSize) else { throw URLError(.badURL) } @@ -147,7 +134,7 @@ final class MediaImageService: NSObject { } let (data, _) = try await session.data(for: request) - saveThumbnail(for: media, size: size) { targetURL in + saveThumbnail(for: media.objectID, size: size) { targetURL in try data.write(to: targetURL) } @@ -156,25 +143,6 @@ final class MediaImageService: NSObject { }.value } - @MainActor - private func remoteThumbnailURL(for media: Media, targetSize: CGSize) -> URL? { - switch media.mediaType { - case .image: - guard let remoteURL = media.remoteURL.flatMap(URL.init) else { - return nil - } - if media.blog.isPrivateAtWPCom() || (!media.blog.isHostedAtWPcom && media.blog.isBasicAuthCredentialStored()) { - return WPImageURLHelper.imageURLWithSize(targetSize, forImageURL: remoteURL) - } else { - let scale = 1.0 / UIScreen.main.scale - let targetSize = targetSize.applying(CGAffineTransform(scaleX: scale, y: scale)) - return PhotonImageURLHelper.photonURL(with: targetSize, forImageURL: remoteURL) - } - default: - return media.remoteThumbnailURL.flatMap(URL.init) - } - } - // MARK: - Stubs @MainActor @@ -186,23 +154,20 @@ final class MediaImageService: NSObject { let objectID = try await mediaRepository.getMedia(withID: mediaID, in: .init(media.blog)) return try coreDataStack.mainContext.existingObject(with: objectID) } +} - // MARK: - Target Size +// MARK: - MediaImageService (ThumbnailSize) + +extension MediaImageService { enum ThumbnailSize: String { + /// The small thumbnail that can be used in collection view cells and + /// similar situations. case small } - /// Returns an optiomal target size in pixels for a thumbnail of the given + /// Returns an optimal target size in pixels for a thumbnail of the given /// size for the given media asset. - /// - /// The size is calculated to fill a collection view cell, assuming the app - /// displays a few cells in a row. The cell size can vary depending on whether the - /// device is in landscape or portrait mode, but the thumbnail size is - /// guaranteed to always be the same across app launches. - /// - /// Example: if media size is 2000x3000 px and targetSize is 200x200 px, the - /// returned value will be 200x300 px. static func getThumbnailSize(for media: Media, size: ThumbnailSize) -> CGSize { let mediaSize = CGSize( width: CGFloat(media.width?.floatValue ?? 0), @@ -220,6 +185,11 @@ final class MediaImageService: NSObject { private static func getPreferredThumbnailSize(for thumbnail: ThumbnailSize) -> CGSize { switch thumbnail { case .small: + /// The size is calculated to fill a collection view cell, assuming the app + /// displays a 4 or 5 cells in one row. The cell size can vary depending + /// on whether the device is in landscape or portrait mode, but the thumbnail size is + /// guaranteed to always be the same across app launches and optimized for + /// a portraint (dominant) mode. let screenSide = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) let itemPerRow = UIDevice.current.userInterfaceIdiom == .pad ? 5 : 4 let availableWidth = screenSide - MediaViewController.spacing * CGFloat(itemPerRow - 1) @@ -229,6 +199,11 @@ final class MediaImageService: NSObject { } } + /// Image CDN (Photon) and `MediaImageExporter` both don't support "aspect-fill" + /// resizing mode, so the service performs the necessary calculations by itself. + /// + /// Example: if media size is 2000x3000 px and targetSize is 200x200 px, the + /// returned value will be 200x300 px. For more examples, see `MediaImageServiceTests`. static func targetSize(forMediaSize mediaSize: CGSize, targetSize originalTargetSize: CGSize) -> CGSize { guard mediaSize.width > 0 && mediaSize.height > 0 else { return originalTargetSize @@ -249,7 +224,36 @@ final class MediaImageService: NSObject { } } -// MARK: - Decompression +// MARK: - Helpers (RemoteURL) + +private extension Media { + /// Returns the thumbnail remote URL with a given target size. It uses + /// Image CDN (formerly Photon) if available. + /// + /// - parameter targetSize: Target size in pixels. + func getRemoteThumbnailURL(targetSize: CGSize) -> URL? { + switch mediaType { + case .image: + guard let remoteURL = remoteURL.flatMap(URL.init) else { + return nil + } + if !isEligibleForPhoton { + return WPImageURLHelper.imageURLWithSize(targetSize, forImageURL: remoteURL) + } else { + let targetSize = targetSize.scaled(by: 1.0 / UIScreen.main.scale) + return PhotonImageURLHelper.photonURL(with: targetSize, forImageURL: remoteURL) + } + default: + return remoteThumbnailURL.flatMap(URL.init) + } + } + + var isEligibleForPhoton: Bool { + blog.isPrivateAtWPCom() || (!blog.isHostedAtWPcom && blog.isBasicAuthCredentialStored()) + } +} + +// MARK: - Helpers (Decompression) // Forces decompression (or bitmapping) to happen in the background. // It's very expensive for some image formats, such as JPEG.