Skip to content

Commit

Permalink
Add MediaThumbnailID and cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
kean committed Sep 21, 2023
1 parent 58ddb02 commit 702b0a0
Showing 1 changed file with 65 additions and 61 deletions.
126 changes: 65 additions & 61 deletions WordPress/Classes/Services/MediaImageService.swift
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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) {
Expand All @@ -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
)
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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.
Expand Down

0 comments on commit 702b0a0

Please sign in to comment.