Files
konverter/Sources/Konverter/Convert/MediaTools.swift
T
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

226 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}