|
|
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..<Int(frameCount) {
|
|
|
let sample = sin(2.0 * .pi * frequency * Double(frame) / sampleRate)
|
|
|
channelData[0][frame] = Int16(sample * Double(Int16.max) * 0.2)
|
|
|
}
|
|
|
|
|
|
guard let formatDescription = format.formatDescription as CMFormatDescription? else {
|
|
|
throw FactoryError.audioBufferCreationFailed
|
|
|
}
|
|
|
|
|
|
var timing = CMSampleTimingInfo(
|
|
|
duration: CMTime(value: 1, timescale: Int32(sampleRate)),
|
|
|
presentationTimeStamp: .zero,
|
|
|
decodeTimeStamp: .invalid
|
|
|
)
|
|
|
var sampleBuffer: CMSampleBuffer?
|
|
|
let createStatus = CMSampleBufferCreate(
|
|
|
allocator: kCFAllocatorDefault,
|
|
|
dataBuffer: nil,
|
|
|
dataReady: false,
|
|
|
makeDataReadyCallback: nil,
|
|
|
refcon: nil,
|
|
|
formatDescription: formatDescription,
|
|
|
sampleCount: CMItemCount(frameCount),
|
|
|
sampleTimingEntryCount: 1,
|
|
|
sampleTimingArray: &timing,
|
|
|
sampleSizeEntryCount: 0,
|
|
|
sampleSizeArray: nil,
|
|
|
sampleBufferOut: &sampleBuffer
|
|
|
)
|
|
|
guard createStatus == noErr, let buffer = sampleBuffer else {
|
|
|
throw FactoryError.audioBufferCreationFailed
|
|
|
}
|
|
|
|
|
|
let setDataStatus = CMSampleBufferSetDataBufferFromAudioBufferList(
|
|
|
buffer,
|
|
|
blockBufferAllocator: kCFAllocatorDefault,
|
|
|
blockBufferMemoryAllocator: kCFAllocatorDefault,
|
|
|
flags: 0,
|
|
|
bufferList: pcmBuffer.mutableAudioBufferList
|
|
|
)
|
|
|
guard setDataStatus == noErr else {
|
|
|
throw FactoryError.audioBufferCreationFailed
|
|
|
}
|
|
|
|
|
|
let queue = DispatchQueue(label: "dev.zielke.droptwo.testvideofactory.audio")
|
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
var didAppend = false
|
|
|
|
|
|
input.requestMediaDataWhenReady(on: queue) {
|
|
|
guard input.isReadyForMoreMediaData, !didAppend else { return }
|
|
|
didAppend = true
|
|
|
input.append(buffer)
|
|
|
input.markAsFinished()
|
|
|
semaphore.signal()
|
|
|
}
|
|
|
|
|
|
semaphore.wait()
|
|
|
}
|
|
|
}
|