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