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