255 lines
10 KiB
Swift
255 lines
10 KiB
Swift
//
|
|
// Copyright 2018 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import AVFoundation
|
|
|
|
public enum OWSMediaError: Error {
|
|
case failure(description: String)
|
|
}
|
|
|
|
@objc
|
|
public class OWSMediaUtils: NSObject {
|
|
|
|
@available(*, unavailable, message: "do not instantiate this class.")
|
|
private override init() {
|
|
}
|
|
|
|
public class func thumbnail(forImage image: UIImage, maxDimensionPixels: CGFloat) throws -> UIImage {
|
|
if image.pixelSize.width <= maxDimensionPixels,
|
|
image.pixelSize.height <= maxDimensionPixels {
|
|
let result = image.withNativeScale
|
|
return result
|
|
}
|
|
guard let thumbnailImage = image.resized(maxDimensionPixels: maxDimensionPixels) else {
|
|
throw OWSMediaError.failure(description: "Could not thumbnail image.")
|
|
}
|
|
guard nil != thumbnailImage.cgImage else {
|
|
throw OWSMediaError.failure(description: "Missing cgImage.")
|
|
}
|
|
let result = thumbnailImage.withNativeScale
|
|
return result
|
|
}
|
|
|
|
private class func thumbnail(forImage image: UIImage, maxDimensionPoints: CGFloat) throws -> UIImage {
|
|
let scale = UIScreen.main.scale
|
|
let maxDimensionPixels = maxDimensionPoints * scale
|
|
return try thumbnail(forImage: image, maxDimensionPixels: maxDimensionPixels)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forImageAtPath path: String, maxDimensionPixels: CGFloat) throws -> UIImage {
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Media file missing.")
|
|
}
|
|
guard Data.ows_isValidImage(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Invalid image.")
|
|
}
|
|
guard let originalImage = UIImage(contentsOfFile: path) else {
|
|
throw OWSMediaError.failure(description: "Could not load original image.")
|
|
}
|
|
return try thumbnail(forImage: originalImage, maxDimensionPixels: maxDimensionPixels)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forImageAtPath path: String, maxDimensionPoints: CGFloat) throws -> UIImage {
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Media file missing.")
|
|
}
|
|
guard Data.ows_isValidImage(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Invalid image.")
|
|
}
|
|
guard let originalImage = UIImage(contentsOfFile: path) else {
|
|
throw OWSMediaError.failure(description: "Could not load original image.")
|
|
}
|
|
return try thumbnail(forImage: originalImage, maxDimensionPoints: maxDimensionPoints)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forImageData imageData: Data, maxDimensionPoints: CGFloat) throws -> UIImage {
|
|
guard imageData.ows_isValidImage else {
|
|
throw OWSMediaError.failure(description: "Invalid image.")
|
|
}
|
|
guard let originalImage = UIImage(data: imageData) else {
|
|
throw OWSMediaError.failure(description: "Could not load original image.")
|
|
}
|
|
return try thumbnail(forImage: originalImage, maxDimensionPoints: maxDimensionPoints)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forImageData imageData: Data, maxDimensionPixels: CGFloat) throws -> UIImage {
|
|
guard imageData.ows_isValidImage else {
|
|
throw OWSMediaError.failure(description: "Invalid image.")
|
|
}
|
|
guard let originalImage = UIImage(data: imageData) else {
|
|
throw OWSMediaError.failure(description: "Could not load original image.")
|
|
}
|
|
return try thumbnail(forImage: originalImage, maxDimensionPixels: maxDimensionPixels)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forWebpAtPath path: String, maxDimensionPoints: CGFloat) throws -> UIImage {
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Media file missing.")
|
|
}
|
|
guard Data.ows_isValidImage(atPath: path) else {
|
|
throw OWSMediaError.failure(description: "Invalid image.")
|
|
}
|
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
guard let stillImage = data.stillForWebpData() else {
|
|
throw OWSMediaError.failure(description: "Could not generate still.")
|
|
}
|
|
return try thumbnail(forImage: stillImage, maxDimensionPoints: maxDimensionPoints)
|
|
}
|
|
|
|
@objc
|
|
public class func thumbnail(forVideoAtPath path: String, maxDimensionPoints: CGFloat) throws -> UIImage {
|
|
guard isVideoOfValidContentTypeAndSize(path: path) else {
|
|
throw OWSMediaError.failure(description: "Media file has missing or invalid length.")
|
|
}
|
|
|
|
let scale = UIScreen.main.scale
|
|
let maxDimensionPixels = maxDimensionPoints * scale
|
|
let maxSizePixels = CGSize(width: maxDimensionPixels, height: maxDimensionPixels)
|
|
let url = URL(fileURLWithPath: path)
|
|
let asset = AVURLAsset(url: url, options: nil)
|
|
guard isValidVideo(asset: asset) else {
|
|
throw OWSMediaError.failure(description: "Invalid video.")
|
|
}
|
|
return try thumbnail(forVideo: asset, maxSizePixels: maxSizePixels)
|
|
}
|
|
|
|
public static let videoStillFrameMimeType = MimeType.imageJpeg
|
|
|
|
public class func thumbnail(forVideo asset: AVAsset, maxSizePixels: CGSize) throws -> UIImage {
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.maximumSize = maxSizePixels
|
|
generator.appliesPreferredTrackTransform = true
|
|
let time: CMTime = CMTimeMake(value: 1, timescale: 60)
|
|
let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
|
|
let image = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up)
|
|
return image
|
|
}
|
|
|
|
public class func thumbnailData(forVideo asset: AVAsset, maxSizePixels: CGSize) throws -> Data {
|
|
let image = try thumbnail(forVideo: asset, maxSizePixels: maxSizePixels)
|
|
owsAssertDebug(Self.videoStillFrameMimeType == MimeType.imageJpeg)
|
|
guard let data = image.jpegData(compressionQuality: 0.8) else {
|
|
throw OWSAssertionError("Unable to serialize image!")
|
|
}
|
|
return data
|
|
}
|
|
|
|
@objc
|
|
public class func isValidVideo(path: String) -> Bool {
|
|
return isValidVideo(path: path, ignoreSize: false)
|
|
}
|
|
|
|
@objc
|
|
public class func isValidVideo(path: String, ignoreSize: Bool) -> Bool {
|
|
let pathValidationMethod: (String) -> Bool
|
|
if ignoreSize {
|
|
pathValidationMethod = self.isVideoOfValidContentType(path:)
|
|
} else {
|
|
pathValidationMethod = self.isVideoOfValidContentTypeAndSize(path:)
|
|
}
|
|
guard pathValidationMethod(path) else {
|
|
Logger.error("Media file has missing or invalid length.")
|
|
return false
|
|
}
|
|
|
|
let url = URL(fileURLWithPath: path)
|
|
let asset = AVURLAsset(url: url, options: nil)
|
|
return isValidVideo(asset: asset)
|
|
}
|
|
|
|
public class func isVideoOfValidContentTypeAndSize(path: String) -> Bool {
|
|
return isVideoOfValidContentType(path: path)
|
|
&& isVideoOfValidSize(path: path)
|
|
}
|
|
|
|
public class func isVideoOfValidContentType(path: String) -> Bool {
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
Logger.error("Media file missing.")
|
|
return false
|
|
}
|
|
let fileExtension = URL(fileURLWithPath: path).pathExtension
|
|
guard let contentType = MimeTypeUtil.mimeTypeForFileExtension(fileExtension) else {
|
|
Logger.error("Media file has unknown content type.")
|
|
return false
|
|
}
|
|
guard MimeTypeUtil.isSupportedVideoMimeType(contentType) else {
|
|
Logger.error("Media file has invalid content type.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public class func isVideoOfValidSize(path: String) -> Bool {
|
|
guard let fileSize = OWSFileSystem.fileSize(ofPath: path) else {
|
|
Logger.error("Media file has unknown length.")
|
|
return false
|
|
}
|
|
return fileSize.uintValue <= kMaxFileSizeVideo
|
|
}
|
|
|
|
public class func isValidVideo(asset: AVAsset) -> Bool {
|
|
var maxTrackSize = CGSize.zero
|
|
for track: AVAssetTrack in asset.tracks(withMediaType: .video) {
|
|
let trackSize: CGSize = track.naturalSize
|
|
maxTrackSize.width = max(maxTrackSize.width, trackSize.width)
|
|
maxTrackSize.height = max(maxTrackSize.height, trackSize.height)
|
|
}
|
|
if maxTrackSize.width < 1.0 || maxTrackSize.height < 1.0 {
|
|
Logger.error("Invalid video size: \(maxTrackSize)")
|
|
return false
|
|
}
|
|
if maxTrackSize.width > kMaxVideoDimensions || maxTrackSize.height > kMaxVideoDimensions {
|
|
Logger.error("Invalid video dimensions: \(maxTrackSize)")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public class func videoResolution(url: URL) -> CGSize {
|
|
var maxTrackSize = CGSize.zero
|
|
let asset = AVURLAsset(url: url)
|
|
for track: AVAssetTrack in asset.tracks(withMediaType: .video) {
|
|
let trackSize: CGSize = track.naturalSize
|
|
maxTrackSize.width = max(maxTrackSize.width, trackSize.width)
|
|
maxTrackSize.height = max(maxTrackSize.height, trackSize.height)
|
|
}
|
|
return maxTrackSize
|
|
}
|
|
|
|
// MARK: Constants
|
|
|
|
/**
|
|
* Media Size constraints from Signal-Android
|
|
*
|
|
* https://github.com/signalapp/Signal-Android/blob/c4bc2162f23e0fd6bc25941af8fb7454d91a4a35/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java
|
|
*/
|
|
@objc
|
|
public static let kMaxFileSizeAnimatedImage = UInt(25 * 1024 * 1024)
|
|
@objc
|
|
public static let kMaxFileSizeImage = UInt(8 * 1024 * 1024)
|
|
// Cloudflare limits uploads to 100 MB. To avoid hitting those limits,
|
|
// we use limits that are 5% lower for the unencrypted content.
|
|
@objc
|
|
public static let kMaxFileSizeVideo = UInt(95 * 1000 * 1000)
|
|
@objc
|
|
public static let kMaxFileSizeAudio = UInt(95 * 1000 * 1000)
|
|
@objc
|
|
public static let kMaxFileSizeGeneric = UInt(95 * 1000 * 1000)
|
|
@objc
|
|
public static let kMaxAttachmentUploadSizeBytes = UInt(100 * 1000 * 1000)
|
|
|
|
@objc
|
|
public static let kMaxVideoDimensions: CGFloat = 4096 // 4k video width
|
|
@objc
|
|
public static let kMaxAnimatedImageDimensions: UInt = 12 * 1024
|
|
@objc
|
|
public static let kMaxStillImageDimensions: UInt = 12 * 1024
|
|
}
|