Files
konverter/Sources/Konverter/Convert/AVConverter.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

369 lines
16 KiB
Swift

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