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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
import AVFoundation
import CoreMedia
import Foundation
/// Video and audio tools. Where AVFoundation cannot express an edit (loudness
/// normalisation, silence ranges, bitrate targets) ffmpeg is used instead.
enum MediaTools {
static func compressVideo(_ source: URL, at destination: URL, quality: String) throws {
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
let crf = quality == "Low" ? "30" : (quality == "High" ? "21" : "26")
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-c:v", "libx264", "-preset", "slow", "-crf", crf, "-c:a", "aac", "-b:a", "128k"]
)
return
}
try AVConverter.convert(source: source, to: destination.pathExtension == "mov" ? .mov : .mp4, at: destination, quality: quality)
}
static func normalizeAudio(_ source: URL, at destination: URL) throws {
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", codecForContainer(destination)]
)
}
static func channels(_ source: URL, at destination: URL, mono: Bool) throws {
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-ac", mono ? "1" : "2", "-c:a", codecForContainer(destination)]
)
return
}
try CoreAudio.remix(source: source, at: destination, mono: mono)
}
static func bleep(_ source: URL, at destination: URL, start: Double, end: Double) throws {
let filter = "volume=enable='between(t,\(start),\(end))':volume=0"
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-af", filter, "-c:a", codecForContainer(destination)]
)
}
static func compressAudio(_ source: URL, at destination: URL, bitrate: String) throws {
guard let format = FileKind.format(of: destination) else {
throw ConversionError.failed("Unknown audio container.")
}
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate)
}
/// Audio edits keep the source container where possible so the output name
/// and the file itself stay recognisable.
static func audioSibling(for source: URL, suffix: String, forcing format: Format? = nil) -> URL {
let resolved = format ?? FileKind.format(of: source) ?? .m4a
return OutputNaming.sibling(for: source, suffix: suffix, extension: resolved.ext)
}
private static func codecForContainer(_ url: URL) -> String {
switch FileKind.canonicalExtension(url.pathExtension) {
case "mp3": return "libmp3lame"
case "ogg": return "libvorbis"
case "opus": return "libopus"
case "flac": return "flac"
case "wav": return "pcm_s16le"
case "aiff": return "pcm_s16be"
case "wma": return "wmav2"
case "caf": return "pcm_s16le"
default: return "aac"
}
}
}
/// Serialisable, human readable descriptions for the info tools.
enum MediaInfo {
static func describe(_ url: URL) -> String {
switch FileKind.category(of: url) {
case .image: return describeImage(url)
case .video, .audio: return describeMedia(url)
case .pdf: return describePDF(url)
case .archive: return describeArchive(url)
case .spreadsheet, .document, .ebook, .subtitle: return describeText(url)
default: return describeFile(url)
}
}
static func describeFile(_ url: URL) -> String {
describeFileBasics(url).joined(separator: "\n")
}
private static func describeFileBasics(_ url: URL) -> [String] {
var lines: [String] = ["Name: \(url.lastPathComponent)"]
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey])
if let size = values?.fileSize {
lines.append("Size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))")
}
if let created = values?.creationDate {
lines.append("Created: \(DateFormatter.localizedString(from: created, dateStyle: .medium, timeStyle: .short))")
}
if let modified = values?.contentModificationDate {
lines.append("Modified: \(DateFormatter.localizedString(from: modified, dateStyle: .medium, timeStyle: .short))")
}
return lines
}
private static func describeImage(_ url: URL) -> String {
var lines = describeFileBasics(url)
guard let source = Raster.imageSource(at: url) else { return lines.joined(separator: "\n") }
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] ?? [:]
if let width = properties[kCGImagePropertyPixelWidth] as? Int,
let height = properties[kCGImagePropertyPixelHeight] as? Int {
lines.append("Dimensions: \(width) × \(height)")
}
if let dpiWidth = properties[kCGImagePropertyDPIWidth] as? Double {
lines.append("Resolution: \(Int(dpiWidth)) dpi")
}
if let colorModel = properties[kCGImagePropertyColorModel] as? String {
lines.append("Color: \(colorModel)")
}
if properties[kCGImagePropertyGIFDictionary] != nil {
lines.append("Format: animated GIF")
}
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] {
if let date = exif[kCGImagePropertyExifDateTimeOriginal] as? String {
lines.append("Captured: \(date)")
}
}
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
if let make = tiff[kCGImagePropertyTIFFMake] as? String,
let model = tiff[kCGImagePropertyTIFFModel] as? String {
lines.append("Camera: \(make) \(model)")
}
}
if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any],
let latitude = gps[kCGImagePropertyGPSLatitude] as? Double,
let longitude = gps[kCGImagePropertyGPSLongitude] as? Double {
lines.append("Location: \(String(format: "%.4f", latitude)), \(String(format: "%.4f", longitude))")
}
lines.append("Frames: \(Raster.frameCount(at: url))")
return lines.joined(separator: "\n")
}
private static func describeMedia(_ url: URL) -> String {
var lines = describeFileBasics(url)
let asset = AVURLAsset(url: url)
let duration = CMTimeGetSeconds(asset.duration)
if duration.isFinite, duration > 0 {
let minutes = Int(duration) / 60
let remaining = duration - Double(minutes * 60)
lines.append("Duration: \(minutes):\(String(format: "%05.2f", remaining))")
}
for track in asset.tracks {
switch track.mediaType {
case .video:
let size = track.naturalSize.applying(track.preferredTransform)
let frameRate = track.nominalFrameRate
lines.append("Video: \(Int(abs(size.width)))×\(Int(abs(size.height))) @ \(String(format: "%.2f", frameRate)) fps")
lines.append("Codec: \(codecName(track) ?? "unknown")")
case .audio:
let rate = track.nominalFrameRate
lines.append("Audio: \(codecName(track) ?? "unknown") @ \(Int(rate)) Hz")
default:
break
}
}
if let ffprobe = Toolchain.ffprobe,
let output = Shell.output(ffprobe, ["-v", "error", "-show_entries", "format=bit_rate:stream=bit_rate", "-of", "default=nw=1", url.path]) {
let rates = output.split(separator: "\n").compactMap { $0.split(separator: "=").last }.compactMap { Int($0) }.filter { $0 > 0 }
if let bitrate = rates.first {
lines.append("Bitrate: \(bitrate / 1000) kbps")
}
}
return lines.joined(separator: "\n")
}
private static func codecName(_ track: AVAssetTrack) -> String? {
guard let raw = track.formatDescriptions.first else { return nil }
let description = raw as! CMFormatDescription
return describeFormat(description)
}
private static func describeFormat(_ description: CMFormatDescription) -> String? {
let codec = CMFormatDescriptionGetMediaSubType(description)
let bytes = [
UInt8((codec >> 24) & 0xFF), UInt8((codec >> 16) & 0xFF),
UInt8((codec >> 8) & 0xFF), UInt8(codec & 0xFF)
]
let name = String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces)
return name?.isEmpty == false ? name : nil
}
private static func describePDF(_ url: URL) -> String {
var lines = describeFileBasics(url)
lines.append("Pages: \(PDFConverter.pageCount(url))")
if let size = PDFConverter.pageSize(url) {
lines.append("Page size: \(Int(size.width)) × \(Int(size.height)) pt")
}
let text = PDFConverter.extractText(url)
lines.append("Words: \(text.split { $0 == " " || $0.isNewline }.count)")
return lines.joined(separator: "\n")
}
private static func describeArchive(_ url: URL) -> String {
var lines = describeFileBasics(url)
let listing = (try? ArchiveConverter.list(url)) ?? ""
let entries = listing.split(separator: "\n").count
lines.append("Entries: \(entries)")
return lines.joined(separator: "\n")
}
private static func describeText(_ url: URL) -> String {
var lines = describeFileBasics(url)
let text = TextDocument.plainText(from: url) ?? ""
let words = text.split { $0 == " " || $0.isNewline }.count
lines.append("Words: \(words)")
lines.append("Characters: \(text.count)")
lines.append("Lines: \(text.components(separatedBy: "\n").count)")
return lines.joined(separator: "\n")
}
}