| 1 | import Foundation |
| 2 | import AVFoundation |
| 3 | import UniformTypeIdentifiers |
| 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 5 | import AppKit |
| 6 | #else |
| 7 | import UIKit |
| 8 | #endif |
| 9 | |
| 10 | // Plays a decrypted video WITHOUT writing plaintext to disk: an AVURLAsset on |
| 11 | // a custom scheme whose resource loader serves byte ranges straight from the |
| 12 | // in-memory Data. AVPlayer can't otherwise play from memory — handing it a |
| 13 | // file would mean silently persisting decrypted bytes, the same posture |
| 14 | // problem QuickLook has (see the photo lightbox). |
| 15 | // |
| 16 | // NB: AVAssetResourceLoader holds its delegate WEAKLY — whoever presents the |
| 17 | // player must keep this loader alive for the asset's lifetime. |
| 18 | /// A poster frame for a clip held in memory (staged-chip previews, and |
| 19 | /// anything else that wants a quick look at second zero) — the same in-memory |
| 20 | /// loader the player uses, so the plaintext never touches disk. |
| 21 | public func videoPosterImage(data: Data, mimeType: String?, |
| 22 | maxDim: Double = 288) async -> PlatformImage? { |
| 23 | let loader = MemoryVideoAsset(data: data, mimeType: mimeType) |
| 24 | let generator = AVAssetImageGenerator(asset: loader.makeAsset()) |
| 25 | generator.appliesPreferredTrackTransform = true |
| 26 | generator.maximumSize = CGSize(width: maxDim, height: maxDim) |
| 27 | guard let cg = try? await generator.image(at: .zero).image else { return nil } |
| 28 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 29 | return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) |
| 30 | #else |
| 31 | return UIImage(cgImage: cg) |
| 32 | #endif |
| 33 | } |
| 34 | |
| 35 | public final class MemoryVideoAsset: NSObject, AVAssetResourceLoaderDelegate { |
| 36 | private let data: Data |
| 37 | private let utiType: String |
| 38 | private let queue = DispatchQueue(label: "lchat.memory-video") |
| 39 | |
| 40 | public init(data: Data, mimeType: String?) { |
| 41 | self.data = data |
| 42 | self.utiType = mimeType.flatMap { UTType(mimeType: $0)?.identifier } |
| 43 | ?? UTType.mpeg4Movie.identifier |
| 44 | super.init() |
| 45 | } |
| 46 | |
| 47 | /// The playable asset. The custom scheme forces every load through the |
| 48 | /// resource loader; the bogus host/path are never used. |
| 49 | public func makeAsset() -> AVURLAsset { |
| 50 | let asset = AVURLAsset(url: URL(string: "lchat-mem://video/clip")!) |
| 51 | asset.resourceLoader.setDelegate(self, queue: queue) |
| 52 | return asset |
| 53 | } |
| 54 | |
| 55 | public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, |
| 56 | shouldWaitForLoadingOfRequestedResource |
| 57 | loadingRequest: AVAssetResourceLoadingRequest) -> Bool { |
| 58 | if let info = loadingRequest.contentInformationRequest { |
| 59 | info.contentType = utiType |
| 60 | info.contentLength = Int64(data.count) |
| 61 | info.isByteRangeAccessSupported = true |
| 62 | } |
| 63 | if let request = loadingRequest.dataRequest { |
| 64 | let from = Int(request.requestedOffset) |
| 65 | let length = request.requestsAllDataToEndOfResource |
| 66 | ? data.count - from |
| 67 | : min(request.requestedLength, data.count - from) |
| 68 | if from >= 0, length > 0, from + length <= data.count { |
| 69 | request.respond(with: data.subdata(in: from ..< from + length)) |
| 70 | } |
| 71 | } |
| 72 | loadingRequest.finishLoading() |
| 73 | return true |
| 74 | } |
| 75 | } |
| 76 | |