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