|
|
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..<height {
|
|
|
let rowStart = base + row * bytesPerRow
|
|
|
memset(rowStart, 255, (width / 2) * 4)
|
|
|
memset(rowStart + (width / 2) * 4, 0, bytesPerRow - (width / 2) * 4)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Auswertung
|
|
|
|
|
|
/// Mittlere Helligkeit (0…1) des Frames bei `seconds` — gerechnet über
|
|
|
/// alle Pixel, auf ein kleines Graubild heruntergerechnet.
|
|
|
static func averageBrightness(of url: URL, at seconds: Double) async throws -> 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<CGImage, Error>) 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..<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()
|
|
|
}
|
|
|
}
|