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 { /// Wo der Ausschnitt über die Laufzeit sitzt. Standard: mittig, ohne /// Positionsmarken. public var cropPlan: CropPlan /// 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( cropPlan: CropPlan = CropPlan(), overwrite: Bool = false, videoBitRate: Int? = nil, audioBitRate: Int? = nil ) { self.cropPlan = cropPlan 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) } /// Der 4:5-Ausschnitt in Bildkoordinaten mit Ursprung **oben links** — /// so, wie ihn Vorschau und Nutzer sehen. `offset` wie in `CropPlan`: /// -1 = ganz oben, 0 = mittig, 1 = ganz unten. Die Oberkante landet auf /// einer geraden Pixelzeile. public static func cropRect(forSourceSize sourceSize: CGSize, offset: CGFloat) -> CGRect { let size = targetSize(forSourceSize: sourceSize) let slack = max(sourceSize.height - size.height, 0) let clampedOffset = min(max(offset, -1), 1) let top = slack / 2 + clampedOffset * slack / 2 let evenTop = min((top / 2).rounded() * 2, slack) return CGRect(x: 0, y: evenTop, width: size.width, height: size.height) } 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 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 }) // iPhone-Clips liegen oft quer im Container und werden nur über die // Track-Transformation hochkant angezeigt. Der Reader liefert die // Frames ungedreht — die Drehung muss vor dem Crop angewendet werden. let orientation = try await imageOrientation(of: videoTrack) 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, sourceSize: info.pixelSize, orientation: orientation, cropPlan: options.cropPlan, 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 /// Übersetzt die Track-Transformation in eine Bildausrichtung. Erkannt /// werden die vier Drehungen um 90°; alles andere gilt als unverändert. static func orientation(for transform: CGAffineTransform) -> CGImagePropertyOrientation { switch (transform.a.rounded(), transform.b.rounded(), transform.c.rounded(), transform.d.rounded()) { case (0, 1, -1, 0): return .right case (0, -1, 1, 0): return .left case (-1, 0, 0, -1): return .down default: return .up } } private static func imageOrientation(of track: AVAssetTrack) async throws -> CGImagePropertyOrientation { orientation(for: try await track.load(.preferredTransform)) } /// Dreht ein Frame aufrecht und legt es wieder auf den Ursprung, damit /// `coreImageCropRect` (Ursprung 0/0, Größe = angezeigte Größe) passt. private static func upright(_ image: CIImage, orientation: CGImagePropertyOrientation) -> CIImage { guard orientation != .up else { return image } let oriented = image.oriented(orientation) return oriented.transformed(by: CGAffineTransform(translationX: -oriented.extent.minX, y: -oriented.extent.minY)) } /// `cropRect` in CoreImage-Koordinaten: Ursprung **unten links**, die /// oberste Pixelzeile eines `CIImage(cvPixelBuffer:)` liegt bei y = Höhe. private static func coreImageCropRect(sourceSize: CGSize, offset: CGFloat) -> CGRect { let rect = cropRect(forSourceSize: sourceSize, offset: offset) return CGRect(x: rect.minX, y: sourceSize.height - rect.maxY, width: rect.width, height: rect.height) } // 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, sourceSize: CGSize, orientation: CGImagePropertyOrientation, cropPlan: CropPlan, 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 cropRect = coreImageCropRect( sourceSize: sourceSize, offset: cropPlan.offset(at: CMTimeGetSeconds(presentationTime)) ) let croppedImage = upright(CIImage(cvPixelBuffer: sourceBuffer), orientation: orientation) .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?) }