Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Media: Thumbnails #21615

Merged
merged 10 commits into from
Sep 25, 2023
Prev Previous commit
Next Next commit
Add MediaThumbnailID and cleanup
  • Loading branch information
kean committed Sep 21, 2023
commit 702b0a0a62a4b82138e8083b5b126f666809b301
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()

@@ -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.