Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

157 lines
6.3 KiB
Swift

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"
}
}
}