├── .gitignore ├── Sources └── FYVideoCompressor │ ├── extensions │ ├── FourCharCode+ToString.swift │ ├── URL+FileSize.swift │ ├── AVFileType+FileExtension.swift │ └── FileManager+TempDirectory.swift │ ├── VideoFrameReducer.swift │ └── FYVideoCompressor.swift ├── LICENSE ├── Package.swift ├── FYVideoCompressor.podspec ├── README.md └── Tests └── FYVideoCompressorTests ├── TestUserDefinedPath.swift ├── BatchCompressionTests.swift └── FYVideoCompressorTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/extensions/FourCharCode+ToString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by xiaoyang on 2023/6/5. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FourCharCode { 11 | internal func toString() -> String { 12 | let result = String(format: "%c%c%c%c", 13 | (self >> 24) & 0xff, 14 | (self >> 16) & 0xff, 15 | (self >> 8) & 0xff, 16 | self & 0xff) 17 | let characterSet = CharacterSet.whitespaces 18 | return result.trimmingCharacters(in: characterSet) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/extensions/URL+FileSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+FileSize.swift 3 | // FYVideoCompressor 4 | // 5 | // Created by xiaoyang on 2021/12/02. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | /// File url video memory footprint. 12 | /// Remote url will return 0. 13 | /// - Returns: memory size 14 | func sizePerMB() -> Double { 15 | guard isFileURL else { return 0 } 16 | do { 17 | let attribute = try FileManager.default.attributesOfItem(atPath: path) 18 | if let size = attribute[FileAttributeKey.size] as? NSNumber { 19 | return size.doubleValue / (1024 * 1024) 20 | } 21 | } catch { 22 | print("Error: \(error)") 23 | } 24 | return 0.0 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/extensions/AVFileType+FileExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AVFileType.swift 3 | // 4 | // 5 | // Created by xiaoyang on 2021/11/11. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | #if !os(macOS) 11 | import MobileCoreServices 12 | #endif 13 | 14 | extension AVFileType { 15 | /// Fetch and extension for a file from UTI string 16 | var fileExtension: String { 17 | if #available(iOS 14.0, macOS 11.0, *) { 18 | if let utType = UTType(self.rawValue) { 19 | print("utType.preferredMIMEType: \(String(describing: utType.preferredMIMEType))") 20 | return utType.preferredFilenameExtension ?? "None" 21 | } 22 | return "None" 23 | } else { 24 | if let ext = UTTypeCopyPreferredTagWithClass(self as CFString, 25 | kUTTagClassFilenameExtension)?.takeRetainedValue() { 26 | return ext as String 27 | } 28 | return "None" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 T2Je 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FYVideoCompressor", 8 | platforms: [ 9 | // macOS 10.13 and up. 10 | .iOS(.v11), .macOS(.v10_13) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "FYVideoCompressor", 16 | targets: ["FYVideoCompressor"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "FYVideoCompressor", 27 | dependencies: []), 28 | .testTarget( 29 | name: "FYVideoCompressorTests", 30 | dependencies: ["FYVideoCompressor"], 31 | resources: []), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /FYVideoCompressor.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint FYVideoCompressorpodspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'FYVideoCompressor' 11 | s.version = '0.0.9' 12 | s.summary = 'A high-performance, flexible and easy to use Video compressor library written by Swift.' 13 | 14 | # This description is used to generate tags and improve search results. 15 | # * Think: What does it do? Why did you write it? What is the focus? 16 | # * Try to keep it short, snappy and to the point. 17 | # * Write the description between the DESC delimiters below. 18 | # * Finally, don't worry about the indent, CocoaPods strips it! 19 | 20 | s.description = <<-DESC 21 | A high-performance, flexible and easy to use Video compressor library written by Swift. Using hardware-accelerator APIs in AVFoundation. 22 | DESC 23 | 24 | s.homepage = 'https://github.com/T2Je' 25 | # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' 26 | s.license = { :type => 'MIT', :file => 'LICENSE' } 27 | s.author = { 't2je' => 't2je@outlook.com' } 28 | s.source = { :git => 'https://github.com/T2Je/FYVideoCompressor.git', :tag => s.version.to_s } 29 | # s.social_media_url = 'https://twitter.com/' 30 | 31 | s.ios.deployment_target = '11' 32 | s.osx.deployment_target = '10.13' 33 | s.swift_version = '5' 34 | 35 | s.source_files = 'Sources/FYVideoCompressor/**/*' 36 | 37 | s.frameworks = 'AVFoundation' 38 | 39 | end 40 | 41 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/extensions/FileManager+TempDirectory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+TempDirectory.swift 3 | // FYVideoCompressor 4 | // 5 | // Created by xiaoyang on 2021/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileManager { 11 | enum CreateTempDirectoryError: Error, LocalizedError { 12 | case fileExsisted 13 | 14 | var errorDescription: String? { 15 | switch self { 16 | case .fileExsisted: 17 | return "File exsisted" 18 | } 19 | } 20 | } 21 | /// Get temp directory. If it exsists, return it, else create it. 22 | /// - Parameter pathComponent: path to append to temp directory. 23 | /// - Throws: error when create temp directory. 24 | /// - Returns: temp directory location. 25 | /// - Warning: Every time you call this function will return a different directory. 26 | static func tempDirectory(with pathComponent: String = ProcessInfo.processInfo.globallyUniqueString) -> URL { 27 | var tempURL: URL 28 | 29 | // Only the volume(卷) of cache url is used. 30 | let cacheURL = FileManager.default.temporaryDirectory 31 | if let url = try? FileManager.default.url(for: .itemReplacementDirectory, 32 | in: .userDomainMask, 33 | appropriateFor: cacheURL, 34 | create: true) { 35 | tempURL = url 36 | } else { 37 | tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) 38 | } 39 | 40 | tempURL.appendPathComponent(pathComponent) 41 | 42 | if !FileManager.default.fileExists(atPath: tempURL.path) { 43 | do { 44 | try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) 45 | } catch { 46 | tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(pathComponent, isDirectory: true) 47 | } 48 | } 49 | #if DEBUG 50 | print("temp directory path \(tempURL)") 51 | #endif 52 | return tempURL 53 | } 54 | 55 | func isValidDirectory(atPath path: URL) -> Bool { 56 | var isDir : ObjCBool = false 57 | if FileManager.default.fileExists(atPath: path.path, isDirectory:&isDir) { 58 | return isDir.boolValue 59 | } else { 60 | return false 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/VideoFrameReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by xiaoyang on 2023/6/5. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A strategy protocol to let users to define their own strategy of reducing fps 11 | public protocol VideoFrameReducer { 12 | /// Get frame buffer index array 13 | /// - Parameters: 14 | /// - originalFPS: original fps 15 | /// - targetFPS: target fps 16 | /// - videoDuration: video duration 17 | /// - Returns: frame buffer index array 18 | func reduce(originalFPS: Float, to targetFPS: Float, with videoDuration: Float) -> [Int] 19 | } 20 | 21 | /// Get frame index array evenly spaced 22 | public struct ReduceFrameEvenlySpaced: VideoFrameReducer { 23 | public init() {} 24 | 25 | public func reduce(originalFPS: Float, to targetFPS: Float, with videoDuration: Float) -> [Int] { 26 | let stride = Int(originalFPS / targetFPS) 27 | var counter = 0 28 | 29 | let originalFrames = (0.. [Int] { 49 | let originalFrames = Int(originalFPS * videoDuration) 50 | let targetFrames = Int(videoDuration * targetFPS) 51 | 52 | // 53 | var rangeArr = Array(repeating: 0, count: targetFrames) 54 | for i in 0.. 1 else { 67 | return randomFrames 68 | } 69 | 70 | for index in 1.. compressedVideoPath.sizePerMB()) 68 | } 69 | 70 | // MARK: Download sample video 71 | func downloadSampleVideo(_ completion: @escaping ((Result) -> Void)) { 72 | if FileManager.default.fileExists(atPath: self.sampleVideoPath.path) { 73 | completion(.success(self.sampleVideoPath)) 74 | } else { 75 | request(Self.testVideoURL) { result in 76 | switch result { 77 | case .success(let data): 78 | do { 79 | try (data as NSData).write(to: self.sampleVideoPath, options: NSData.WritingOptions.atomic) 80 | completion(.success(self.sampleVideoPath)) 81 | } catch { 82 | completion(.failure(error)) 83 | } 84 | case .failure(let error): 85 | completion(.failure(error)) 86 | } 87 | } 88 | } 89 | } 90 | 91 | func request(_ url: URL, completion: @escaping ((Result) -> Void)) { 92 | if task != nil { 93 | task?.cancel() 94 | } 95 | 96 | let task = URLSession.shared.dataTask(with: url) { (data, response, error) in 97 | if let error = error { 98 | DispatchQueue.main.async { 99 | completion(.failure(error)) 100 | } 101 | self.task = nil 102 | return 103 | } 104 | 105 | guard let httpResponse = response as? HTTPURLResponse else { 106 | self.task = nil 107 | return 108 | } 109 | 110 | if (200...299).contains(httpResponse.statusCode) { 111 | if let data = data { 112 | DispatchQueue.main.async { 113 | self.task = nil 114 | completion(.success(data)) 115 | } 116 | } 117 | } else { 118 | let domain = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) 119 | let error = NSError(domain: domain, code: httpResponse.statusCode, userInfo: nil) 120 | DispatchQueue.main.async { 121 | self.task = nil 122 | completion(.failure(error)) 123 | } 124 | } 125 | } 126 | task.resume() 127 | self.task = task 128 | } 129 | 130 | func createCustomDirectory() -> URL? { 131 | let fileManager = FileManager.default 132 | let documentsDirectory = try! fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 133 | let directoryURL = documentsDirectory.appendingPathComponent("MyDirectory") 134 | 135 | do { 136 | try fileManager.createDirectory(atPath: directoryURL.path, withIntermediateDirectories: true, attributes: nil) 137 | return directoryURL 138 | } catch { 139 | print(error.localizedDescription) 140 | return nil 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tests/FYVideoCompressorTests/BatchCompressionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BatchCompressionTests.swift 3 | // FYVideoCompressorTests 4 | // 5 | // Created by xiaoyang on 2022/6/17. 6 | // 7 | 8 | import XCTest 9 | @testable import FYVideoCompressor 10 | 11 | class BatchCompressionTests: XCTestCase { 12 | 13 | // "http://clips.vorwaerts-gmbh.de/VfE_html5.mp4" 14 | let sampleVideoURLs = [ 15 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mov-file.mov", 16 | "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4", 17 | "http://clips.vorwaerts-gmbh.de/VfE_html5.mp4" 18 | ] 19 | 20 | var sampleVideoPath: [URL: URL] = [:] 21 | var compressedVideoPath: [URL: URL] = [:] 22 | 23 | var tasks: [URL: URLSessionDataTask] = [:] 24 | 25 | func setupSampleVideoPath() { 26 | sampleVideoURLs.forEach { urlStr in 27 | if let url = URL(string: urlStr) { 28 | sampleVideoPath[url] = FileManager.tempDirectory(with: "UnitTestSampleVideo").appendingPathComponent("\(url.lastPathComponent)") 29 | } 30 | } 31 | } 32 | 33 | override func setUpWithError() throws { 34 | setupSampleVideoPath() 35 | let expectation = XCTestExpectation(description: "video cache downloading remote video") 36 | var error: Error? 37 | 38 | var allSampleVideosCount = sampleVideoPath.count 39 | 40 | sampleVideoURLs.forEach { urlStr in 41 | downloadSampleVideo(URL(string: urlStr)!) { result in 42 | switch result { 43 | case .failure(let _error): 44 | print("💀failed to download sample video:(\(urlStr)) with error: \(_error)") 45 | error = _error 46 | case .success(let path): 47 | print("sample video downloaded at path: \(path)") 48 | allSampleVideosCount -= 1 49 | if allSampleVideosCount <= 0 { 50 | expectation.fulfill() 51 | } 52 | } 53 | } 54 | } 55 | 56 | if let error = error { 57 | throw error 58 | } 59 | wait(for: [expectation], timeout: 300) 60 | // Put setup code here. This method is called before the invocation of each test method in the class. 61 | } 62 | 63 | override func tearDownWithError() throws { 64 | tasks.values.forEach { 65 | $0.cancel() 66 | } 67 | sampleVideoPath.values.forEach { 68 | try? FileManager.default.removeItem(at: $0) 69 | } 70 | 71 | compressedVideoPath.values.forEach { 72 | try? FileManager.default.removeItem(at: $0) 73 | } 74 | } 75 | 76 | func testCompressVideo() { 77 | let expectation = XCTestExpectation(description: "compress video") 78 | 79 | var allSampleVideosCount = sampleVideoPath.count 80 | 81 | sampleVideoPath.values.forEach { sampleVideo in 82 | FYVideoCompressor().compressVideo(sampleVideo, quality: .lowQuality) { result in 83 | switch result { 84 | case .success(let video): 85 | self.compressedVideoPath[sampleVideo] = video 86 | 87 | allSampleVideosCount -= 1 88 | if allSampleVideosCount <= 0 { 89 | expectation.fulfill() 90 | } 91 | case .failure(let error): 92 | XCTFail(error.localizedDescription) 93 | } 94 | } 95 | } 96 | 97 | wait(for: [expectation], timeout: 300) 98 | XCTAssertNotNil(compressedVideoPath) 99 | // XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath!.sizePerMB()) 100 | } 101 | 102 | 103 | func testPerformanceExample() throws { 104 | // This is an example of a performance test case. 105 | self.measure { 106 | // Put the code you want to measure the time of here. 107 | } 108 | } 109 | 110 | // MARK: Download sample video 111 | func downloadSampleVideo(_ url: URL, _ completion: @escaping ((Result) -> Void)) { 112 | let sampleVideoCachedURL: URL 113 | if let path = sampleVideoPath[url] { 114 | sampleVideoCachedURL = path 115 | } else { 116 | sampleVideoCachedURL = FileManager.tempDirectory(with: "UnitTestSampleVideo").appendingPathComponent("\(url.lastPathComponent)") 117 | sampleVideoPath[url] = sampleVideoCachedURL 118 | } 119 | if FileManager.default.fileExists(atPath: sampleVideoCachedURL.path) { 120 | completion(.success(sampleVideoCachedURL)) 121 | } else { 122 | request(url) { result in 123 | switch result { 124 | case .success(let data): 125 | do { 126 | try (data as NSData).write(to: sampleVideoCachedURL, options: NSData.WritingOptions.atomic) 127 | completion(.success(sampleVideoCachedURL)) 128 | } catch { 129 | completion(.failure(error)) 130 | } 131 | case .failure(let error): 132 | completion(.failure(error)) 133 | } 134 | } 135 | } 136 | } 137 | 138 | func request(_ url: URL, completion: @escaping ((Result) -> Void)) { 139 | tasks[url]?.cancel() 140 | print("Donwloading \(url.absoluteString)") 141 | let task = URLSession.shared.dataTask(with: url) { (data, response, error) in 142 | if let error = error { 143 | DispatchQueue.main.async { 144 | completion(.failure(error)) 145 | } 146 | self.tasks[url] = nil 147 | return 148 | } 149 | 150 | guard let httpResponse = response as? HTTPURLResponse else { 151 | self.tasks[url] = nil 152 | return 153 | } 154 | 155 | if (200...299).contains(httpResponse.statusCode) { 156 | if let data = data { 157 | DispatchQueue.main.async { 158 | self.tasks[url] = nil 159 | completion(.success(data)) 160 | } 161 | } 162 | } else { 163 | let domain = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) 164 | let error = NSError(domain: domain, code: httpResponse.statusCode, userInfo: nil) 165 | DispatchQueue.main.async { 166 | self.tasks[url] = nil 167 | completion(.failure(error)) 168 | } 169 | } 170 | } 171 | task.resume() 172 | self.tasks[url] = task 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Tests/FYVideoCompressorTests/FYVideoCompressorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FYVideoCompressor 3 | import AVFoundation 4 | 5 | final class FYVideoCompressorTests: XCTestCase { 6 | // sample video websites: https://file-examples.com/index.php/sample-video-files/sample-mp4-files/ 7 | // https://www.learningcontainer.com/mp4-sample-video-files-download/#Sample_MP4_Video_File_Download_for_Testing 8 | 9 | // http://clips.vorwaerts-gmbh.de/VfE_html5.mp4 5.3 10 | // static let testVideoURL = URL(string: "https://file-examples.com/storage/fe92e8a57762aaf72faee17/2017/04/file_example_MP4_1280_10MG.mp4")! // video size 5.3M 11 | 12 | // static let testVideoURL = URL(string: "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mov-file.mov")! 13 | 14 | // https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-MP4-Video-File-for-Testing.mp4 15 | static let testVideoURL = URL(string: "https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-MP4-Video-File-for-Testing.mp4")! 16 | 17 | // let localVideoURL = Bundle.module.url(forResource: "sample2", withExtension: "MOV")! 18 | 19 | let sampleVideoPath: URL = FileManager.tempDirectory(with: "UnitTestSampleVideo").appendingPathComponent("sample.mp4") 20 | // var compressedVideoPath: URL? 21 | var compressedVideoPaths: [URL] = [] 22 | 23 | var task: URLSessionDataTask? 24 | 25 | let compressor = FYVideoCompressor() 26 | 27 | override func setUpWithError() throws { 28 | let expectation = XCTestExpectation(description: "video cache downloading remote video") 29 | var error: Error? 30 | downloadSampleVideo { result in 31 | switch result { 32 | case .failure(let _error): 33 | print("failed to download sample video: \(_error)") 34 | error = _error 35 | case .success(let path): 36 | print("sample video downloaded at path: \(path)") 37 | expectation.fulfill() 38 | } 39 | } 40 | if let error = error { 41 | throw error 42 | } 43 | wait(for: [expectation], timeout: 100) 44 | } 45 | 46 | override func tearDownWithError() throws { 47 | task?.cancel() 48 | 49 | try FileManager.default.removeItem(at: sampleVideoPath) 50 | 51 | for path in compressedVideoPaths { 52 | try FileManager.default.removeItem(at: path) 53 | } 54 | } 55 | 56 | func testAVFileTypeExtension() { 57 | let mp4Extension = AVFileType("public.mpeg-4") 58 | XCTAssertEqual(mp4Extension.fileExtension, "mp4") 59 | 60 | let movExtension = AVFileType("com.apple.quicktime-movie") 61 | XCTAssertEqual(movExtension.fileExtension, "mov") 62 | } 63 | 64 | func testGetRandomFramesIndexesCount() { 65 | let arr = compressor.getFrameIndexesWith(originalFPS: 50, targetFPS: 30, duration: 10) 66 | XCTAssertEqual(arr.count, 300) 67 | } 68 | 69 | func testCompressVideo() { 70 | let expectation = XCTestExpectation(description: "compress video") 71 | 72 | // var sampleVideoPath = localVideoURL // sampleVideoPath 73 | var compressedVideoPath: URL! 74 | compressor.compressVideo(sampleVideoPath, quality: .lowQuality, frameReducer: ReduceFrameRandomly()) { result in 75 | switch result { 76 | case .success(let video): 77 | compressedVideoPath = video 78 | expectation.fulfill() 79 | case .failure(let error): 80 | XCTFail(error.localizedDescription) 81 | } 82 | } 83 | wait(for: [expectation], timeout: 30) 84 | XCTAssertNotNil(compressedVideoPath) 85 | compressedVideoPaths.append(compressedVideoPath) 86 | XCTAssertTrue(sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 87 | } 88 | 89 | func testCompressVideoWithScale() { 90 | let expectation = XCTestExpectation(description: "compress video") 91 | let config = FYVideoCompressor.CompressionConfig(scale: CGSize(width: -1, height: -1)) 92 | 93 | var compressedVideoPath: URL! 94 | compressor.compressVideo(sampleVideoPath, config: config) { result in 95 | switch result { 96 | case .success(let video): 97 | compressedVideoPath = video 98 | expectation.fulfill() 99 | case .failure(let error): 100 | XCTFail(error.localizedDescription) 101 | } 102 | } 103 | 104 | wait(for: [expectation], timeout: 30) 105 | XCTAssertNotNil(compressedVideoPath) 106 | compressedVideoPaths.append(compressedVideoPath) 107 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 108 | } 109 | 110 | func testCompressVideoWithVideoBitrate() { 111 | let expectation = XCTestExpectation(description: "compress video") 112 | let config = FYVideoCompressor.CompressionConfig(videoBitrate: 200_000) 113 | 114 | var compressedVideoPath: URL! 115 | compressor.compressVideo(sampleVideoPath, config: config) { result in 116 | switch result { 117 | case .success(let video): 118 | compressedVideoPath = video 119 | expectation.fulfill() 120 | case .failure(let error): 121 | XCTFail(error.localizedDescription) 122 | } 123 | } 124 | 125 | wait(for: [expectation], timeout: 30) 126 | XCTAssertNotNil(compressedVideoPath) 127 | compressedVideoPaths.append(compressedVideoPath) 128 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 129 | } 130 | 131 | func testCompressVideoWithVideomaxKeyFrameInterval() { 132 | let expectation = XCTestExpectation(description: "compress video") 133 | let config = FYVideoCompressor.CompressionConfig(videomaxKeyFrameInterval: 1) 134 | 135 | var compressedVideoPath: URL! 136 | compressor.compressVideo(sampleVideoPath, config: config) { result in 137 | switch result { 138 | case .success(let video): 139 | compressedVideoPath = video 140 | expectation.fulfill() 141 | case .failure(let error): 142 | XCTFail(error.localizedDescription) 143 | } 144 | } 145 | 146 | wait(for: [expectation], timeout: 30) 147 | XCTAssertNotNil(compressedVideoPath) 148 | 149 | compressedVideoPaths.append(compressedVideoPath) 150 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 151 | } 152 | 153 | func testCompressVideoWithFPS() { 154 | let expectation = XCTestExpectation(description: "compress video") 155 | let config = FYVideoCompressor.CompressionConfig(fps: 24) 156 | 157 | var compressedVideoPath: URL! 158 | compressor.compressVideo(sampleVideoPath, config: config) { result in 159 | switch result { 160 | case .success(let video): 161 | compressedVideoPath = video 162 | expectation.fulfill() 163 | case .failure(let error): 164 | XCTFail(error.localizedDescription) 165 | } 166 | } 167 | 168 | wait(for: [expectation], timeout: 30) 169 | XCTAssertNotNil(compressedVideoPath) 170 | 171 | compressedVideoPaths.append(compressedVideoPath) 172 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 173 | } 174 | 175 | func testCompressVideoWithAudioSampleRate() { 176 | let expectation = XCTestExpectation(description: "compress video") 177 | let config = FYVideoCompressor.CompressionConfig(audioSampleRate: 44100) 178 | 179 | var compressedVideoPath: URL! 180 | compressor.compressVideo(sampleVideoPath, config: config) { result in 181 | switch result { 182 | case .success(let video): 183 | compressedVideoPath = video 184 | expectation.fulfill() 185 | case .failure(let error): 186 | XCTFail(error.localizedDescription) 187 | } 188 | } 189 | 190 | wait(for: [expectation], timeout: 30) 191 | XCTAssertNotNil(compressedVideoPath) 192 | compressedVideoPaths.append(compressedVideoPath) 193 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 194 | } 195 | 196 | func testCompressVideoWithAudioBitrate() { 197 | let expectation = XCTestExpectation(description: "compress video") 198 | let config = FYVideoCompressor.CompressionConfig(audioBitrate: 128000) 199 | 200 | var compressedVideoPath: URL! 201 | compressor.compressVideo(sampleVideoPath, config: config) { result in 202 | switch result { 203 | case .success(let video): 204 | compressedVideoPath = video 205 | expectation.fulfill() 206 | case .failure(let error): 207 | XCTFail(error.localizedDescription) 208 | } 209 | } 210 | 211 | wait(for: [expectation], timeout: 30) 212 | XCTAssertNotNil(compressedVideoPath) 213 | compressedVideoPaths.append(compressedVideoPath) 214 | XCTAssertTrue(self.sampleVideoPath.sizePerMB() > compressedVideoPath.sizePerMB()) 215 | } 216 | 217 | func testTargetVideoSizeWithQuality() { 218 | let targetSize = compressor.calculateSizeWithQuality(.lowQuality, originalSize: CGSize(width: 1920, height: 1080)) 219 | XCTAssertEqual(targetSize, CGSize(width: 398, height: 224)) 220 | } 221 | 222 | func testTargetVideoSizeWithConfig() { 223 | let scale1 = compressor.calculateSizeWithScale(CGSize(width: -1, height: 224), originalSize: CGSize(width: 1920, height: 1080)) 224 | XCTAssertEqual(scale1, CGSize(width: 398, height: 224)) 225 | 226 | let scale2 = compressor.calculateSizeWithScale(CGSize(width: 640, height: -1), originalSize: CGSize(width: 1920, height: 1080)) 227 | XCTAssertEqual(scale2, CGSize(width: 640, height: 360)) 228 | } 229 | 230 | // MARK: Download sample video 231 | func downloadSampleVideo(_ completion: @escaping ((Result) -> Void)) { 232 | if FileManager.default.fileExists(atPath: self.sampleVideoPath.path) { 233 | completion(.success(self.sampleVideoPath)) 234 | } else { 235 | request(Self.testVideoURL) { result in 236 | switch result { 237 | case .success(let data): 238 | do { 239 | try (data as NSData).write(to: self.sampleVideoPath, options: NSData.WritingOptions.atomic) 240 | completion(.success(self.sampleVideoPath)) 241 | } catch { 242 | completion(.failure(error)) 243 | } 244 | case .failure(let error): 245 | completion(.failure(error)) 246 | } 247 | } 248 | } 249 | } 250 | 251 | func request(_ url: URL, completion: @escaping ((Result) -> Void)) { 252 | if task != nil { 253 | task?.cancel() 254 | } 255 | 256 | let task = URLSession.shared.dataTask(with: url) { (data, response, error) in 257 | if let error = error { 258 | DispatchQueue.main.async { 259 | completion(.failure(error)) 260 | } 261 | self.task = nil 262 | return 263 | } 264 | 265 | guard let httpResponse = response as? HTTPURLResponse else { 266 | self.task = nil 267 | return 268 | } 269 | 270 | if (200...299).contains(httpResponse.statusCode) { 271 | if let data = data { 272 | DispatchQueue.main.async { 273 | self.task = nil 274 | completion(.success(data)) 275 | } 276 | } 277 | } else { 278 | let domain = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) 279 | let error = NSError(domain: domain, code: httpResponse.statusCode, userInfo: nil) 280 | DispatchQueue.main.async { 281 | self.task = nil 282 | completion(.failure(error)) 283 | } 284 | } 285 | } 286 | task.resume() 287 | self.task = task 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Sources/FYVideoCompressor/FYVideoCompressor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AVFoundation 3 | import CoreMedia 4 | 5 | // sample video https://download.blender.org/demo/movies/BBB/ 6 | 7 | /// A high-performance, flexible and easy to use Video compressor library written by Swift. 8 | /// Using hardware-accelerator APIs in AVFoundation. 9 | public class FYVideoCompressor { 10 | public enum VideoCompressorError: Error, LocalizedError { 11 | case noVideo 12 | case compressedFailed(_ error: Error) 13 | case outputPathNotValid(_ path: URL) 14 | 15 | public var errorDescription: String? { 16 | switch self { 17 | case .noVideo: 18 | return "No video" 19 | case .compressedFailed(let error): 20 | return error.localizedDescription 21 | case .outputPathNotValid(let path): 22 | return "Output path is invalid: \(path)" 23 | } 24 | } 25 | } 26 | 27 | /// Quality configuration. VideoCompressor will compress video by decreasing fps and bitrate. 28 | /// Bitrate has a minimum value: `minimumVideoBitrate`, you can change it if need. 29 | /// The video will be compressed using H.264, audio will be compressed using AAC. 30 | public enum VideoQuality: Equatable { 31 | /// Scale video size proportionally, not large than 224p and 32 | /// reduce fps and bit rate if need. 33 | case lowQuality 34 | 35 | /// Scale video size proportionally, not large than 480p and 36 | /// reduce fps and bit rate if need. 37 | case mediumQuality 38 | 39 | /// Scale video size proportionally, not large than 1080p and 40 | /// reduce fps and bit rate if need. 41 | case highQuality 42 | 43 | /// reduce fps and bit rate if need. 44 | /// Scale video size with specified `scale`. 45 | case custom(fps: Float = 24, bitrate: Int = 1000_000, scale: CGSize) 46 | 47 | /// fps and bitrate. 48 | /// This bitrate value is the maximum value. Depending on the video original bitrate, the video bitrate after compressing may be lower than this value. 49 | /// Considering that the video size taken by mobile phones is reversed, we don't hard code scale value. 50 | var value: (fps: Float, bitrate: Int) { 51 | switch self { 52 | case .lowQuality: 53 | return (15, 250_000) 54 | case .mediumQuality: 55 | return (24, 2500_000) 56 | case .highQuality: 57 | return (30, 8000_000) 58 | case .custom(fps: let fps, bitrate: let bitrate, _): 59 | return (fps, bitrate) 60 | } 61 | } 62 | 63 | } 64 | 65 | // Compression Encode Parameters 66 | public struct CompressionConfig { 67 | //Tag: video 68 | 69 | /// Config video bitrate. 70 | /// If the input video bitrate is less than this value, it will be ignored. 71 | /// bitrate use 1000 for 1kbps. https://en.wikipedia.org/wiki/Bit_rate. 72 | /// Default is 1Mbps 73 | public var videoBitrate: Int 74 | 75 | /// A key to access the maximum interval between keyframes. 1 means key frames only, H.264 only. Default is 10. 76 | public var videomaxKeyFrameInterval: Int // 77 | 78 | /// If video's fps less than this value, this value will be ignored. Default is 24. 79 | public var fps: Float 80 | 81 | //Tag: audio 82 | 83 | /// Sample rate must be between 8.0 and 192.0 kHz inclusive 84 | /// Default 44100 85 | public var audioSampleRate: Int 86 | 87 | /// Default is 128_000 88 | /// If the input audio bitrate is less than this value, it will be ignored. 89 | public var audioBitrate: Int 90 | 91 | /// Default is mp4 92 | public var fileType: AVFileType 93 | 94 | /// Scale (resize) the input video 95 | /// 1. If you need to simply resize your video to a specific size (e.g 320×240), you can use the scale: CGSize(width: 320, height: 240) 96 | /// 2. If you want to keep the aspect ratio, you need to specify only one component, either width or height, and set the other component to -1 97 | /// e.g CGSize(width: 320, height: -1) 98 | public var scale: CGSize? 99 | 100 | /// compressed video will be moved to this path. If no value is set, `FYVideoCompressor` will create it for you. 101 | /// Default is nil. 102 | public var outputPath: URL? 103 | 104 | 105 | public init() { 106 | self.videoBitrate = 1000_000 107 | self.videomaxKeyFrameInterval = 10 108 | self.fps = 24 109 | self.audioSampleRate = 44100 110 | self.audioBitrate = 128_000 111 | self.fileType = .mp4 112 | self.scale = nil 113 | self.outputPath = nil 114 | } 115 | 116 | public init(videoBitrate: Int = 1000_000, 117 | videomaxKeyFrameInterval: Int = 10, 118 | fps: Float = 24, 119 | audioSampleRate: Int = 44100, 120 | audioBitrate: Int = 128_000, 121 | fileType: AVFileType = .mp4, 122 | scale: CGSize? = nil, 123 | outputPath: URL? = nil) { 124 | self.videoBitrate = videoBitrate 125 | self.videomaxKeyFrameInterval = videomaxKeyFrameInterval 126 | self.fps = fps 127 | self.audioSampleRate = audioSampleRate 128 | self.audioBitrate = audioBitrate 129 | self.fileType = fileType 130 | self.scale = scale 131 | self.outputPath = outputPath 132 | } 133 | } 134 | 135 | private let group = DispatchGroup() 136 | private let videoCompressQueue = DispatchQueue.init(label: "com.video.compress_queue") 137 | private lazy var audioCompressQueue = DispatchQueue.init(label: "com.audio.compress_queue") 138 | private var reader: AVAssetReader? 139 | private var writer: AVAssetWriter? 140 | private var compressVideoPaths: [URL] = [] 141 | 142 | @available(*, deprecated, renamed: "init()", message: "In the case of batch compression, singleton causes a crash, be sure to use init method - init()") 143 | static public let shared: FYVideoCompressor = FYVideoCompressor() 144 | 145 | public var videoFrameReducer: VideoFrameReducer! 146 | 147 | public init() { } 148 | 149 | /// Youtube suggests 1Mbps for 24 frame rate 360p video, 1Mbps = 1000_000bps. 150 | /// Custom quality will not be affected by this value. 151 | static public var minimumVideoBitrate = 1000 * 200 152 | 153 | /// Compress Video with quality. 154 | 155 | /// Compress Video with quality. 156 | /// - Parameters: 157 | /// - url: path of the video that needs to be compressed 158 | /// - quality: the quality of the output video. Default is mediumQuality. 159 | /// - outputPath: compressed video will be moved to this path. If no value is set, `FYVideoCompressor` will create it for you. Default is nil. 160 | /// - frameReducer: video frame reducer to reduce fps of the video. 161 | /// - completion: completion block 162 | public func compressVideo(_ url: URL, 163 | quality: VideoQuality = .mediumQuality, 164 | outputPath: URL? = nil, 165 | frameReducer: VideoFrameReducer = ReduceFrameEvenlySpaced(), 166 | completion: @escaping (Result) -> Void) { 167 | self.videoFrameReducer = frameReducer 168 | let asset = AVAsset(url: url) 169 | // setup 170 | guard let videoTrack = asset.tracks(withMediaType: .video).first else { 171 | completion(.failure(VideoCompressorError.noVideo)) 172 | return 173 | } 174 | 175 | print("video codec type: \(videoCodecType(for: videoTrack))") 176 | 177 | // --- Video --- 178 | // video bit rate 179 | let targetVideoBitrate = getVideoBitrateWithQuality(quality, originalBitrate: videoTrack.estimatedDataRate) 180 | 181 | // scale size 182 | let scaleSize = calculateSizeWithQuality(quality, originalSize: videoTrack.naturalSize) 183 | 184 | let videoSettings = createVideoSettingsWithBitrate(targetVideoBitrate, 185 | maxKeyFrameInterval: 10, 186 | size: scaleSize) 187 | #if DEBUG 188 | print("************** Video info **************") 189 | #endif 190 | var audioTrack: AVAssetTrack? 191 | var audioSettings: [String: Any]? 192 | if let adTrack = asset.tracks(withMediaType: .audio).first { 193 | // --- Audio --- 194 | audioTrack = adTrack 195 | let audioBitrate: Int 196 | let audioSampleRate: Int 197 | 198 | audioBitrate = quality == .lowQuality ? 96_000 : 128_000 // 96_000 199 | audioSampleRate = 44100 200 | audioSettings = createAudioSettingsWithAudioTrack(adTrack, bitrate: Float(audioBitrate), sampleRate: audioSampleRate) 201 | } 202 | #if DEBUG 203 | print("🎬 Video ") 204 | print("ORIGINAL:") 205 | print("video size: \(url.sizePerMB())M") 206 | print("bitrate: \(videoTrack.estimatedDataRate) b/s") 207 | print("fps: \(videoTrack.nominalFrameRate)") // 208 | print("scale size: \(videoTrack.naturalSize)") 209 | 210 | print("TARGET:") 211 | print("video bitrate: \(targetVideoBitrate) b/s") 212 | print("fps: \(quality.value.fps)") 213 | print("scale size: (\(scaleSize))") 214 | 215 | print("****************************************") 216 | #endif 217 | var _outputPath: URL 218 | if let outputPath = outputPath { 219 | _outputPath = outputPath 220 | } else { 221 | _outputPath = FileManager.tempDirectory(with: "CompressedVideo") 222 | } 223 | _compress(asset: asset, 224 | fileType: .mp4, 225 | videoTrack, 226 | videoSettings, 227 | audioTrack, 228 | audioSettings, 229 | targetFPS: quality.value.fps, 230 | outputPath: _outputPath, 231 | completion: completion) 232 | } 233 | 234 | /// Compress Video with config. 235 | public func compressVideo(_ url: URL, config: CompressionConfig, frameReducer: VideoFrameReducer = ReduceFrameEvenlySpaced(), completion: @escaping (Result) -> Void) { 236 | self.videoFrameReducer = frameReducer 237 | 238 | let asset = AVAsset(url: url) 239 | // setup 240 | guard let videoTrack = asset.tracks(withMediaType: .video).first else { 241 | completion(.failure(VideoCompressorError.noVideo)) 242 | return 243 | } 244 | 245 | #if DEBUG 246 | print("video codec type: \(videoCodecType(for: videoTrack))") 247 | #endif 248 | let targetVideoBitrate: Float 249 | if Float(config.videoBitrate) > videoTrack.estimatedDataRate { 250 | let tempBitrate = videoTrack.estimatedDataRate/4 251 | targetVideoBitrate = max(tempBitrate, Float(Self.minimumVideoBitrate)) 252 | } else { 253 | targetVideoBitrate = Float(config.videoBitrate) 254 | } 255 | 256 | let targetSize = calculateSizeWithScale(config.scale, originalSize: videoTrack.naturalSize) 257 | let videoSettings = createVideoSettingsWithBitrate(targetVideoBitrate, 258 | maxKeyFrameInterval: config.videomaxKeyFrameInterval, 259 | size: targetSize) 260 | 261 | var audioTrack: AVAssetTrack? 262 | var audioSettings: [String: Any]? 263 | 264 | if let adTrack = asset.tracks(withMediaType: .audio).first { 265 | audioTrack = adTrack 266 | let targetAudioBitrate: Float 267 | if Float(config.audioBitrate) < adTrack.estimatedDataRate { 268 | targetAudioBitrate = Float(config.audioBitrate) 269 | } else { 270 | targetAudioBitrate = 64_000 271 | } 272 | 273 | let targetSampleRate: Int 274 | if config.audioSampleRate < 8000 { 275 | targetSampleRate = 8000 276 | } else if config.audioSampleRate > 192_000 { 277 | targetSampleRate = 192_000 278 | } else { 279 | targetSampleRate = config.audioSampleRate 280 | } 281 | audioSettings = createAudioSettingsWithAudioTrack(adTrack, bitrate: targetAudioBitrate, sampleRate: targetSampleRate) 282 | } 283 | 284 | var _outputPath: URL 285 | if let outputPath = config.outputPath { 286 | _outputPath = outputPath 287 | } else { 288 | _outputPath = FileManager.tempDirectory(with: "CompressedVideo") 289 | } 290 | 291 | #if DEBUG 292 | print("************** Video info **************") 293 | 294 | print("🎬 Video ") 295 | print("ORIGINAL:") 296 | print("video size: \(url.sizePerMB())M") 297 | print("bitrate: \(videoTrack.estimatedDataRate) b/s") 298 | print("fps: \(videoTrack.nominalFrameRate)") // 299 | print("scale size: \(videoTrack.naturalSize)") 300 | 301 | print("TARGET:") 302 | print("video bitrate: \(targetVideoBitrate) b/s") 303 | print("fps: \(config.fps)") 304 | print("scale size: (\(targetSize))") 305 | print("****************************************") 306 | #endif 307 | 308 | _compress(asset: asset, 309 | fileType: config.fileType, 310 | videoTrack, 311 | videoSettings, 312 | audioTrack, 313 | audioSettings, 314 | targetFPS: config.fps, 315 | outputPath: _outputPath, 316 | completion: completion) 317 | } 318 | 319 | /// Remove all cached compressed videos 320 | public func removeAllCompressedVideo() { 321 | var candidates = [Int]() 322 | for index in 0..) -> Void) { 346 | // video 347 | let videoOutput = AVAssetReaderTrackOutput.init(track: videoTrack, 348 | outputSettings: [kCVPixelBufferPixelFormatTypeKey as String: 349 | kCVPixelFormatType_32BGRA]) 350 | let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) 351 | videoInput.transform = videoTrack.preferredTransform // fix output video orientation 352 | do { 353 | guard FileManager.default.isValidDirectory(atPath: outputPath) else { 354 | completion(.failure(VideoCompressorError.outputPathNotValid(outputPath))) 355 | return 356 | } 357 | 358 | var outputPath = outputPath 359 | let videoName = UUID().uuidString + ".\(fileType.fileExtension)" 360 | outputPath.appendPathComponent("\(videoName)") 361 | 362 | // store urls for deleting 363 | compressVideoPaths.append(outputPath) 364 | 365 | let reader = try AVAssetReader(asset: asset) 366 | let writer = try AVAssetWriter(url: outputPath, fileType: fileType) 367 | self.reader = reader 368 | self.writer = writer 369 | 370 | // video output 371 | if reader.canAdd(videoOutput) { 372 | reader.add(videoOutput) 373 | videoOutput.alwaysCopiesSampleData = false 374 | } 375 | if writer.canAdd(videoInput) { 376 | writer.add(videoInput) 377 | } 378 | 379 | // audio output 380 | var audioInput: AVAssetWriterInput? 381 | var audioOutput: AVAssetReaderTrackOutput? 382 | if let audioTrack = audioTrack, let audioSettings = audioSettings { 383 | // Specify the number of audio channels we want when decompressing the audio from the asset to avoid error when handling audio data. 384 | // It really matters when the audio has more than 2 channels, e.g: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4' 385 | audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [AVFormatIDKey: kAudioFormatLinearPCM, 386 | AVNumberOfChannelsKey: 2]) 387 | let adInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) 388 | audioInput = adInput 389 | if reader.canAdd(audioOutput!) { 390 | reader.add(audioOutput!) 391 | } 392 | if writer.canAdd(adInput) { 393 | writer.add(adInput) 394 | } 395 | } 396 | 397 | #if DEBUG 398 | let startTime = Date() 399 | #endif 400 | // start compressing 401 | reader.startReading() 402 | writer.startWriting() 403 | writer.startSession(atSourceTime: CMTime.zero) 404 | 405 | // output video 406 | group.enter() 407 | 408 | let reduceFPS = targetFPS < videoTrack.nominalFrameRate 409 | 410 | let frameIndexArr = videoFrameReducer.reduce(originalFPS: videoTrack.nominalFrameRate, 411 | to: targetFPS, 412 | with: Float(videoTrack.asset?.duration.seconds ?? 0.0)) 413 | 414 | outputVideoDataByReducingFPS(videoInput: videoInput, 415 | videoOutput: videoOutput, 416 | frameIndexArr: reduceFPS ? frameIndexArr : []) { 417 | self.group.leave() 418 | } 419 | 420 | 421 | // output audio 422 | if let realAudioInput = audioInput, let realAudioOutput = audioOutput { 423 | group.enter() 424 | // todo: drop audio sample buffer 425 | outputAudioData(realAudioInput, audioOutput: realAudioOutput, frameIndexArr: []) { 426 | self.group.leave() 427 | } 428 | } 429 | 430 | // completion 431 | group.notify(queue: .main) { 432 | switch writer.status { 433 | case .writing, .completed: 434 | writer.finishWriting { 435 | #if DEBUG 436 | let endTime = Date() 437 | let elapse = endTime.timeIntervalSince(startTime) 438 | print("******** Compression finished ✅**********") 439 | print("Compressed video:") 440 | print("time: \(elapse)") 441 | print("size: \(outputPath.sizePerMB())M") 442 | print("path: \(outputPath)") 443 | print("******************************************") 444 | #endif 445 | DispatchQueue.main.sync { 446 | completion(.success(outputPath)) 447 | } 448 | } 449 | default: 450 | completion(.failure(writer.error!)) 451 | } 452 | } 453 | 454 | } catch { 455 | completion(.failure(error)) 456 | } 457 | 458 | } 459 | 460 | private func createVideoSettingsWithBitrate(_ bitrate: Float, maxKeyFrameInterval: Int, size: CGSize) -> [String: Any] { 461 | return [AVVideoCodecKey: AVVideoCodecType.h264, 462 | AVVideoWidthKey: size.width, 463 | AVVideoHeightKey: size.height, 464 | AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill, 465 | AVVideoCompressionPropertiesKey: [AVVideoAverageBitRateKey: bitrate, 466 | AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, 467 | AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC, 468 | AVVideoMaxKeyFrameIntervalKey: maxKeyFrameInterval 469 | ] 470 | ] 471 | } 472 | 473 | private func createAudioSettingsWithAudioTrack(_ audioTrack: AVAssetTrack, bitrate: Float, sampleRate: Int) -> [String: Any] { 474 | #if DEBUG 475 | if let audioFormatDescs = audioTrack.formatDescriptions as? [CMFormatDescription], let formatDescription = audioFormatDescs.first { 476 | print("🔊 Audio") 477 | print("ORINGIAL:") 478 | print("bitrate: \(audioTrack.estimatedDataRate)") 479 | if let streamBasicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) { 480 | print("sampleRate: \(streamBasicDescription.pointee.mSampleRate)") 481 | print("channels: \(streamBasicDescription.pointee.mChannelsPerFrame)") 482 | print("formatID: \(streamBasicDescription.pointee.mFormatID)") 483 | } 484 | 485 | print("TARGET:") 486 | print("bitrate: \(bitrate)") 487 | print("sampleRate: \(sampleRate)") 488 | // print("channels: \(2)") 489 | print("formatID: \(kAudioFormatMPEG4AAC)") 490 | } 491 | #endif 492 | 493 | var audioChannelLayout = AudioChannelLayout() 494 | memset(&audioChannelLayout, 0, MemoryLayout.size) 495 | audioChannelLayout.mChannelLayoutTag = kAudioChannelLayoutTag_Stereo 496 | 497 | return [ 498 | AVFormatIDKey: kAudioFormatMPEG4AAC, 499 | AVSampleRateKey: sampleRate, 500 | AVEncoderBitRateKey: bitrate, 501 | AVNumberOfChannelsKey: 2, 502 | AVChannelLayoutKey: Data(bytes: &audioChannelLayout, count: MemoryLayout.size) 503 | ] 504 | } 505 | 506 | private func outputVideoDataByReducingFPS(videoInput: AVAssetWriterInput, 507 | videoOutput: AVAssetReaderTrackOutput, 508 | frameIndexArr: [Int], 509 | completion: @escaping(() -> Void)) { 510 | var counter = 0 511 | var index = 0 512 | 513 | videoInput.requestMediaDataWhenReady(on: videoCompressQueue) { 514 | while videoInput.isReadyForMoreMediaData { 515 | if let buffer = videoOutput.copyNextSampleBuffer() { 516 | if frameIndexArr.isEmpty { 517 | videoInput.append(buffer) 518 | } else { // reduce FPS 519 | // append first frame 520 | if index < frameIndexArr.count { 521 | let frameIndex = frameIndexArr[index] 522 | if counter == frameIndex { 523 | index += 1 524 | videoInput.append(buffer) 525 | } 526 | counter += 1 527 | } else { 528 | // Drop this frame 529 | CMSampleBufferInvalidate(buffer) 530 | } 531 | } 532 | 533 | } else { 534 | videoInput.markAsFinished() 535 | completion() 536 | break 537 | } 538 | } 539 | } 540 | } 541 | 542 | private func outputAudioData(_ audioInput: AVAssetWriterInput, 543 | audioOutput: AVAssetReaderTrackOutput, 544 | frameIndexArr: [Int], 545 | completion: @escaping(() -> Void)) { 546 | 547 | var counter = 0 548 | var index = 0 549 | 550 | audioInput.requestMediaDataWhenReady(on: audioCompressQueue) { 551 | while audioInput.isReadyForMoreMediaData { 552 | if let buffer = audioOutput.copyNextSampleBuffer() { 553 | 554 | if frameIndexArr.isEmpty { 555 | audioInput.append(buffer) 556 | counter += 1 557 | } else { 558 | // append first frame 559 | if index < frameIndexArr.count { 560 | let frameIndex = frameIndexArr[index] 561 | if counter == frameIndex { 562 | index += 1 563 | audioInput.append(buffer) 564 | } 565 | counter += 1 566 | } else { 567 | // Drop this frame 568 | CMSampleBufferInvalidate(buffer) 569 | } 570 | } 571 | 572 | } else { 573 | audioInput.markAsFinished() 574 | completion() 575 | break 576 | } 577 | } 578 | } 579 | } 580 | 581 | // MARK: - Calculation 582 | func getVideoBitrateWithQuality(_ quality: VideoQuality, originalBitrate: Float) -> Float { 583 | var targetBitrate = Float(quality.value.bitrate) 584 | if originalBitrate < targetBitrate { 585 | switch quality { 586 | case .lowQuality: 587 | targetBitrate = originalBitrate/8 588 | targetBitrate = max(targetBitrate, Float(Self.minimumVideoBitrate)) 589 | case .mediumQuality: 590 | targetBitrate = originalBitrate/4 591 | targetBitrate = max(targetBitrate, Float(Self.minimumVideoBitrate)) 592 | case .highQuality: 593 | targetBitrate = originalBitrate/2 594 | targetBitrate = max(targetBitrate, Float(Self.minimumVideoBitrate)) 595 | case .custom(_, _, _): 596 | break 597 | } 598 | } 599 | return targetBitrate 600 | } 601 | 602 | func calculateSizeWithQuality(_ quality: VideoQuality, originalSize: CGSize) -> CGSize { 603 | let originalWidth = originalSize.width 604 | let originalHeight = originalSize.height 605 | let isRotated = originalHeight > originalWidth // videos captured by mobile phone have rotated size. 606 | 607 | var threshold: CGFloat = -1 608 | 609 | switch quality { 610 | case .lowQuality: 611 | threshold = 224 612 | case .mediumQuality: 613 | threshold = 480 614 | case .highQuality: 615 | threshold = 1080 616 | case .custom(_, _, let scale): 617 | return scale 618 | } 619 | 620 | var targetWidth: CGFloat = originalWidth 621 | var targetHeight: CGFloat = originalHeight 622 | if !isRotated { 623 | if originalHeight > threshold { 624 | targetHeight = threshold 625 | targetWidth = threshold * originalWidth / originalHeight 626 | } 627 | } else { 628 | if originalWidth > threshold { 629 | targetWidth = threshold 630 | targetHeight = threshold * originalHeight / originalWidth 631 | } 632 | } 633 | return CGSize(width: Int(targetWidth), height: Int(targetHeight)) 634 | } 635 | 636 | func calculateSizeWithScale(_ scale: CGSize?, originalSize: CGSize) -> CGSize { 637 | guard let scale = scale else { 638 | return originalSize 639 | } 640 | if scale.width == -1 && scale.height == -1 { 641 | return originalSize 642 | } else if scale.width != -1 && scale.height != -1 { 643 | return scale 644 | } else if scale.width == -1 { 645 | let targetWidth = Int(scale.height * originalSize.width / originalSize.height) 646 | return CGSize(width: CGFloat(targetWidth), height: scale.height) 647 | } else { 648 | let targetHeight = Int(scale.width * originalSize.height / originalSize.width) 649 | return CGSize(width: scale.width, height: CGFloat(targetHeight)) 650 | } 651 | } 652 | 653 | /// Randomly drop some indexes to get final frames indexes 654 | /// 655 | /// 1. Calculate original frames and target frames 656 | /// 2. Divide the range (0, `originalFrames`) into `targetFrames` parts equaly, eg., divide range 0..<9 into 3 parts: 0..<3, 3..<6. 6..<9 657 | /// 3. 658 | /// 659 | /// - Parameters: 660 | /// - originFPS: original video fps 661 | /// - targetFPS: target video fps 662 | /// - Returns: frame indexes 663 | func getFrameIndexesWith(originalFPS: Float, targetFPS: Float, duration: Float) -> [Int] { 664 | assert(originalFPS > 0) 665 | assert(targetFPS > 0) 666 | let originalFrames = Int(originalFPS * duration) 667 | let targetFrames = Int(duration * targetFPS) 668 | 669 | // 670 | var rangeArr = Array(repeating: 0, count: targetFrames) 671 | for i in 0.. 1 else { 684 | return randomFrames 685 | } 686 | 687 | for index in 1.. String { 696 | let res = videoTrack.formatDescriptions 697 | .map { CMFormatDescriptionGetMediaSubType($0 as! CMFormatDescription).toString() } 698 | return res.first ?? "unknown codec type" 699 | } 700 | 701 | private func isKeyFrame(sampleBuffer: CMSampleBuffer) -> Bool { 702 | guard let attachmentArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) else { 703 | return false 704 | } 705 | 706 | let attachmentCount = CFArrayGetCount(attachmentArray) 707 | if attachmentCount == 0 { 708 | return true // Assume keyframe if no attachments are present 709 | } 710 | 711 | let attachment = unsafeBitCast( 712 | CFArrayGetValueAtIndex(attachmentArray, 0), 713 | to: CFDictionary.self 714 | ) 715 | 716 | if let dependsOnOthers = CFDictionaryGetValue(attachment, Unmanaged.passUnretained(kCMSampleAttachmentKey_DependsOnOthers).toOpaque()) { 717 | let value = Unmanaged.fromOpaque(dependsOnOthers).takeUnretainedValue() 718 | return !CFBooleanGetValue(value) 719 | } else { 720 | return true // Assume keyframe if attachment is not present 721 | } 722 | } 723 | } 724 | --------------------------------------------------------------------------------