From a0bd794295b484757fd7dc6cb9b407cba272c0ef Mon Sep 17 00:00:00 2001 From: adimiz1 <95848801+adimiz1@users.noreply.github.com> Date: Tue, 17 Sep 2024 12:27:21 +0300 Subject: [PATCH] Add video transcode feature --- Cloudinary.xcodeproj/project.pbxproj | 12 ++ .../Uploader/CLDVideoPreprocessChain.swift | 135 ++++++++++++++++++ .../Uploader/CLDVideoPreprocessHelpers.swift | 52 +++++++ .../ios/Uploader/CLDVideoTranscode.swift | 131 +++++++++++++++++ Example/Cloudinary.xcodeproj/project.pbxproj | 14 +- .../{ => Preprocess}/PreprocessTests.swift | 0 .../Preprocess/VideoPreprocessTests.swift | 108 ++++++++++++++ 7 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessChain.swift create mode 100644 Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessHelpers.swift create mode 100644 Cloudinary/Classes/ios/Uploader/CLDVideoTranscode.swift rename Example/Tests/{ => Preprocess}/PreprocessTests.swift (100%) create mode 100644 Example/Tests/Preprocess/VideoPreprocessTests.swift diff --git a/Cloudinary.xcodeproj/project.pbxproj b/Cloudinary.xcodeproj/project.pbxproj index f2802ec8..3b9147a5 100644 --- a/Cloudinary.xcodeproj/project.pbxproj +++ b/Cloudinary.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ B694AAEC2B308AF100075041 /* CLDAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B694AAEB2B308AF100075041 /* CLDAnalytics.swift */; }; B694AAEF2B308B8400075041 /* VideoAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B694AAEE2B308B8400075041 /* VideoAnalytics.swift */; }; B694AAF12B308B9D00075041 /* VideoEventsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B694AAF02B308B9D00075041 /* VideoEventsManager.swift */; }; + B6B4C49A2C560FEE00C9B604 /* CLDVideoPreprocessChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4C4992C560FEE00C9B604 /* CLDVideoPreprocessChain.swift */; }; + B6B4C49C2C56100600C9B604 /* CLDVideoTranscode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4C49B2C56100600C9B604 /* CLDVideoTranscode.swift */; }; + B6B4C4A12C5615CF00C9B604 /* CLDVideoPreprocessHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4C4A02C5615CF00C9B604 /* CLDVideoPreprocessHelpers.swift */; }; B6C2D4862A724C4200AA0039 /* CLDVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2D4852A724C4200AA0039 /* CLDVideoPlayer.swift */; }; OBJ_257 /* CLDNConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* CLDNConvertible.swift */; }; OBJ_258 /* CLDNError.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_11 /* CLDNError.swift */; }; @@ -170,6 +173,9 @@ B694AAEB2B308AF100075041 /* CLDAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CLDAnalytics.swift; sourceTree = ""; }; B694AAEE2B308B8400075041 /* VideoAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAnalytics.swift; sourceTree = ""; }; B694AAF02B308B9D00075041 /* VideoEventsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEventsManager.swift; sourceTree = ""; }; + B6B4C4992C560FEE00C9B604 /* CLDVideoPreprocessChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDVideoPreprocessChain.swift; sourceTree = ""; }; + B6B4C49B2C56100600C9B604 /* CLDVideoTranscode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDVideoTranscode.swift; sourceTree = ""; }; + B6B4C4A02C5615CF00C9B604 /* CLDVideoPreprocessHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDVideoPreprocessHelpers.swift; sourceTree = ""; }; B6C2D4852A724C4200AA0039 /* CLDVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDVideoPlayer.swift; sourceTree = ""; }; "Cloudinary::Cloudinary::Product" /* Cloudinary.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Cloudinary.framework; sourceTree = BUILT_PRODUCTS_DIR; }; OBJ_10 /* CLDNConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDNConvertible.swift; sourceTree = ""; }; @@ -688,6 +694,9 @@ children = ( OBJ_240 /* CLDImagePreprocessChain.swift */, OBJ_241 /* CLDPreprocessHelpers.swift */, + B6B4C4992C560FEE00C9B604 /* CLDVideoPreprocessChain.swift */, + B6B4C49B2C56100600C9B604 /* CLDVideoTranscode.swift */, + B6B4C4A02C5615CF00C9B604 /* CLDVideoPreprocessHelpers.swift */, ); path = Uploader; sourceTree = ""; @@ -975,6 +984,7 @@ OBJ_309 /* CLDOperators.swift in Sources */, OBJ_310 /* CLDTransformation.swift in Sources */, OBJ_311 /* CLDVariable.swift in Sources */, + B6B4C49C2C56100600C9B604 /* CLDVideoTranscode.swift in Sources */, OBJ_312 /* CLDFetchLayer.swift in Sources */, OBJ_313 /* CLDLayer.swift in Sources */, OBJ_314 /* CLDSubtitlesLayer.swift in Sources */, @@ -1002,6 +1012,7 @@ OBJ_335 /* CLDOcrResult.swift in Sources */, OBJ_336 /* CLDOcrSymbolResult.swift in Sources */, OBJ_337 /* CLDOcrTextAnnotationResult.swift in Sources */, + B6B4C49A2C560FEE00C9B604 /* CLDVideoPreprocessChain.swift in Sources */, OBJ_338 /* CLDOcrWordResult.swift in Sources */, OBJ_339 /* CLDQualityAnalysis.swift in Sources */, OBJ_340 /* CLDRekognitionFace.swift in Sources */, @@ -1076,6 +1087,7 @@ OBJ_406 /* CLDDefaultNetworkAdapter.swift in Sources */, OBJ_407 /* CLDNetworkCoordinator.swift in Sources */, OBJ_408 /* CLDAsyncNetworkUploadRequest.swift in Sources */, + B6B4C4A12C5615CF00C9B604 /* CLDVideoPreprocessHelpers.swift in Sources */, OBJ_409 /* CLDErrorRequest.swift in Sources */, OBJ_410 /* CLDGenericNetworkRequest.swift in Sources */, OBJ_411 /* CLDNetworkDataRequestImpl.swift in Sources */, diff --git a/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessChain.swift b/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessChain.swift new file mode 100644 index 00000000..b054aa3a --- /dev/null +++ b/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessChain.swift @@ -0,0 +1,135 @@ +// +// CLDVideoPreprocessChain.swift +// +// Copyright (c) 2017 Cloudinary (http://cloudinary.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import AVKit + +public typealias CLDVideoPreprocessStep = (CLDVideoTranscode) throws -> CLDVideoTranscode + +public class CLDVideoPreprocessChain: CLDPreprocessChain { + private var outputURL: URL? + + public override init() { + super.init() + } + + internal override func decodeResource(_ resourceData: Any) throws -> CLDVideoTranscode? { + if let url = resourceData as? URL { + return CLDVideoTranscode(sourceURL: url) + } else if let data = resourceData as? Data { + return try handleLargeVideoData(data) + } else { + throw CLDError.error(code: CLDError.CloudinaryErrorCode.preprocessingError, message: "Resource type should be URL or Data only!") + } + } + + private func handleLargeVideoData(_ data: Data) throws -> CLDVideoTranscode? { + var tempDirectory: URL! + if #available(iOS 10.0, *) { + tempDirectory = FileManager.default.temporaryDirectory + } else { + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + } + let tempURL = tempDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") + do { + try data.write(to: tempURL) + return CLDVideoTranscode(sourceURL: tempURL) + } catch { + throw CLDError.error(code: CLDError.CloudinaryErrorCode.preprocessingError, message: "Failed to write data to temporary file: \(error.localizedDescription)") + } + } + + public func setOutputFormat(format: AVFileType) -> Self { + addStep { videoTranscode in + try videoTranscode.setOutputFormat(format: format) + return videoTranscode + } + return self + } + + public func setOutputDimensions(dimensions: CGSize) -> Self { + addStep { videoTranscode in + videoTranscode.setOutputDimensions(dimensions: dimensions) + return videoTranscode + } + return self + } + + public func setCompressionPreset(preset: String) -> Self { + addStep { videoTranscode in + videoTranscode.setCompressionPreset(preset: preset) + return videoTranscode + } + return self + } + + internal override func execute(resourceData: Any) throws -> URL { + try verifyEncoder() + + guard let videoTranscode = try decodeResource(resourceData) else { + throw CLDError.error(code: CLDError.CloudinaryErrorCode.preprocessingError, message: "Error decoding resource") + } + + let dispatchGroup = DispatchGroup() + var resultURL: URL? + var resultError: Error? + + dispatchGroup.enter() + + DispatchQueue.global(qos: .background).async { + do { + var processedTranscode = videoTranscode + for preprocess in self.chain { + processedTranscode = try preprocess(processedTranscode) + } + + processedTranscode.transcode { success, error in + if success, let outputURL = processedTranscode.outputURL { + resultURL = outputURL + } else { + resultError = error ?? CLDError.error(code: CLDError.CloudinaryErrorCode.preprocessingError, message: "Error transcoding video") + } + dispatchGroup.leave() + } + } catch { + resultError = error + dispatchGroup.leave() + } + } + + dispatchGroup.wait() + + if let finalOutputURL = resultURL { + return finalOutputURL + } else { + throw resultError ?? CLDError.error(code: CLDError.CloudinaryErrorCode.preprocessingError, message: "Unknown error") + } + } + + internal override func verifyEncoder() throws { + if encoder == nil { + encoder = { _ in self.outputURL } + } + } +} diff --git a/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessHelpers.swift b/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessHelpers.swift new file mode 100644 index 00000000..2e707351 --- /dev/null +++ b/Cloudinary/Classes/ios/Uploader/CLDVideoPreprocessHelpers.swift @@ -0,0 +1,52 @@ +import Foundation +import AVKit + +public class CLDVideoPreprocessHelpers { + + private var steps: [(CLDVideoTranscode) throws -> CLDVideoTranscode] = [] + + public func addStep(_ step: @escaping (CLDVideoTranscode) throws -> CLDVideoTranscode) { + steps.append(step) + } + + public static func setOutputFormat(format: AVFileType) -> CLDVideoPreprocessStep { + return { videoTranscode in + do { + try videoTranscode.setOutputFormat(format: format) + } catch { + throw error + } + return videoTranscode + } + } + + public static func setOutputDimensions(dimensions: CGSize) -> CLDVideoPreprocessStep { + return { videoTranscode in + videoTranscode.setOutputDimensions(dimensions: dimensions) + return videoTranscode + } + } + + public static func setCompressionPreset(preset: String) -> CLDVideoPreprocessStep { + return { videoTranscode in + videoTranscode.setCompressionPreset(preset: preset) + return videoTranscode + } + } + + public static func dimensionsValidator(minWidth: CGFloat, maxWidth: CGFloat, minHeight: CGFloat, maxHeight: CGFloat) -> CLDVideoPreprocessStep { + return { videoTranscode in + guard let dimensions = videoTranscode.outputDimensions else { + throw NSError(domain: "CLDVideoPreprocessHelpers", code: -1, userInfo: [NSLocalizedDescriptionKey: "Dimensions not set"]) + } + if dimensions.width < minWidth || dimensions.width > maxWidth || dimensions.height < minHeight || dimensions.height > maxHeight { + throw VideoPreprocessError.dimensionsOutOfRange + } + return videoTranscode + } + } +} + +enum VideoPreprocessError: Error { + case dimensionsOutOfRange +} diff --git a/Cloudinary/Classes/ios/Uploader/CLDVideoTranscode.swift b/Cloudinary/Classes/ios/Uploader/CLDVideoTranscode.swift new file mode 100644 index 00000000..6697deaf --- /dev/null +++ b/Cloudinary/Classes/ios/Uploader/CLDVideoTranscode.swift @@ -0,0 +1,131 @@ +import Foundation +import AVFoundation + +public class CLDVideoTranscode { + let sourceURL: URL + var outputURL: URL? + var outputFormat: AVFileType = .mov + var outputDimensions: CGSize? + var compressionPreset: String = AVAssetExportPresetPassthrough // Default to passthrough + + init(sourceURL: URL) { + self.sourceURL = sourceURL + } + + func setOutputFormat(format: AVFileType) throws { + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + throw NSError(domain: "CLDVideoPreprocessHelpers", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid source URL"]) + } + + let asset = AVAsset(url: sourceURL) + guard asset.tracks(withMediaType: .video).first != nil else { + throw NSError(domain: "CLDVideoPreprocessHelpers", code: -1, userInfo: [NSLocalizedDescriptionKey: "No video track found"]) + } + + self.outputFormat = format + } + + func setOutputDimensions(dimensions: CGSize) { + self.outputDimensions = dimensions + } + + func setCompressionPreset(preset: String) { + self.compressionPreset = preset + } + + var hasVideoTrack: Bool { + let asset = AVAsset(url: sourceURL) + return asset.tracks(withMediaType: .video).first != nil + } + + func transcode(completion: @escaping (Bool, Error?) -> Void) { + let asset = AVAsset(url: sourceURL) + let outputURL = generateOutputURL() + + do { + // Create asset reader + let assetReader = try AVAssetReader(asset: asset) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { + throw NSError(domain: "CLDVideoTranscode", code: -1, userInfo: [NSLocalizedDescriptionKey: "No video track found"]) + } + + // Configure output settings for AVAssetReader + let assetReaderOutputSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB + ] + + let assetReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: assetReaderOutputSettings) + + // Add the output to the reader + assetReader.add(assetReaderOutput) + + // Create asset writer + let assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: outputFormat) + let videoSettings: [String: Any] = [ + AVVideoWidthKey: outputDimensions?.width ?? videoTrack.naturalSize.width, + AVVideoHeightKey: outputDimensions?.height ?? videoTrack.naturalSize.height + ] + + let assetWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + assetWriterInput.expectsMediaDataInRealTime = true + assetWriter.add(assetWriterInput) + + // Start reading and writing + assetReader.startReading() + assetWriter.startWriting() + assetWriter.startSession(atSourceTime: .zero) + + assetWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "assetWriterQueue")) { + while assetWriterInput.isReadyForMoreMediaData { + guard assetReader.status == .reading else { + let error = assetReader.error ?? NSError(domain: "CLDVideoTranscode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Asset reader status is not reading"]) + completion(false, error) + return + } + + if let sampleBuffer = assetReaderOutput.copyNextSampleBuffer() { + assetWriterInput.append(sampleBuffer) + } else { + assetWriterInput.markAsFinished() + assetWriter.finishWriting { + defer { + if self.sourceURL.isFileURL { + try? FileManager.default.removeItem(at: self.sourceURL) + } + } + + if assetWriter.status == .completed { + self.outputURL = assetWriter.outputURL + completion(true, nil) + } else { + completion(false, assetWriter.error) + } + } + break + } + } + } + } catch { + completion(false, error) + } + } + + private func generateOutputURL() -> URL { + return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension(outputFormat.fileExtension) + } +} + +private extension AVFileType { + var fileExtension: String { + switch self { + case .mov: + return "mov" + case .mp4: + return "mp4" + case .m4v: + return "m4v" + default: + return "mov" + } + } +} diff --git a/Example/Cloudinary.xcodeproj/project.pbxproj b/Example/Cloudinary.xcodeproj/project.pbxproj index e146608e..5e3c3829 100644 --- a/Example/Cloudinary.xcodeproj/project.pbxproj +++ b/Example/Cloudinary.xcodeproj/project.pbxproj @@ -171,6 +171,7 @@ B67476172B8CA0EF006ED6C2 /* IntegrateAI.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B67476162B8CA0EF006ED6C2 /* IntegrateAI.storyboard */; }; B694AAF32B308C3C00075041 /* VideoEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B694AAF22B308C3C00075041 /* VideoEventsTests.swift */; }; B694AAF52B308C5A00075041 /* VideoEventsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B694AAF42B308C5A00075041 /* VideoEventsManagerTests.swift */; }; + B6B4C49F2C56152700C9B604 /* VideoPreprocessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4C49E2C56152700C9B604 /* VideoPreprocessTests.swift */; }; B6C2D4882A72741B00AA0039 /* CLDVideoPlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2D4872A72741B00AA0039 /* CLDVideoPlayerTests.swift */; }; B6F11F3D288FC20900A895CD /* CLDAnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F11F3C288FC20900A895CD /* CLDAnalyticsTests.swift */; }; D7119CE3246C7C8100F6B3ED /* CLDConditionExpressionHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7119CE2246C7C8100F6B3ED /* CLDConditionExpressionHelpersTests.swift */; }; @@ -413,6 +414,7 @@ B67476162B8CA0EF006ED6C2 /* IntegrateAI.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = IntegrateAI.storyboard; sourceTree = ""; }; B694AAF22B308C3C00075041 /* VideoEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEventsTests.swift; sourceTree = ""; }; B694AAF42B308C5A00075041 /* VideoEventsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEventsManagerTests.swift; sourceTree = ""; }; + B6B4C49E2C56152700C9B604 /* VideoPreprocessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreprocessTests.swift; sourceTree = ""; }; B6C2D4872A72741B00AA0039 /* CLDVideoPlayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDVideoPlayerTests.swift; sourceTree = ""; }; B6F11F3C288FC20900A895CD /* CLDAnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLDAnalyticsTests.swift; sourceTree = ""; }; D07C87FEDB1F561D123D6873 /* Pods_Cloudinary_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cloudinary_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -843,6 +845,7 @@ 607FACE81AFB9204008FA782 /* Tests */ = { isa = PBXGroup; children = ( + B6B4C49D2C56151100C9B604 /* Preprocess */, 272C513B242B6B6E0093AB1B /* BaseNetwork */, D7E79DCF24B515650082288A /* ParamTests */, D71ADD9A247D973E00235AD4 /* CryptoUtilsTests */, @@ -851,7 +854,6 @@ 274C6D1D23FBAF5E0090BC40 /* NetworkTests */, 5DBC4D49247BFE570014B37E /* TransformationTests */, 5D53A94E2488CDB7005C14AB /* ConfigurationTests */, - 274C6D1423FBAF5E0090BC40 /* PreprocessTests.swift */, 274C6D2323FBAF5E0090BC40 /* Resources */, 274C6D2223FBAF5E0090BC40 /* StringUtilsTest.swift */, 274C6D1923FBAF5E0090BC40 /* UIExtensions */, @@ -1292,6 +1294,15 @@ path = "Video Analytics"; sourceTree = ""; }; + B6B4C49D2C56151100C9B604 /* Preprocess */ = { + isa = PBXGroup; + children = ( + 274C6D1423FBAF5E0090BC40 /* PreprocessTests.swift */, + B6B4C49E2C56152700C9B604 /* VideoPreprocessTests.swift */, + ); + path = Preprocess; + sourceTree = ""; + }; D7173496258296CE006F34CD /* WidgetUITests */ = { isa = PBXGroup; children = ( @@ -1825,6 +1836,7 @@ D73117582473D7A30051AAFC /* CLDExpressionTests.m in Sources */, 1E635A8325A7585E0003E9D3 /* UploaderQualityAnalysisTests.swift in Sources */, 1E635A3E25A756870003E9D3 /* ObjcBaseTestCase.m in Sources */, + B6B4C49F2C56152700C9B604 /* VideoPreprocessTests.swift in Sources */, 274C6D3323FBAF5E0090BC40 /* UIButtonTests.swift in Sources */, 274C6D3223FBAF5E0090BC40 /* UIBaseTest.swift in Sources */, 5DB2D29924FFE32500001845 /* UploaderWidgetConfigurationTests.m in Sources */, diff --git a/Example/Tests/PreprocessTests.swift b/Example/Tests/Preprocess/PreprocessTests.swift similarity index 100% rename from Example/Tests/PreprocessTests.swift rename to Example/Tests/Preprocess/PreprocessTests.swift diff --git a/Example/Tests/Preprocess/VideoPreprocessTests.swift b/Example/Tests/Preprocess/VideoPreprocessTests.swift new file mode 100644 index 00000000..334641f9 --- /dev/null +++ b/Example/Tests/Preprocess/VideoPreprocessTests.swift @@ -0,0 +1,108 @@ +import XCTest +import AVFoundation +@testable import Cloudinary + +class VideoPreprocessTests: XCTestCase { + + var videoURL: URL! + var invalidVideoURL: URL! + var notAVideoURL: URL! + + // MARK: - Setup and TearDown + override func setUp() { + super.setUp() + let bundle = Bundle(for: VideoPreprocessTests.self) + videoURL = bundle.url(forResource: "dog", withExtension: "mp4") + invalidVideoURL = URL(fileURLWithPath: "/invalid/path/to/video.mp4") + notAVideoURL = bundle.url(forResource: "empty_string", withExtension: "txt") + } + + override func tearDown() { + super.tearDown() + videoURL = nil + invalidVideoURL = nil + notAVideoURL = nil + } + + // MARK: - Helper Methods + fileprivate func getVideoTranscode(from url: URL) -> CLDVideoTranscode { + return CLDVideoTranscode(sourceURL: url) + } + + // MARK: - Tests + func testSetOutputFormat() { + var video = getVideoTranscode(from: videoURL) + + video = try! CLDVideoPreprocessHelpers.setOutputFormat(format: .mp4)(video) + XCTAssertEqual(video.outputFormat, .mp4) + + video = try! CLDVideoPreprocessHelpers.setOutputFormat(format: .mov)(video) + XCTAssertEqual(video.outputFormat, .mov) + } + + func testSetOutputDimensions() { + var video = getVideoTranscode(from: videoURL) + let dimensions = CGSize(width: 1280, height: 720) + + video = try! CLDVideoPreprocessHelpers.setOutputDimensions(dimensions: dimensions)(video) + XCTAssertEqual(video.outputDimensions, dimensions) + } + + func testSetCompressionPreset() { + var video = getVideoTranscode(from: videoURL) + let preset = AVAssetExportPresetHighestQuality + + video = try! CLDVideoPreprocessHelpers.setCompressionPreset(preset: preset)(video) + XCTAssertEqual(video.compressionPreset, preset) + } + + func testDimensionsValidator() { + var video = getVideoTranscode(from: videoURL) + let dimensions = CGSize(width: 1280, height: 720) + + video = try! CLDVideoPreprocessHelpers.setOutputDimensions(dimensions: dimensions)(video) + + var modifiedVideo = try? CLDVideoPreprocessHelpers.dimensionsValidator(minWidth: 100, maxWidth: 2000, minHeight: 100, maxHeight: 2000)(video) + XCTAssertNotNil(modifiedVideo) + + modifiedVideo = try? CLDVideoPreprocessHelpers.dimensionsValidator(minWidth: 1300, maxWidth: 2000, minHeight: 100, maxHeight: 2000)(video) + XCTAssertNil(modifiedVideo) + } + + func testInvalidURL() { + let video = getVideoTranscode(from: invalidVideoURL) + XCTAssertThrowsError(try CLDVideoPreprocessHelpers.setOutputFormat(format: .mp4)(video)) { error in + XCTAssertEqual((error as NSError).domain, "CLDVideoPreprocessHelpers") + } + } + + func testNotAVideo() { + let video = getVideoTranscode(from: notAVideoURL) + XCTAssertThrowsError(try CLDVideoPreprocessHelpers.setOutputFormat(format: .mp4)(video)) { error in + XCTAssertEqual((error as NSError).domain, "CLDVideoPreprocessHelpers") + } + } + + func testMissingVideoTrack() { + let video = getVideoTranscode(from: videoURL) + XCTAssertTrue(video.hasVideoTrack, "Original video should have a video track") + + let noVideoTrackURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension("mov") + + let fileManager = FileManager.default + let fileContents = Data() + fileManager.createFile(atPath: noVideoTrackURL.path, contents: fileContents) + + let noVideoTrackTranscode = getVideoTranscode(from: noVideoTrackURL) + XCTAssertFalse(noVideoTrackTranscode.hasVideoTrack, "The mock video should not have a video track") + + do { + _ = try CLDVideoPreprocessHelpers.setOutputFormat(format: .mp4)(noVideoTrackTranscode) + XCTFail("Expected to throw an error for missing video track") + } catch { + XCTAssertEqual((error as NSError).domain, "CLDVideoPreprocessHelpers") + } + } + + +}