You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

329 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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()
}
}

Powered by TurnKey Linux.