|
| 1 | +import AVFoundation |
| 2 | +import Foundation |
| 3 | + |
| 4 | +enum ResizeError: Error, CustomStringConvertible { |
| 5 | + case invalidArgs(String) |
| 6 | + case missingVideoTrack |
| 7 | + case exportFailed(String) |
| 8 | + |
| 9 | + var description: String { |
| 10 | + switch self { |
| 11 | + case .invalidArgs(let message): |
| 12 | + return message |
| 13 | + case .missingVideoTrack: |
| 14 | + return "Input video does not contain a video track." |
| 15 | + case .exportFailed(let message): |
| 16 | + return message |
| 17 | + } |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +do { |
| 22 | + try run() |
| 23 | +} catch { |
| 24 | + fputs("recording-resize: \(error)\n", stderr) |
| 25 | + exit(1) |
| 26 | +} |
| 27 | + |
| 28 | +func run() throws { |
| 29 | + let arguments = Array(CommandLine.arguments.dropFirst()) |
| 30 | + let parsedArgs = try parseArguments(arguments) |
| 31 | + let inputURL = URL(fileURLWithPath: parsedArgs.inputPath) |
| 32 | + let outputURL = URL(fileURLWithPath: parsedArgs.outputPath) |
| 33 | + |
| 34 | + if FileManager.default.fileExists(atPath: outputURL.path) { |
| 35 | + try FileManager.default.removeItem(at: outputURL) |
| 36 | + } |
| 37 | + |
| 38 | + let asset = AVURLAsset(url: inputURL) |
| 39 | + guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else { |
| 40 | + throw ResizeError.missingVideoTrack |
| 41 | + } |
| 42 | + |
| 43 | + let renderSize = scaledRenderSize(for: sourceVideoTrack, quality: parsedArgs.quality) |
| 44 | + let composition = AVMutableComposition() |
| 45 | + let fullRange = CMTimeRange(start: .zero, duration: asset.duration) |
| 46 | + |
| 47 | + guard let compositionVideoTrack = composition.addMutableTrack( |
| 48 | + withMediaType: .video, |
| 49 | + preferredTrackID: kCMPersistentTrackID_Invalid |
| 50 | + ) else { |
| 51 | + throw ResizeError.exportFailed("Failed to create composition video track.") |
| 52 | + } |
| 53 | + try compositionVideoTrack.insertTimeRange(fullRange, of: sourceVideoTrack, at: .zero) |
| 54 | + |
| 55 | + if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first, |
| 56 | + let compositionAudioTrack = composition.addMutableTrack( |
| 57 | + withMediaType: .audio, |
| 58 | + preferredTrackID: kCMPersistentTrackID_Invalid |
| 59 | + ) { |
| 60 | + try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero) |
| 61 | + } |
| 62 | + |
| 63 | + let scale = CGFloat(parsedArgs.quality) / 10.0 |
| 64 | + let videoComposition = AVMutableVideoComposition() |
| 65 | + videoComposition.renderSize = renderSize |
| 66 | + videoComposition.frameDuration = resolvedFrameDuration(for: sourceVideoTrack) |
| 67 | + |
| 68 | + let instruction = AVMutableVideoCompositionInstruction() |
| 69 | + instruction.timeRange = fullRange |
| 70 | + let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack) |
| 71 | + // Scale the full preferred transform (including translation) to match the smaller render canvas. |
| 72 | + let scaledTransform = scaledPreferredTransform(sourceVideoTrack.preferredTransform, scale: scale) |
| 73 | + layerInstruction.setTransform(scaledTransform, at: .zero) |
| 74 | + instruction.layerInstructions = [layerInstruction] |
| 75 | + videoComposition.instructions = [instruction] |
| 76 | + |
| 77 | + guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { |
| 78 | + throw ResizeError.exportFailed("Failed to create export session.") |
| 79 | + } |
| 80 | + |
| 81 | + exporter.outputURL = outputURL |
| 82 | + exporter.outputFileType = .mp4 |
| 83 | + exporter.videoComposition = videoComposition |
| 84 | + exporter.shouldOptimizeForNetworkUse = true |
| 85 | + |
| 86 | + let semaphore = DispatchSemaphore(value: 0) |
| 87 | + exporter.exportAsynchronously { |
| 88 | + semaphore.signal() |
| 89 | + } |
| 90 | + if semaphore.wait(timeout: .now() + 120) == .timedOut { |
| 91 | + exporter.cancelExport() |
| 92 | + throw ResizeError.exportFailed("Resize export timed out.") |
| 93 | + } |
| 94 | + |
| 95 | + if exporter.status != .completed { |
| 96 | + throw ResizeError.exportFailed(exporter.error?.localizedDescription ?? "Resize export failed.") |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, quality: Int) { |
| 101 | + var inputPath: String? |
| 102 | + var outputPath: String? |
| 103 | + var quality: Int? |
| 104 | + var index = 0 |
| 105 | + |
| 106 | + while index < arguments.count { |
| 107 | + let argument = arguments[index] |
| 108 | + let nextIndex = index + 1 |
| 109 | + switch argument { |
| 110 | + case "--input": |
| 111 | + guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--input requires a value") } |
| 112 | + inputPath = arguments[nextIndex] |
| 113 | + index += 2 |
| 114 | + case "--output": |
| 115 | + guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--output requires a value") } |
| 116 | + outputPath = arguments[nextIndex] |
| 117 | + index += 2 |
| 118 | + case "--quality": |
| 119 | + guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--quality requires a value") } |
| 120 | + guard let parsed = Int(arguments[nextIndex]), parsed >= 5, parsed <= 10 else { |
| 121 | + throw ResizeError.invalidArgs("--quality must be an integer between 5 and 10") |
| 122 | + } |
| 123 | + quality = parsed |
| 124 | + index += 2 |
| 125 | + default: |
| 126 | + throw ResizeError.invalidArgs("Unknown argument: \(argument)") |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + guard let inputPath, let outputPath, let quality else { |
| 131 | + throw ResizeError.invalidArgs( |
| 132 | + "Usage: recording-resize.swift --input <video> --output <video> --quality <5-10>" |
| 133 | + ) |
| 134 | + } |
| 135 | + return (inputPath, outputPath, quality) |
| 136 | +} |
| 137 | + |
| 138 | +func resolvedRenderSize(for track: AVAssetTrack) -> CGSize { |
| 139 | + let transformed = track.naturalSize.applying(track.preferredTransform) |
| 140 | + return CGSize(width: abs(transformed.width), height: abs(transformed.height)) |
| 141 | +} |
| 142 | + |
| 143 | +func scaledRenderSize(for track: AVAssetTrack, quality: Int) -> CGSize { |
| 144 | + let renderSize = resolvedRenderSize(for: track) |
| 145 | + guard quality < 10 else { return renderSize } |
| 146 | + let scale = CGFloat(quality) / 10.0 |
| 147 | + return CGSize( |
| 148 | + width: scaledDimension(renderSize.width, scale: scale), |
| 149 | + height: scaledDimension(renderSize.height, scale: scale) |
| 150 | + ) |
| 151 | +} |
| 152 | + |
| 153 | +func scaledDimension(_ value: CGFloat, scale: CGFloat) -> CGFloat { |
| 154 | + let evenValue = Int((Double(value * scale) / 2.0).rounded()) * 2 |
| 155 | + return CGFloat(max(2, evenValue)) |
| 156 | +} |
| 157 | + |
| 158 | +func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime { |
| 159 | + let minFrameDuration = track.minFrameDuration |
| 160 | + if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 { |
| 161 | + return minFrameDuration |
| 162 | + } |
| 163 | + |
| 164 | + let nominalFrameRate = track.nominalFrameRate |
| 165 | + if nominalFrameRate > 0 { |
| 166 | + let timescale = Int32(max(1, round(nominalFrameRate))) |
| 167 | + return CMTime(value: 1, timescale: timescale) |
| 168 | + } |
| 169 | + |
| 170 | + return CMTime(value: 1, timescale: 60) |
| 171 | +} |
| 172 | + |
| 173 | +func scaledPreferredTransform(_ transform: CGAffineTransform, scale: CGFloat) -> CGAffineTransform { |
| 174 | + CGAffineTransform( |
| 175 | + a: transform.a * scale, |
| 176 | + b: transform.b * scale, |
| 177 | + c: transform.c * scale, |
| 178 | + d: transform.d * scale, |
| 179 | + tx: transform.tx * scale, |
| 180 | + ty: transform.ty * scale |
| 181 | + ) |
| 182 | +} |
0 commit comments