import AVFoundation import CoreImage import CoreMedia import CoreVideo /// Schneidet eine 9:16-Quelle auf 4:5 zu und schreibt sie als MP4/H.264/AAC — /// das feste Zielformat für die Meta-Ads-Veröffentlichung, unabhängig vom /// Quellcodec (ProRes, H.264, HEVC, …). Läuft über `AVAssetReader`/ /// `AVAssetWriter` statt `AVAssetExportSession`, weil das die volle Kontrolle /// über Zielbitrate und Profil gibt, die Presets nicht bieten. /// /// Bekannte Grenzen dieser Implementierung: /// - Der Crop läuft über eine 32BGRA-Zwischenstufe (CoreImage). Bei 10-Bit- /// Quellen bedeutet das eine Requantisierung auf 8 Bit während des Crops — /// für ein H.264-Ad-Deliverable ohnehin die Zielfarbtiefe, also keine /// Einschränkung mehr wie bei einem codec-erhaltenden Export. /// - Ziel ist ausschließlich `.mp4`; alles andere wird abgelehnt. /// - Bitrate/Profil sind eine Faustregel an gängigen Meta-Ads-Empfehlungen /// orientiert (H.264 High Profile, resolutionsabhängige Zielbitrate, /// AAC-LC 128 kbit/s), keine von Meta veröffentlichte Formel — bei Bedarf /// über `Options.videoBitRate`/`Options.audioBitRate` überschreibbar. public enum CropEngine { public struct Options { /// -1...1, 0 = vertikal zentriert. Platzhalter für den in der README /// als "Offene Punkte" genannten, später pro Datei einstellbaren Offset. public var verticalOffset: CGFloat /// false = eine bestehende Zieldatei wird nie stillschweigend überschrieben. public var overwrite: Bool /// Video-Zielbitrate in bit/s. nil = automatisch aus Auflösung und /// Framerate berechnet. public var videoBitRate: Int? /// Audio-Zielbitrate in bit/s für AAC. nil = 128 kbit/s. public var audioBitRate: Int? public init( verticalOffset: CGFloat = 0, overwrite: Bool = false, videoBitRate: Int? = nil, audioBitRate: Int? = nil ) { self.verticalOffset = verticalOffset self.overwrite = overwrite self.videoBitRate = videoBitRate self.audioBitRate = audioBitRate } } /// Reine Geometrie, kein AVFoundation-Zugriff — pur testbar. /// Breite bleibt, Höhe = Breite × 5⁄4, auf eine gerade Zahl gerundet. public static func targetSize(forSourceSize sourceSize: CGSize) -> CGSize { let rawHeight = sourceSize.width * 5 / 4 let evenHeight = (rawHeight / 2).rounded() * 2 return CGSize(width: sourceSize.width, height: evenHeight) } public static func crop( sourceURL: URL, destinationURL: URL, options: Options = Options(), progress: ((Double) -> Void)? = nil ) async throws { if !options.overwrite && FileManager.default.fileExists(atPath: destinationURL.path) { throw CropEngineError.destinationAlreadyExists } let outputFileType = try fileType(forExtension: destinationURL.pathExtension) let info = try await VideoInspector.inspect(sourceURL) guard info.isNineBySixteen else { throw CropEngineError.sourceNotNineBySixteen(actualSize: info.pixelSize) } let cropSize = targetSize(forSourceSize: info.pixelSize) let cropOriginY = verticalCropOrigin( sourceHeight: info.pixelSize.height, targetHeight: cropSize.height, verticalOffset: options.verticalOffset ) let cropRect = CGRect(x: 0, y: cropOriginY, width: cropSize.width, height: cropSize.height) let asset = AVURLAsset(url: sourceURL) let tracks = try await asset.load(.tracks) guard let videoTrack = tracks.first(where: { $0.mediaType == .video }) else { throw CropEngineError.readerSetupFailed(underlying: VideoInspectorError.noVideoTrack) } let audioTrack = tracks.first(where: { $0.mediaType == .audio }) let videoSettings = videoEncoderSettings(size: cropSize, frameRate: info.frameRate, bitRateOverride: options.videoBitRate) if options.overwrite { try? FileManager.default.removeItem(at: destinationURL) } let reader: AVAssetReader do { reader = try AVAssetReader(asset: asset) } catch { throw CropEngineError.readerSetupFailed(underlying: error) } let videoReaderSettings: [String: Any] = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoReaderSettings) videoOutput.alwaysCopiesSampleData = false guard reader.canAdd(videoOutput) else { throw CropEngineError.readerSetupFailed(underlying: nil) } reader.add(videoOutput) var audioOutput: AVAssetReaderTrackOutput? if let audioTrack { // Decodiert zu PCM — der Writer-Input unten enkodiert das dann nach AAC. let audioReaderSettings: [String: Any] = [AVFormatIDKey: kAudioFormatLinearPCM] let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings) output.alwaysCopiesSampleData = false guard reader.canAdd(output) else { throw CropEngineError.readerSetupFailed(underlying: nil) } reader.add(output) audioOutput = output } let writer: AVAssetWriter do { writer = try AVAssetWriter(outputURL: destinationURL, fileType: outputFileType) } catch { throw CropEngineError.writerSetupFailed(underlying: error) } let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) videoInput.expectsMediaDataInRealTime = false guard writer.canAdd(videoInput) else { throw CropEngineError.writerSetupFailed(underlying: nil) } writer.add(videoInput) let pixelBufferAttributes: [String: Any] = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String: Int(cropSize.width), kCVPixelBufferHeightKey as String: Int(cropSize.height) ] let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor( assetWriterInput: videoInput, sourcePixelBufferAttributes: pixelBufferAttributes ) var audioInput: AVAssetWriterInput? if audioOutput != nil, let audioTrack { let sourceChannelCount = try await audioTrack.load(.formatDescriptions) .first .flatMap { CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee.mChannelsPerFrame } .map(Int.init) ?? 2 let audioSettings = audioEncoderSettings(sourceChannelCount: sourceChannelCount, bitRateOverride: options.audioBitRate) let input = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) input.expectsMediaDataInRealTime = false guard writer.canAdd(input) else { throw CropEngineError.writerSetupFailed(underlying: nil) } writer.add(input) audioInput = input } guard reader.startReading() else { throw CropEngineError.readerSetupFailed(underlying: reader.error) } guard writer.startWriting() else { throw CropEngineError.writerSetupFailed(underlying: writer.error) } writer.startSession(atSourceTime: .zero) do { let durationSeconds = CMTimeGetSeconds(info.duration) let ciContext = CIContext() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await processVideo( output: videoOutput, input: videoInput, adaptor: pixelBufferAdaptor, cropRect: cropRect, ciContext: ciContext, durationSeconds: durationSeconds, progress: progress ) } if let audioOutput, let audioInput { group.addTask { try await transcodeAudio(output: audioOutput, input: audioInput) } } try await group.waitForAll() } await writer.finishWriting() guard writer.status == .completed else { throw CropEngineError.processingFailed(underlying: writer.error) } } catch { reader.cancelReading() writer.cancelWriting() try? FileManager.default.removeItem(at: destinationURL) throw error } } // MARK: - Geometrie private static func verticalCropOrigin(sourceHeight: CGFloat, targetHeight: CGFloat, verticalOffset: CGFloat) -> CGFloat { let slack = max(sourceHeight - targetHeight, 0) let centered = slack / 2 let shifted = centered + (verticalOffset * slack / 2) let clamped = min(max(shifted, 0), slack) return (clamped / 2).rounded() * 2 } // MARK: - Container & Codec /// Nur MP4 — das ist das feste Zielformat für die Meta-Ads-Auslieferung. private static func fileType(forExtension fileExtension: String) throws -> AVFileType { switch fileExtension.lowercased() { case "mp4": return .mp4 default: throw CropEngineError.unsupportedContainer(fileExtension: fileExtension) } } private static func videoEncoderSettings(size: CGSize, frameRate: Float, bitRateOverride: Int?) -> [String: Any] { let width = Int(size.width) let height = Int(size.height) let bitRate = bitRateOverride ?? recommendedVideoBitRate(width: width, height: height, frameRate: frameRate) let keyFrameInterval = max(Int(frameRate.rounded()) * 2, 1) return [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: width, AVVideoHeightKey: height, AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: bitRate, AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, AVVideoMaxKeyFrameIntervalKey: keyFrameInterval ] ] } /// Faustregel für eine Meta-Ads-taugliche H.264-Qualität: ~0.1 Bit pro /// Pixel und Frame, geklemmt auf einen sinnvollen Korridor. Orientiert an /// Metas Empfehlung von grob mindestens 8 Mbit/s für 1080-Klasse-Vertikal- /// /4:5-Formate — keine von Meta veröffentlichte Formel, im Zweifel gegen /// die aktuelle Meta-Spec-Seite prüfen. private static func recommendedVideoBitRate(width: Int, height: Int, frameRate: Float) -> Int { let pixelsPerSecond = Double(width * height) * Double(frameRate) let bitsPerPixel = 0.1 let raw = Int(pixelsPerSecond * bitsPerPixel) return min(max(raw, 6_000_000), 25_000_000) } /// AAC-LC, 48 kHz, Metas Empfehlung von mindestens 128 kbit/s für Stereo. /// Kanalzahl folgt der Quelle (max. Stereo). private static func audioEncoderSettings(sourceChannelCount: Int, bitRateOverride: Int?) -> [String: Any] { let channels = min(max(sourceChannelCount, 1), 2) return [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48_000, AVNumberOfChannelsKey: channels, AVEncoderBitRateKey: bitRateOverride ?? 128_000 ] } // MARK: - Verarbeitung private static func processVideo( output: AVAssetReaderTrackOutput, input: AVAssetWriterInput, adaptor: AVAssetWriterInputPixelBufferAdaptor, cropRect: CGRect, ciContext: CIContext, durationSeconds: Double, progress: ((Double) -> Void)? ) async throws { let queue = DispatchQueue(label: "dev.zielke.droptwo.cropengine.video") try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in var didResume = false func finish(throwing error: Error? = nil) { guard !didResume else { return } didResume = true input.markAsFinished() if let error { continuation.resume(throwing: error) } else { continuation.resume() } } input.requestMediaDataWhenReady(on: queue) { while input.isReadyForMoreMediaData { guard let sampleBuffer = output.copyNextSampleBuffer() else { finish() return } guard let sourceBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { continue } let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) guard let pool = adaptor.pixelBufferPool else { finish(throwing: CropEngineError.processingFailed(underlying: nil)) return } var pixelBufferOut: CVPixelBuffer? let status = CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBufferOut) guard status == kCVReturnSuccess, let outBuffer = pixelBufferOut else { finish(throwing: CropEngineError.processingFailed(underlying: nil)) return } let croppedImage = CIImage(cvPixelBuffer: sourceBuffer) .cropped(to: cropRect) .transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y)) ciContext.render(croppedImage, to: outBuffer) if !adaptor.append(outBuffer, withPresentationTime: presentationTime) { finish(throwing: CropEngineError.processingFailed(underlying: nil)) return } if durationSeconds > 0 { progress?(min(CMTimeGetSeconds(presentationTime) / durationSeconds, 1.0)) } } } } } /// Liest decodierte PCM-Samples und reicht sie an den Writer-Input durch; /// der enkodiert sie anhand seiner `outputSettings` nach AAC. Kein /// manuelles Audio-Graph-Setup nötig — das ist Standard-AVFoundation- /// Verhalten für nicht-passthrough Writer-Inputs. private static func transcodeAudio(output: AVAssetReaderTrackOutput, input: AVAssetWriterInput) async throws { let queue = DispatchQueue(label: "dev.zielke.droptwo.cropengine.audio") try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in var didResume = false input.requestMediaDataWhenReady(on: queue) { while input.isReadyForMoreMediaData { guard let sampleBuffer = output.copyNextSampleBuffer() else { guard !didResume else { return } didResume = true input.markAsFinished() continuation.resume() return } if !input.append(sampleBuffer) { guard !didResume else { return } didResume = true input.markAsFinished() continuation.resume(throwing: CropEngineError.processingFailed(underlying: nil)) return } } } } } } public enum CropEngineError: Error { case sourceNotNineBySixteen(actualSize: CGSize) case destinationAlreadyExists case unsupportedContainer(fileExtension: String) case readerSetupFailed(underlying: Error?) case writerSetupFailed(underlying: Error?) case processingFailed(underlying: Error?) }