import Foundation /// ffmpeg back end. Used for containers and codecs Apple's frameworks cannot /// write. Progress is parsed from `-progress pipe:1`. enum FFmpeg { static func convert( source: URL, to format: Format, at destination: URL, bitrate: String = "128", quality: String = "Medium", cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws { guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else { throw ConversionError.missingTool("\(format.displayName) requires ffmpeg — install it with: brew install ffmpeg") } var arguments = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path] arguments += encodingArguments(for: format, bitrate: bitrate, quality: quality) arguments += ["-progress", "pipe:1", "-nostats"] arguments.append(destination.path) let duration = AVConverter.duration(source) ?? 0 try Shell.runReportingProgress(ffmpeg, arguments, cancellation: cancellation) { _, line in guard let progress, duration > 0 else { return } if let range = line.range(of: "out_time_us="), let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) { progress(min(1, microseconds / 1_000_000 / duration)) } } progress?(1) } /// Down-mixes any media to audio. static func extractAudio( source: URL, to format: Format, at destination: URL, bitrate: String = "192", cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws { try convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress) } /// Progress-reporting pass-through for tools (trim, speed, gain, …). static func filter( source: URL, at destination: URL, arguments: [String], cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws { guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else { throw ConversionError.missingTool("This tool requires ffmpeg — install it with: brew install ffmpeg") } var full = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path] full += arguments full += ["-progress", "pipe:1", "-nostats", destination.path] let duration = AVConverter.duration(source) ?? 0 try Shell.runReportingProgress(ffmpeg, full, cancellation: cancellation) { _, line in guard let progress, duration > 0 else { return } if let range = line.range(of: "out_time_us="), let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) { progress(min(1, microseconds / 1_000_000 / duration)) } } progress?(1) } private static func encodingArguments(for format: Format, bitrate: String, quality: String) -> [String] { let audioRate = "\(bitrate)k" switch format { case .mp3: return ["-vn", "-c:a", "libmp3lame", "-b:a", audioRate] case .m4a: return ["-vn", "-c:a", "aac", "-b:a", audioRate] case .wav: return ["-vn", "-c:a", "pcm_s16le"] case .aiff: return ["-vn", "-c:a", "pcm_s16be"] case .flac: return ["-vn", "-c:a", "flac"] case .ogg: return ["-vn", "-c:a", "libvorbis", "-q:a", "5"] case .opus: return ["-vn", "-c:a", "libopus", "-b:a", audioRate] case .wma: return ["-vn", "-c:a", "wmav2", "-b:a", audioRate] case .mkv: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate] case .webm: return ["-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-b:a", audioRate] case .avi: return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame", "-b:a", audioRate] case .wmv: return ["-c:v", "wmv2", "-b:v", "2M", "-c:a", "wmav2", "-b:a", audioRate] case .mp4, .mov, .m4v, .gif: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate] default: return [] } } private static func videoArguments(codec: String, quality: String) -> [String] { let crf: String switch quality { case "Low": crf = "30" case "High": crf = "20" default: crf = "25" } return ["-c:v", codec, "-preset", "medium", "-crf", crf, "-pix_fmt", "yuv420p"] } } /// CoreAudio's `afconvert`: always present, handles PCM containers and /// channel remapping without any third-party install. enum CoreAudio { static func convert( source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil ) throws { guard let fileFormat = coreAudioFileFormat(for: format) else { throw ConversionError.unsupported("CoreAudio cannot write \(format.displayName).") } do { try Shell.run( Toolchain.afconvert, ["-f", fileFormat, "-d", dataFormat(for: fileFormat), source.path, destination.path], cancellation: cancellation ) } catch { throw ConversionError.failed("CoreAudio could not convert \(source.lastPathComponent): \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)") } } static func remix( source: URL, at destination: URL, mono: Bool, cancellation: Cancellation? = nil ) throws { try Shell.run( Toolchain.afconvert, ["-f", "caff", "-d", "LEI16", "-c", mono ? "1" : "2", source.path, destination.path], cancellation: cancellation ) } private static func coreAudioFileFormat(for format: Format) -> String? { switch format { case .wav: return "WAVE" case .aiff: return "AIFF" case .m4a: return "m4af" default: return nil } } private static func dataFormat(for fileFormat: String) -> String { switch fileFormat { case "WAVE": return "LEI16" case "AIFF": return "BEI16" default: return "aac" } } }