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.
226 lines
10 KiB
Swift
226 lines
10 KiB
Swift
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")
|
||
}
|
||
}
|