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,368 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
/// Audio and video, mostly through AVFoundation. Formats Apple cannot write
|
||||
/// (MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA) are delegated to ffmpeg
|
||||
/// when it is installed — see `FFmpeg`.
|
||||
enum AVConverter {
|
||||
|
||||
// MARK: - Probing
|
||||
|
||||
static func duration(_ url: URL) -> Double? {
|
||||
let asset = AVURLAsset(url: url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
|
||||
let seconds = CMTimeGetSeconds(asset.duration)
|
||||
return seconds.isFinite && seconds > 0 ? seconds : nil
|
||||
}
|
||||
|
||||
/// Uses the synchronously loaded track list; called only while building the
|
||||
/// drag wheel, so it must not await.
|
||||
static func trackSummary(_ url: URL) -> (video: Bool, audio: Bool) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
var video = false
|
||||
var audio = false
|
||||
for track in asset.tracks {
|
||||
switch track.mediaType {
|
||||
case .video where track.naturalSize.height > 0: video = true
|
||||
case .audio: audio = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return (video, audio)
|
||||
}
|
||||
|
||||
// MARK: - Conversion
|
||||
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
quality: String = "Medium",
|
||||
bitrate: String = "128",
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
|
||||
switch format {
|
||||
case .gif:
|
||||
try makeGIF(source: source, at: destination, fps: 12, maxWidth: 480, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
case .mp4, .mov, .m4v:
|
||||
try exportWithSession(
|
||||
source: source,
|
||||
destination: destination,
|
||||
fileType: format == .mov ? .mov : .mp4,
|
||||
preset: videoPreset(for: quality),
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return
|
||||
case .m4a, .mp3:
|
||||
if format == .m4a {
|
||||
try exportWithSession(
|
||||
source: source,
|
||||
destination: destination,
|
||||
fileType: .m4a,
|
||||
preset: AVAssetExportPresetAppleM4A,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return
|
||||
}
|
||||
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
default:
|
||||
// Everything else: try ffmpeg, then CoreAudio, then AVFoundation.
|
||||
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
}
|
||||
if format == .wav || format == .aiff {
|
||||
try CoreAudio.convert(source: source, to: format, at: destination, cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
throw ConversionError.missingTool(
|
||||
"\(format.displayName) needs ffmpeg. Install it with: brew install ffmpeg"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func videoPreset(for quality: String) -> String {
|
||||
switch quality {
|
||||
case "Low": return AVAssetExportPreset960x540
|
||||
case "High": return AVAssetExportPreset1920x1080
|
||||
default: return AVAssetExportPreset1280x720
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
static func trim(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
start: Double,
|
||||
end: Double?,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
throw ConversionError.failed("Could not prepare a trim for \(source.lastPathComponent).")
|
||||
}
|
||||
let duration = CMTimeGetSeconds(asset.duration)
|
||||
let startTime = max(0, min(start, duration))
|
||||
let endTime = min(end ?? duration, duration)
|
||||
guard endTime > startTime else {
|
||||
throw ConversionError.failed("The trim range is empty.")
|
||||
}
|
||||
session.timeRange = CMTimeRange(
|
||||
start: CMTime(seconds: startTime, preferredTimescale: 600),
|
||||
end: CMTime(seconds: endTime, preferredTimescale: 600)
|
||||
)
|
||||
try run(session: session, destination: destination, fileType: destination.pathExtension == "mov" ? .mov : .mp4, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
/// Rebuilds the file with the audio track dropped.
|
||||
static func mute(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let composition = AVMutableComposition()
|
||||
var inserted = false
|
||||
for track in asset.tracks where track.mediaType == .video {
|
||||
guard let target = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
|
||||
try target.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: track, at: .zero)
|
||||
target.preferredTransform = track.preferredTransform
|
||||
inserted = true
|
||||
}
|
||||
guard inserted else { throw ConversionError.failed("No video track to keep.") }
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
static func changeSpeed(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
rate: Double,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
guard rate > 0 else { throw ConversionError.failed("Speed must be greater than zero.") }
|
||||
let asset = AVURLAsset(url: source)
|
||||
let composition = AVMutableComposition()
|
||||
let duration = asset.duration
|
||||
for track in asset.tracks {
|
||||
guard track.mediaType == .video || track.mediaType == .audio else { continue }
|
||||
guard let target = composition.addMutableTrack(withMediaType: track.mediaType, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
|
||||
try target.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: track, at: .zero)
|
||||
if track.mediaType == .video {
|
||||
target.preferredTransform = track.preferredTransform
|
||||
} else {
|
||||
target.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
|
||||
}
|
||||
}
|
||||
for track in composition.tracks where track.mediaType == .video {
|
||||
track.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
|
||||
}
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, audioPitch: .varispeed)
|
||||
}
|
||||
|
||||
static func resize(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
width: Int,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let track = asset.tracks(withMediaType: .video).first else {
|
||||
throw ConversionError.failed("No video track to resize.")
|
||||
}
|
||||
let natural = track.naturalSize.applying(track.preferredTransform)
|
||||
let sourceSize = CGSize(width: abs(natural.width), height: abs(natural.height))
|
||||
guard sourceSize.width > 0 else { throw ConversionError.failed("Unknown video size.") }
|
||||
let scale = CGFloat(width) / sourceSize.width
|
||||
let renderSize = CGSize(width: CGFloat(width).rounded(), height: (sourceSize.height * scale).rounded())
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
||||
throw ConversionError.failed("Could not build the resize composition.")
|
||||
}
|
||||
let range = CMTimeRange(start: .zero, duration: asset.duration)
|
||||
try videoTrack.insertTimeRange(range, of: track, at: .zero)
|
||||
if let audio = asset.tracks(withMediaType: .audio).first,
|
||||
let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
|
||||
try audioTrack.insertTimeRange(range, of: audio, at: .zero)
|
||||
}
|
||||
|
||||
let instruction = AVMutableVideoCompositionInstruction()
|
||||
instruction.timeRange = range
|
||||
let layer = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
|
||||
let transform = track.preferredTransform.concatenating(CGAffineTransform(scaleX: scale, y: scale))
|
||||
layer.setTransform(transform, at: .zero)
|
||||
instruction.layerInstructions = [layer]
|
||||
|
||||
let videoComposition = AVMutableVideoComposition()
|
||||
videoComposition.renderSize = renderSize
|
||||
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
|
||||
videoComposition.instructions = [instruction]
|
||||
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, videoComposition: videoComposition)
|
||||
}
|
||||
|
||||
static func snapshot(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
time: Double,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.requestedTimeToleranceBefore = .zero
|
||||
generator.requestedTimeToleranceAfter = .zero
|
||||
try cancellation?.check()
|
||||
let image = try generator.copyCGImage(at: CMTime(seconds: time, preferredTimescale: 600), actualTime: nil)
|
||||
try Raster.write(image, to: destination, format: .png, quality: 1, metadata: nil)
|
||||
}
|
||||
|
||||
/// Frame-by-frame GIF. Long clips are capped so the result stays small.
|
||||
static func makeGIF(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
fps: Int,
|
||||
maxWidth: Int,
|
||||
maxDuration: Double = 20,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let duration = min(CMTimeGetSeconds(asset.duration), maxDuration)
|
||||
guard duration > 0 else { throw ConversionError.failed("Nothing to animate.") }
|
||||
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: maxWidth, height: maxWidth)
|
||||
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.05, preferredTimescale: 600)
|
||||
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.05, preferredTimescale: 600)
|
||||
|
||||
guard let output = CGImageDestinationCreateWithURL(destination as CFURL, "com.compuserve.gif" as CFString, 0, nil) else {
|
||||
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
|
||||
}
|
||||
CGImageDestinationSetProperties(output, [
|
||||
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]
|
||||
] as CFDictionary)
|
||||
|
||||
let frameCount = max(1, Int(duration * Double(fps)))
|
||||
let frameDuration = 1.0 / Double(fps)
|
||||
for index in 0..<frameCount {
|
||||
try cancellation?.check()
|
||||
let time = CMTime(seconds: Double(index) * frameDuration, preferredTimescale: 600)
|
||||
if let image = try? generator.copyCGImage(at: time, actualTime: nil) {
|
||||
CGImageDestinationAddImage(output, image, [
|
||||
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: frameDuration]
|
||||
] as CFDictionary)
|
||||
}
|
||||
progress?(Double(index + 1) / Double(frameCount))
|
||||
}
|
||||
guard CGImageDestinationFinalize(output) else {
|
||||
throw ConversionError.failed("Writing the GIF failed.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func exportComposition(
|
||||
_ composition: AVComposition,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?,
|
||||
audioPitch: AVAudioTimePitchAlgorithm? = nil,
|
||||
videoComposition: AVVideoComposition? = nil
|
||||
) throws {
|
||||
let fileType: AVFileType = destination.pathExtension == "mov"
|
||||
? .mov
|
||||
: (destination.pathExtension == "m4a" ? .m4a : .mp4)
|
||||
guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
throw ConversionError.failed("Could not prepare the export.")
|
||||
}
|
||||
if let audioPitch { session.audioTimePitchAlgorithm = audioPitch }
|
||||
if let videoComposition { session.videoComposition = videoComposition }
|
||||
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
private static func exportWithSession(
|
||||
source: URL,
|
||||
destination: URL,
|
||||
fileType: AVFileType,
|
||||
preset: String,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: preset) else {
|
||||
throw ConversionError.failed("This file cannot be exported with the \(preset) preset.")
|
||||
}
|
||||
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
private final class ErrorBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: Error?
|
||||
func set(_ error: Error) { lock.lock(); value = error; lock.unlock() }
|
||||
func get() -> Error? { lock.lock(); defer { lock.unlock() }; return value }
|
||||
}
|
||||
|
||||
private static func run(
|
||||
session: AVAssetExportSession,
|
||||
destination: URL,
|
||||
fileType: AVFileType,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?
|
||||
) throws {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
|
||||
let box = ErrorBox()
|
||||
let ready = DispatchSemaphore(value: 0)
|
||||
|
||||
if #available(macOS 15.0, *) {
|
||||
let session = session
|
||||
Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try await session.export(to: destination, as: fileType)
|
||||
} catch {
|
||||
box.set(error)
|
||||
}
|
||||
ready.signal()
|
||||
}
|
||||
} else {
|
||||
session.outputURL = destination
|
||||
session.outputFileType = fileType
|
||||
session.shouldOptimizeForNetworkUse = false
|
||||
session.exportAsynchronously { ready.signal() }
|
||||
}
|
||||
|
||||
// Poll for progress and cancellation while the export runs.
|
||||
while ready.wait(timeout: .now() + 0.2) == .timedOut {
|
||||
if cancellation?.isCancelled == true {
|
||||
session.cancelExport()
|
||||
throw CancellationError()
|
||||
}
|
||||
progress?(Double(session.progress))
|
||||
}
|
||||
|
||||
try cancellation?.check()
|
||||
if let error = box.get() { throw ConversionError.failed(error.localizedDescription) }
|
||||
if session.status == .failed {
|
||||
throw ConversionError.failed(session.error?.localizedDescription ?? "Export failed.")
|
||||
}
|
||||
if session.status == .cancelled { throw CancellationError() }
|
||||
progress?(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user