diff --git a/Sources/DropTwoCore/CropEngine.swift b/Sources/DropTwoCore/CropEngine.swift new file mode 100644 index 0000000..5f31b6d --- /dev/null +++ b/Sources/DropTwoCore/CropEngine.swift @@ -0,0 +1,370 @@ +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?) +} diff --git a/Sources/DropTwoCore/VideoInspector.swift b/Sources/DropTwoCore/VideoInspector.swift new file mode 100644 index 0000000..ec4706b --- /dev/null +++ b/Sources/DropTwoCore/VideoInspector.swift @@ -0,0 +1,126 @@ +import AVFoundation +import CoreMedia + +/// Liest Dimension, Codec, Framerate und Dauer einer Videodatei. Zustandslos, +/// ein einziger Async-Entry-Point — der Rest von DropTwoCore braucht nie +/// direkten AVFoundation-Zugriff auf die Quelldatei. +public enum VideoInspector { + public static func inspect(_ url: URL) async throws -> VideoInfo { + let asset = AVURLAsset(url: url) + + let tracks: [AVAssetTrack] + do { + tracks = try await asset.load(.tracks) + } catch { + throw VideoInspectorError.assetUnreadable(underlying: error) + } + + guard let videoTrack = tracks.first(where: { $0.mediaType == .video }) else { + throw VideoInspectorError.noVideoTrack + } + let hasAudioTrack = tracks.contains { $0.mediaType == .audio } + + do { + let naturalSize = try await videoTrack.load(.naturalSize) + let preferredTransform = try await videoTrack.load(.preferredTransform) + let frameRate = try await videoTrack.load(.nominalFrameRate) + let formatDescriptions = try await videoTrack.load(.formatDescriptions) + let duration = try await asset.load(.duration) + + let transformedSize = naturalSize.applying(preferredTransform) + let pixelSize = CGSize(width: abs(transformedSize.width), height: abs(transformedSize.height)) + + return VideoInfo( + pixelSize: pixelSize, + codec: codec(fromFormatDescriptions: formatDescriptions), + frameRate: frameRate, + duration: duration, + hasAudioTrack: hasAudioTrack + ) + } catch let error as VideoInspectorError { + throw error + } catch { + throw VideoInspectorError.assetUnreadable(underlying: error) + } + } + + private static func codec(fromFormatDescriptions formatDescriptions: [CMFormatDescription]) -> VideoCodec { + guard let description = formatDescriptions.first else { + return .other(fourCC: "????") + } + let subType = CMFormatDescriptionGetMediaSubType(description) + switch subType { + case kCMVideoCodecType_H264: + return .h264 + case kCMVideoCodecType_HEVC: + return .hevc + case kCMVideoCodecType_AppleProRes422Proxy: + return .appleProRes422Proxy + case kCMVideoCodecType_AppleProRes422LT: + return .appleProRes422LT + case kCMVideoCodecType_AppleProRes422: + return .appleProRes422 + case kCMVideoCodecType_AppleProRes422HQ: + return .appleProRes422HQ + case kCMVideoCodecType_AppleProRes4444: + return .appleProRes4444 + case kCMVideoCodecType_AppleProRes4444XQ: + return .appleProRes4444XQ + default: + return .other(fourCC: fourCCString(subType)) + } + } + + private static func fourCCString(_ code: FourCharCode) -> String { + let bytes: [UInt8] = [ + UInt8((code >> 24) & 0xFF), + UInt8((code >> 16) & 0xFF), + UInt8((code >> 8) & 0xFF), + UInt8(code & 0xFF) + ] + return String(bytes: bytes, encoding: .ascii) ?? "????" + } +} + +/// Ergebnis einer erfolgreichen Inspektion. +public struct VideoInfo: Equatable { + /// Orientierungskorrigiert — `preferredTransform` bereits angewendet. + public let pixelSize: CGSize + public let codec: VideoCodec + public let frameRate: Float + public let duration: CMTime + public let hasAudioTrack: Bool + + public init(pixelSize: CGSize, codec: VideoCodec, frameRate: Float, duration: CMTime, hasAudioTrack: Bool) { + self.pixelSize = pixelSize + self.codec = codec + self.frameRate = frameRate + self.duration = duration + self.hasAudioTrack = hasAudioTrack + } + + /// 9:16 mit kleiner Toleranz — echtes Footage rundet gelegentlich leicht ab. + public var isNineBySixteen: Bool { + guard pixelSize.height > 0 else { return false } + let ratio = pixelSize.width / pixelSize.height + let target: CGFloat = 9.0 / 16.0 + return abs(ratio - target) < 0.01 + } +} + +public enum VideoCodec: Equatable { + case h264 + case hevc + case appleProRes422Proxy + case appleProRes422LT + case appleProRes422 + case appleProRes422HQ + case appleProRes4444 + case appleProRes4444XQ + case other(fourCC: String) +} + +public enum VideoInspectorError: Error { + case noVideoTrack + case assetUnreadable(underlying: Error) +} diff --git a/Tests/DropTwoCoreTests/CropEngineTests.swift b/Tests/DropTwoCoreTests/CropEngineTests.swift new file mode 100644 index 0000000..8f79427 --- /dev/null +++ b/Tests/DropTwoCoreTests/CropEngineTests.swift @@ -0,0 +1,141 @@ +import CoreMedia +import XCTest +@testable import DropTwoCore + +final class CropEngineTests: XCTestCase { + private var tempDirectory: URL! + + override func setUpWithError() throws { + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDirectory) + } + + // MARK: - Geometrie (rein arithmetisch, kein Video nötig) + + func testTargetSizeMatchesReadmeExamples() { + XCTAssertEqual( + CropEngine.targetSize(forSourceSize: CGSize(width: 1080, height: 1920)), + CGSize(width: 1080, height: 1350) + ) + XCTAssertEqual( + CropEngine.targetSize(forSourceSize: CGSize(width: 2160, height: 3840)), + CGSize(width: 2160, height: 2700) + ) + } + + func testTargetSizeRoundsHeightToEvenNumber() { + let size = CropEngine.targetSize(forSourceSize: CGSize(width: 217, height: 386)) + + XCTAssertEqual(size.width, 217) + XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0) + } + + // MARK: - End-to-End gegen einen synthetischen Clip + + func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + + let resultInfo = try await VideoInspector.inspect(destination) + let expectedSize = CropEngine.targetSize(forSourceSize: clip.size) + XCTAssertEqual(resultInfo.pixelSize, expectedSize) + XCTAssertEqual(resultInfo.codec, .h264) + XCTAssertTrue(resultInfo.hasAudioTrack) + XCTAssertEqual(CMTimeGetSeconds(resultInfo.duration), clip.seconds, accuracy: 0.3) + } + + func testCropsWithoutAudioTrack() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + + let resultInfo = try await VideoInspector.inspect(destination) + XCTAssertFalse(resultInfo.hasAudioTrack) + } + + func testReportsProgress() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + + var reportedValues: [Double] = [] + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) { value in + reportedValues.append(value) + } + + XCTAssertFalse(reportedValues.isEmpty) + XCTAssertEqual(reportedValues.last ?? 0, 1.0, accuracy: 0.15) + } + + // MARK: - Ablehnungen + + func testRejectsNonNineBySixteenSource() async throws { + let clip = try TestVideoFactory.makeClip(size: CGSize(width: 400, height: 400), in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + + do { + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + XCTFail("Erwartete Ablehnung für eine Nicht-9:16-Quelle") + } catch CropEngineError.sourceNotNineBySixteen { + // erwartet + } + } + + func testRejectsExistingDestinationWithoutOverwrite() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + FileManager.default.createFile(atPath: destination.path, contents: Data()) + + do { + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + XCTFail("Erwartete Ablehnung wegen bestehender Zieldatei") + } catch CropEngineError.destinationAlreadyExists { + // erwartet + } + } + + func testOverwritesExistingDestinationWhenExplicitlyAllowed() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mp4") + FileManager.default.createFile(atPath: destination.path, contents: Data("platzhalter".utf8)) + + try await CropEngine.crop( + sourceURL: clip.url, + destinationURL: destination, + options: .init(overwrite: true) + ) + + let resultInfo = try await VideoInspector.inspect(destination) + XCTAssertEqual(resultInfo.pixelSize, CropEngine.targetSize(forSourceSize: clip.size)) + } + + func testRejectsUnsupportedContainer() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mxf") + + do { + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + XCTFail("Erwartete Ablehnung für ein MXF-Ziel") + } catch CropEngineError.unsupportedContainer { + // erwartet + } + } + + func testRejectsMovAsDestinationBecauseTargetIsFixedToMp4() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) + let destination = tempDirectory.appendingPathComponent("out.mov") + + do { + try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) + XCTFail("Erwartete Ablehnung für ein MOV-Ziel — Zielformat ist fest auf MP4 gesetzt") + } catch CropEngineError.unsupportedContainer { + // erwartet + } + } +} diff --git a/Tests/DropTwoCoreTests/TestVideoFactory.swift b/Tests/DropTwoCoreTests/TestVideoFactory.swift new file mode 100644 index 0000000..c425aba --- /dev/null +++ b/Tests/DropTwoCoreTests/TestVideoFactory.swift @@ -0,0 +1,258 @@ +import AVFoundation +import CoreMedia + +/// Erzeugt winzige, echte Videodateien zur Laufzeit für die Tests — statt +/// Binärdateien einzuchecken. Passt zur "textbasiert, merge-freundlich"- +/// Philosophie des Repos und hält die Tests hermetisch reproduzierbar. +enum TestVideoFactory { + struct GeneratedVideo { + let url: URL + let size: CGSize + let seconds: Double + let hasAudio: Bool + } + + enum FactoryError: Error { + case writerSetupFailed(underlying: Error?) + case pixelBufferPoolUnavailable + case pixelBufferCreationFailed + case audioBufferCreationFailed + } + + /// 9:16-Clip, Standardgröße 216×384 (kleinstmögliches Vielfaches, das + /// exakt 9:16 trifft und für Tests schnell zu erzeugen/verarbeiten ist). + static func makeNineBySixteenClip( + frameRate: Int32 = 10, + seconds: Double = 2, + includeAudio: Bool = true, + fileName: String? = nil, + in directory: URL + ) throws -> GeneratedVideo { + try makeClip( + size: CGSize(width: 216, height: 384), + frameRate: frameRate, + seconds: seconds, + includeAudio: includeAudio, + fileName: fileName, + in: directory + ) + } + + static func makeClip( + size: CGSize, + frameRate: Int32 = 10, + seconds: Double = 2, + includeAudio: Bool = true, + fileName: String? = nil, + in directory: URL + ) throws -> GeneratedVideo { + let url = fileName.map { directory.appendingPathComponent($0) } + ?? directory.appendingPathComponent(UUID().uuidString).appendingPathExtension("mov") + + let writer: AVAssetWriter + do { + writer = try AVAssetWriter(outputURL: url, fileType: .mov) + } catch { + throw FactoryError.writerSetupFailed(underlying: error) + } + + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: Int(size.width), + AVVideoHeightKey: Int(size.height) + ] + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + videoInput.expectsMediaDataInRealTime = false + + let pixelBufferAttributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: Int(size.width), + kCVPixelBufferHeightKey as String: Int(size.height) + ] + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: videoInput, + sourcePixelBufferAttributes: pixelBufferAttributes + ) + guard writer.canAdd(videoInput) else { + throw FactoryError.writerSetupFailed(underlying: nil) + } + writer.add(videoInput) + + var audioInput: AVAssetWriterInput? + if includeAudio { + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ] + let input = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + input.expectsMediaDataInRealTime = false + guard writer.canAdd(input) else { + throw FactoryError.writerSetupFailed(underlying: nil) + } + writer.add(input) + audioInput = input + } + + guard writer.startWriting() else { + throw FactoryError.writerSetupFailed(underlying: writer.error) + } + writer.startSession(atSourceTime: .zero) + + let frameCount = Int(seconds * Double(frameRate)) + try appendFrames(count: frameCount, frameRate: frameRate, input: videoInput, adaptor: adaptor) + + if let audioInput { + try appendAudio(seconds: seconds, sampleRate: 44_100, input: audioInput) + } + + let semaphore = DispatchSemaphore(value: 0) + writer.finishWriting { semaphore.signal() } + semaphore.wait() + + guard writer.status == .completed else { + throw FactoryError.writerSetupFailed(underlying: writer.error) + } + + return GeneratedVideo(url: url, size: size, seconds: seconds, hasAudio: includeAudio) + } + + // MARK: - Video + + /// Wichtig: `isReadyForMoreMediaData` wird bei `expectsMediaDataInRealTime + /// = false` erst durch `requestMediaDataWhenReady` überhaupt getrieben — + /// ein reines Polling auf das Flag ohne diesen Callback bleibt für immer + /// bei `false` hängen. Selbe Technik wie in `CropEngine`, nur synchron + /// per Semaphore statt async/await gebrückt. + private static func appendFrames( + count: Int, + frameRate: Int32, + input: AVAssetWriterInput, + adaptor: AVAssetWriterInputPixelBufferAdaptor + ) throws { + let queue = DispatchQueue(label: "dev.zielke.droptwo.testvideofactory.video") + let semaphore = DispatchSemaphore(value: 0) + var thrownError: Error? + var nextFrameIndex = 0 + + input.requestMediaDataWhenReady(on: queue) { + while input.isReadyForMoreMediaData { + guard nextFrameIndex < count else { + input.markAsFinished() + semaphore.signal() + return + } + guard let pool = adaptor.pixelBufferPool else { + thrownError = FactoryError.pixelBufferPoolUnavailable + input.markAsFinished() + semaphore.signal() + return + } + var pixelBufferOut: CVPixelBuffer? + CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBufferOut) + guard let pixelBuffer = pixelBufferOut else { + thrownError = FactoryError.pixelBufferCreationFailed + input.markAsFinished() + semaphore.signal() + return + } + fill(pixelBuffer: pixelBuffer, frameIndex: nextFrameIndex) + let presentationTime = CMTime(value: CMTimeValue(nextFrameIndex), timescale: frameRate) + adaptor.append(pixelBuffer, withPresentationTime: presentationTime) + nextFrameIndex += 1 + } + } + + semaphore.wait() + if let thrownError { + throw thrownError + } + } + + private static func fill(pixelBuffer: CVPixelBuffer, frameIndex: Int) { + CVPixelBufferLockBaseAddress(pixelBuffer, []) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return } + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let value = Int32(UInt8((frameIndex * 37) % 256)) + memset(base, value, bytesPerRow * height) + } + + // MARK: - Audio + + private static func appendAudio(seconds: Double, sampleRate: Double, input: AVAssetWriterInput) throws { + let frameCount = AVAudioFrameCount(seconds * sampleRate) + guard let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: sampleRate, channels: 1, interleaved: true), + let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { + throw FactoryError.audioBufferCreationFailed + } + pcmBuffer.frameLength = frameCount + guard let channelData = pcmBuffer.int16ChannelData else { + throw FactoryError.audioBufferCreationFailed + } + + let frequency = 440.0 + for frame in 0..