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 } /// Bildinhalt der erzeugten Frames. enum Pattern { /// Ganze Fläche einfarbig grau, Helligkeit wechselt von Frame zu Frame. case flatByFrameIndex /// Obere Bildhälfte weiß, untere schwarz — damit lässt sich prüfen, /// welcher vertikale Ausschnitt tatsächlich gerendert wurde. case brightTopDarkBottom /// Linke Bildhälfte weiß, rechte schwarz — für quer gespeicherte, /// per Transformation gedrehte Clips (iPhone). case brightLeftDarkRight } 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, pattern: Pattern = .flatByFrameIndex, fileName: String? = nil, in directory: URL ) throws -> GeneratedVideo { try makeClip( size: CGSize(width: 216, height: 384), frameRate: frameRate, seconds: seconds, includeAudio: includeAudio, pattern: pattern, fileName: fileName, in: directory ) } static func makeClip( size: CGSize, frameRate: Int32 = 10, seconds: Double = 2, includeAudio: Bool = true, pattern: Pattern = .flatByFrameIndex, transform: CGAffineTransform = .identity, 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 videoInput.transform = transform 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, pattern: pattern, 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, pattern: Pattern, 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, pattern: pattern) 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, pattern: Pattern) { CVPixelBufferLockBaseAddress(pixelBuffer, []) defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return } let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) switch pattern { case .flatByFrameIndex: let value = Int32(UInt8((frameIndex * 37) % 256)) memset(base, value, bytesPerRow * height) case .brightTopDarkBottom: let topRows = height / 2 memset(base, 255, bytesPerRow * topRows) memset(base + bytesPerRow * topRows, 0, bytesPerRow * (height - topRows)) case .brightLeftDarkRight: let width = CVPixelBufferGetWidth(pixelBuffer) for row in 0.. Double { let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url)) generator.requestedTimeToleranceBefore = .zero generator.requestedTimeToleranceAfter = .zero let time = CMTime(seconds: seconds, preferredTimescale: 600) let image = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, image, _, _, error in if let image { continuation.resume(returning: image) } else { continuation.resume(throwing: error ?? FactoryError.pixelBufferCreationFailed) } } } let side = 16 var pixels = [UInt8](repeating: 0, count: side * side) let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in guard let context = CGContext( data: buffer.baseAddress, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue ) else { return false } context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) return true } guard drawn else { throw FactoryError.pixelBufferCreationFailed } return Double(pixels.reduce(0) { $0 + Int($1) }) / Double(pixels.count * 255) } // 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..