Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user