VideoInspector und CropEngine: 4:5-Crop für Meta-Ads-Auslieferung

Schritt 2 der Umsetzungsreihenfolge.

VideoInspector liest Dimension (orientierungskorrigiert), Codec,
Framerate, Dauer und Audiospur-Vorhandensein über die moderne
async AVFoundation-API.

CropEngine läuft über AVAssetReader/AVAssetWriter statt
AVAssetExportSession, weil das die volle Kontrolle über Zielbitrate
und Profil gibt. Zielformat ist fest MP4/H.264/AAC, unabhängig vom
Quellcodec — das Deliverable landet ausschließlich bei Meta Ads.
Bitrate wird aus Auflösung × Framerate abgeleitet (Faustregel,
überschreibbar über Options), Audio läuft nicht mehr per Passthrough,
sondern wird nach AAC-LC transkodiert. Crop bleibt volle Breite,
vertikal zentriert, Höhe auf gerade Zahl gerundet.

Tests synthetisieren ihre Clips zur Laufzeit per AVAssetWriter
(TestVideoFactory) statt Binärdateien einzuchecken.
main
Samuel Zielke 1 month ago
parent 2b7839592b
commit aea8a3e6fd

@ -0,0 +1,370 @@
import AVFoundation
import CoreImage
import CoreMedia
import CoreVideo
/// Schneidet eine 9:16-Quelle auf 4:5 zu und schreibt sie als MP4/H.264/AAC —
/// das feste Zielformat für die Meta-Ads-Veröffentlichung, unabhängig vom
/// Quellcodec (ProRes, H.264, HEVC, …). Läuft über `AVAssetReader`/
/// `AVAssetWriter` statt `AVAssetExportSession`, weil das die volle Kontrolle
/// über Zielbitrate und Profil gibt, die Presets nicht bieten.
///
/// Bekannte Grenzen dieser Implementierung:
/// - Der Crop läuft über eine 32BGRA-Zwischenstufe (CoreImage). Bei 10-Bit-
/// Quellen bedeutet das eine Requantisierung auf 8 Bit während des Crops —
/// für ein H.264-Ad-Deliverable ohnehin die Zielfarbtiefe, also keine
/// Einschränkung mehr wie bei einem codec-erhaltenden Export.
/// - Ziel ist ausschließlich `.mp4`; alles andere wird abgelehnt.
/// - Bitrate/Profil sind eine Faustregel an gängigen Meta-Ads-Empfehlungen
/// orientiert (H.264 High Profile, resolutionsabhängige Zielbitrate,
/// AAC-LC 128 kbit/s), keine von Meta veröffentlichte Formel — bei Bedarf
/// über `Options.videoBitRate`/`Options.audioBitRate` überschreibbar.
public enum CropEngine {
public struct Options {
/// -1...1, 0 = vertikal zentriert. Platzhalter für den in der README
/// als "Offene Punkte" genannten, später pro Datei einstellbaren Offset.
public var verticalOffset: CGFloat
/// false = eine bestehende Zieldatei wird nie stillschweigend überschrieben.
public var overwrite: Bool
/// Video-Zielbitrate in bit/s. nil = automatisch aus Auflösung und
/// Framerate berechnet.
public var videoBitRate: Int?
/// Audio-Zielbitrate in bit/s für AAC. nil = 128 kbit/s.
public var audioBitRate: Int?
public init(
verticalOffset: CGFloat = 0,
overwrite: Bool = false,
videoBitRate: Int? = nil,
audioBitRate: Int? = nil
) {
self.verticalOffset = verticalOffset
self.overwrite = overwrite
self.videoBitRate = videoBitRate
self.audioBitRate = audioBitRate
}
}
/// Reine Geometrie, kein AVFoundation-Zugriff — pur testbar.
/// Breite bleibt, Höhe = Breite × 5⁄4, auf eine gerade Zahl gerundet.
public static func targetSize(forSourceSize sourceSize: CGSize) -> CGSize {
let rawHeight = sourceSize.width * 5 / 4
let evenHeight = (rawHeight / 2).rounded() * 2
return CGSize(width: sourceSize.width, height: evenHeight)
}
public static func crop(
sourceURL: URL,
destinationURL: URL,
options: Options = Options(),
progress: ((Double) -> Void)? = nil
) async throws {
if !options.overwrite && FileManager.default.fileExists(atPath: destinationURL.path) {
throw CropEngineError.destinationAlreadyExists
}
let outputFileType = try fileType(forExtension: destinationURL.pathExtension)
let info = try await VideoInspector.inspect(sourceURL)
guard info.isNineBySixteen else {
throw CropEngineError.sourceNotNineBySixteen(actualSize: info.pixelSize)
}
let cropSize = targetSize(forSourceSize: info.pixelSize)
let cropOriginY = verticalCropOrigin(
sourceHeight: info.pixelSize.height,
targetHeight: cropSize.height,
verticalOffset: options.verticalOffset
)
let cropRect = CGRect(x: 0, y: cropOriginY, width: cropSize.width, height: cropSize.height)
let asset = AVURLAsset(url: sourceURL)
let tracks = try await asset.load(.tracks)
guard let videoTrack = tracks.first(where: { $0.mediaType == .video }) else {
throw CropEngineError.readerSetupFailed(underlying: VideoInspectorError.noVideoTrack)
}
let audioTrack = tracks.first(where: { $0.mediaType == .audio })
let videoSettings = videoEncoderSettings(size: cropSize, frameRate: info.frameRate, bitRateOverride: options.videoBitRate)
if options.overwrite {
try? FileManager.default.removeItem(at: destinationURL)
}
let reader: AVAssetReader
do {
reader = try AVAssetReader(asset: asset)
} catch {
throw CropEngineError.readerSetupFailed(underlying: error)
}
let videoReaderSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoReaderSettings)
videoOutput.alwaysCopiesSampleData = false
guard reader.canAdd(videoOutput) else {
throw CropEngineError.readerSetupFailed(underlying: nil)
}
reader.add(videoOutput)
var audioOutput: AVAssetReaderTrackOutput?
if let audioTrack {
// Decodiert zu PCM — der Writer-Input unten enkodiert das dann nach AAC.
let audioReaderSettings: [String: Any] = [AVFormatIDKey: kAudioFormatLinearPCM]
let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings)
output.alwaysCopiesSampleData = false
guard reader.canAdd(output) else {
throw CropEngineError.readerSetupFailed(underlying: nil)
}
reader.add(output)
audioOutput = output
}
let writer: AVAssetWriter
do {
writer = try AVAssetWriter(outputURL: destinationURL, fileType: outputFileType)
} catch {
throw CropEngineError.writerSetupFailed(underlying: error)
}
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
videoInput.expectsMediaDataInRealTime = false
guard writer.canAdd(videoInput) else {
throw CropEngineError.writerSetupFailed(underlying: nil)
}
writer.add(videoInput)
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: Int(cropSize.width),
kCVPixelBufferHeightKey as String: Int(cropSize.height)
]
let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: videoInput,
sourcePixelBufferAttributes: pixelBufferAttributes
)
var audioInput: AVAssetWriterInput?
if audioOutput != nil, let audioTrack {
let sourceChannelCount = try await audioTrack.load(.formatDescriptions)
.first
.flatMap { CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee.mChannelsPerFrame }
.map(Int.init) ?? 2
let audioSettings = audioEncoderSettings(sourceChannelCount: sourceChannelCount, bitRateOverride: options.audioBitRate)
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
input.expectsMediaDataInRealTime = false
guard writer.canAdd(input) else {
throw CropEngineError.writerSetupFailed(underlying: nil)
}
writer.add(input)
audioInput = input
}
guard reader.startReading() else {
throw CropEngineError.readerSetupFailed(underlying: reader.error)
}
guard writer.startWriting() else {
throw CropEngineError.writerSetupFailed(underlying: writer.error)
}
writer.startSession(atSourceTime: .zero)
do {
let durationSeconds = CMTimeGetSeconds(info.duration)
let ciContext = CIContext()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await processVideo(
output: videoOutput,
input: videoInput,
adaptor: pixelBufferAdaptor,
cropRect: cropRect,
ciContext: ciContext,
durationSeconds: durationSeconds,
progress: progress
)
}
if let audioOutput, let audioInput {
group.addTask {
try await transcodeAudio(output: audioOutput, input: audioInput)
}
}
try await group.waitForAll()
}
await writer.finishWriting()
guard writer.status == .completed else {
throw CropEngineError.processingFailed(underlying: writer.error)
}
} catch {
reader.cancelReading()
writer.cancelWriting()
try? FileManager.default.removeItem(at: destinationURL)
throw error
}
}
// MARK: - Geometrie
private static func verticalCropOrigin(sourceHeight: CGFloat, targetHeight: CGFloat, verticalOffset: CGFloat) -> CGFloat {
let slack = max(sourceHeight - targetHeight, 0)
let centered = slack / 2
let shifted = centered + (verticalOffset * slack / 2)
let clamped = min(max(shifted, 0), slack)
return (clamped / 2).rounded() * 2
}
// MARK: - Container & Codec
/// Nur MP4 — das ist das feste Zielformat für die Meta-Ads-Auslieferung.
private static func fileType(forExtension fileExtension: String) throws -> AVFileType {
switch fileExtension.lowercased() {
case "mp4":
return .mp4
default:
throw CropEngineError.unsupportedContainer(fileExtension: fileExtension)
}
}
private static func videoEncoderSettings(size: CGSize, frameRate: Float, bitRateOverride: Int?) -> [String: Any] {
let width = Int(size.width)
let height = Int(size.height)
let bitRate = bitRateOverride ?? recommendedVideoBitRate(width: width, height: height, frameRate: frameRate)
let keyFrameInterval = max(Int(frameRate.rounded()) * 2, 1)
return [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: bitRate,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
AVVideoMaxKeyFrameIntervalKey: keyFrameInterval
]
]
}
/// Faustregel für eine Meta-Ads-taugliche H.264-Qualität: ~0.1 Bit pro
/// Pixel und Frame, geklemmt auf einen sinnvollen Korridor. Orientiert an
/// Metas Empfehlung von grob mindestens 8 Mbit/s für 1080-Klasse-Vertikal-
/// /4:5-Formate — keine von Meta veröffentlichte Formel, im Zweifel gegen
/// die aktuelle Meta-Spec-Seite prüfen.
private static func recommendedVideoBitRate(width: Int, height: Int, frameRate: Float) -> Int {
let pixelsPerSecond = Double(width * height) * Double(frameRate)
let bitsPerPixel = 0.1
let raw = Int(pixelsPerSecond * bitsPerPixel)
return min(max(raw, 6_000_000), 25_000_000)
}
/// AAC-LC, 48 kHz, Metas Empfehlung von mindestens 128 kbit/s für Stereo.
/// Kanalzahl folgt der Quelle (max. Stereo).
private static func audioEncoderSettings(sourceChannelCount: Int, bitRateOverride: Int?) -> [String: Any] {
let channels = min(max(sourceChannelCount, 1), 2)
return [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 48_000,
AVNumberOfChannelsKey: channels,
AVEncoderBitRateKey: bitRateOverride ?? 128_000
]
}
// MARK: - Verarbeitung
private static func processVideo(
output: AVAssetReaderTrackOutput,
input: AVAssetWriterInput,
adaptor: AVAssetWriterInputPixelBufferAdaptor,
cropRect: CGRect,
ciContext: CIContext,
durationSeconds: Double,
progress: ((Double) -> Void)?
) async throws {
let queue = DispatchQueue(label: "dev.zielke.droptwo.cropengine.video")
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
var didResume = false
func finish(throwing error: Error? = nil) {
guard !didResume else { return }
didResume = true
input.markAsFinished()
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
input.requestMediaDataWhenReady(on: queue) {
while input.isReadyForMoreMediaData {
guard let sampleBuffer = output.copyNextSampleBuffer() else {
finish()
return
}
guard let sourceBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { continue }
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
guard let pool = adaptor.pixelBufferPool else {
finish(throwing: CropEngineError.processingFailed(underlying: nil))
return
}
var pixelBufferOut: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBufferOut)
guard status == kCVReturnSuccess, let outBuffer = pixelBufferOut else {
finish(throwing: CropEngineError.processingFailed(underlying: nil))
return
}
let croppedImage = CIImage(cvPixelBuffer: sourceBuffer)
.cropped(to: cropRect)
.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
ciContext.render(croppedImage, to: outBuffer)
if !adaptor.append(outBuffer, withPresentationTime: presentationTime) {
finish(throwing: CropEngineError.processingFailed(underlying: nil))
return
}
if durationSeconds > 0 {
progress?(min(CMTimeGetSeconds(presentationTime) / durationSeconds, 1.0))
}
}
}
}
}
/// Liest decodierte PCM-Samples und reicht sie an den Writer-Input durch;
/// der enkodiert sie anhand seiner `outputSettings` nach AAC. Kein
/// manuelles Audio-Graph-Setup nötig — das ist Standard-AVFoundation-
/// Verhalten für nicht-passthrough Writer-Inputs.
private static func transcodeAudio(output: AVAssetReaderTrackOutput, input: AVAssetWriterInput) async throws {
let queue = DispatchQueue(label: "dev.zielke.droptwo.cropengine.audio")
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
var didResume = false
input.requestMediaDataWhenReady(on: queue) {
while input.isReadyForMoreMediaData {
guard let sampleBuffer = output.copyNextSampleBuffer() else {
guard !didResume else { return }
didResume = true
input.markAsFinished()
continuation.resume()
return
}
if !input.append(sampleBuffer) {
guard !didResume else { return }
didResume = true
input.markAsFinished()
continuation.resume(throwing: CropEngineError.processingFailed(underlying: nil))
return
}
}
}
}
}
}
public enum CropEngineError: Error {
case sourceNotNineBySixteen(actualSize: CGSize)
case destinationAlreadyExists
case unsupportedContainer(fileExtension: String)
case readerSetupFailed(underlying: Error?)
case writerSetupFailed(underlying: Error?)
case processingFailed(underlying: Error?)
}

@ -0,0 +1,126 @@
import AVFoundation
import CoreMedia
/// Liest Dimension, Codec, Framerate und Dauer einer Videodatei. Zustandslos,
/// ein einziger Async-Entry-Point — der Rest von DropTwoCore braucht nie
/// direkten AVFoundation-Zugriff auf die Quelldatei.
public enum VideoInspector {
public static func inspect(_ url: URL) async throws -> VideoInfo {
let asset = AVURLAsset(url: url)
let tracks: [AVAssetTrack]
do {
tracks = try await asset.load(.tracks)
} catch {
throw VideoInspectorError.assetUnreadable(underlying: error)
}
guard let videoTrack = tracks.first(where: { $0.mediaType == .video }) else {
throw VideoInspectorError.noVideoTrack
}
let hasAudioTrack = tracks.contains { $0.mediaType == .audio }
do {
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let frameRate = try await videoTrack.load(.nominalFrameRate)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
let duration = try await asset.load(.duration)
let transformedSize = naturalSize.applying(preferredTransform)
let pixelSize = CGSize(width: abs(transformedSize.width), height: abs(transformedSize.height))
return VideoInfo(
pixelSize: pixelSize,
codec: codec(fromFormatDescriptions: formatDescriptions),
frameRate: frameRate,
duration: duration,
hasAudioTrack: hasAudioTrack
)
} catch let error as VideoInspectorError {
throw error
} catch {
throw VideoInspectorError.assetUnreadable(underlying: error)
}
}
private static func codec(fromFormatDescriptions formatDescriptions: [CMFormatDescription]) -> VideoCodec {
guard let description = formatDescriptions.first else {
return .other(fourCC: "????")
}
let subType = CMFormatDescriptionGetMediaSubType(description)
switch subType {
case kCMVideoCodecType_H264:
return .h264
case kCMVideoCodecType_HEVC:
return .hevc
case kCMVideoCodecType_AppleProRes422Proxy:
return .appleProRes422Proxy
case kCMVideoCodecType_AppleProRes422LT:
return .appleProRes422LT
case kCMVideoCodecType_AppleProRes422:
return .appleProRes422
case kCMVideoCodecType_AppleProRes422HQ:
return .appleProRes422HQ
case kCMVideoCodecType_AppleProRes4444:
return .appleProRes4444
case kCMVideoCodecType_AppleProRes4444XQ:
return .appleProRes4444XQ
default:
return .other(fourCC: fourCCString(subType))
}
}
private static func fourCCString(_ code: FourCharCode) -> String {
let bytes: [UInt8] = [
UInt8((code >> 24) & 0xFF),
UInt8((code >> 16) & 0xFF),
UInt8((code >> 8) & 0xFF),
UInt8(code & 0xFF)
]
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
}
/// Ergebnis einer erfolgreichen Inspektion.
public struct VideoInfo: Equatable {
/// Orientierungskorrigiert — `preferredTransform` bereits angewendet.
public let pixelSize: CGSize
public let codec: VideoCodec
public let frameRate: Float
public let duration: CMTime
public let hasAudioTrack: Bool
public init(pixelSize: CGSize, codec: VideoCodec, frameRate: Float, duration: CMTime, hasAudioTrack: Bool) {
self.pixelSize = pixelSize
self.codec = codec
self.frameRate = frameRate
self.duration = duration
self.hasAudioTrack = hasAudioTrack
}
/// 9:16 mit kleiner Toleranz — echtes Footage rundet gelegentlich leicht ab.
public var isNineBySixteen: Bool {
guard pixelSize.height > 0 else { return false }
let ratio = pixelSize.width / pixelSize.height
let target: CGFloat = 9.0 / 16.0
return abs(ratio - target) < 0.01
}
}
public enum VideoCodec: Equatable {
case h264
case hevc
case appleProRes422Proxy
case appleProRes422LT
case appleProRes422
case appleProRes422HQ
case appleProRes4444
case appleProRes4444XQ
case other(fourCC: String)
}
public enum VideoInspectorError: Error {
case noVideoTrack
case assetUnreadable(underlying: Error)
}

@ -0,0 +1,141 @@
import CoreMedia
import XCTest
@testable import DropTwoCore
final class CropEngineTests: XCTestCase {
private var tempDirectory: URL!
override func setUpWithError() throws {
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDirectory)
}
// MARK: - Geometrie (rein arithmetisch, kein Video nötig)
func testTargetSizeMatchesReadmeExamples() {
XCTAssertEqual(
CropEngine.targetSize(forSourceSize: CGSize(width: 1080, height: 1920)),
CGSize(width: 1080, height: 1350)
)
XCTAssertEqual(
CropEngine.targetSize(forSourceSize: CGSize(width: 2160, height: 3840)),
CGSize(width: 2160, height: 2700)
)
}
func testTargetSizeRoundsHeightToEvenNumber() {
let size = CropEngine.targetSize(forSourceSize: CGSize(width: 217, height: 386))
XCTAssertEqual(size.width, 217)
XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0)
}
// MARK: - End-to-End gegen einen synthetischen Clip
func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
let resultInfo = try await VideoInspector.inspect(destination)
let expectedSize = CropEngine.targetSize(forSourceSize: clip.size)
XCTAssertEqual(resultInfo.pixelSize, expectedSize)
XCTAssertEqual(resultInfo.codec, .h264)
XCTAssertTrue(resultInfo.hasAudioTrack)
XCTAssertEqual(CMTimeGetSeconds(resultInfo.duration), clip.seconds, accuracy: 0.3)
}
func testCropsWithoutAudioTrack() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
let resultInfo = try await VideoInspector.inspect(destination)
XCTAssertFalse(resultInfo.hasAudioTrack)
}
func testReportsProgress() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
var reportedValues: [Double] = []
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) { value in
reportedValues.append(value)
}
XCTAssertFalse(reportedValues.isEmpty)
XCTAssertEqual(reportedValues.last ?? 0, 1.0, accuracy: 0.15)
}
// MARK: - Ablehnungen
func testRejectsNonNineBySixteenSource() async throws {
let clip = try TestVideoFactory.makeClip(size: CGSize(width: 400, height: 400), in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
do {
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
XCTFail("Erwartete Ablehnung für eine Nicht-9:16-Quelle")
} catch CropEngineError.sourceNotNineBySixteen {
// erwartet
}
}
func testRejectsExistingDestinationWithoutOverwrite() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
FileManager.default.createFile(atPath: destination.path, contents: Data())
do {
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
XCTFail("Erwartete Ablehnung wegen bestehender Zieldatei")
} catch CropEngineError.destinationAlreadyExists {
// erwartet
}
}
func testOverwritesExistingDestinationWhenExplicitlyAllowed() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
FileManager.default.createFile(atPath: destination.path, contents: Data("platzhalter".utf8))
try await CropEngine.crop(
sourceURL: clip.url,
destinationURL: destination,
options: .init(overwrite: true)
)
let resultInfo = try await VideoInspector.inspect(destination)
XCTAssertEqual(resultInfo.pixelSize, CropEngine.targetSize(forSourceSize: clip.size))
}
func testRejectsUnsupportedContainer() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mxf")
do {
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
XCTFail("Erwartete Ablehnung für ein MXF-Ziel")
} catch CropEngineError.unsupportedContainer {
// erwartet
}
}
func testRejectsMovAsDestinationBecauseTargetIsFixedToMp4() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mov")
do {
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
XCTFail("Erwartete Ablehnung für ein MOV-Ziel — Zielformat ist fest auf MP4 gesetzt")
} catch CropEngineError.unsupportedContainer {
// erwartet
}
}
}

@ -0,0 +1,258 @@
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()
}
}

@ -0,0 +1,57 @@
import CoreMedia
import XCTest
@testable import DropTwoCore
final class VideoInspectorTests: XCTestCase {
private var tempDirectory: URL!
override func setUpWithError() throws {
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDirectory)
}
func testInspectsSynthesizedClip() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let info = try await VideoInspector.inspect(clip.url)
XCTAssertEqual(info.pixelSize, clip.size)
XCTAssertEqual(info.codec, .h264)
XCTAssertTrue(info.hasAudioTrack)
XCTAssertTrue(info.isNineBySixteen)
XCTAssertEqual(CMTimeGetSeconds(info.duration), clip.seconds, accuracy: 0.2)
}
func testRecognizesNonNineBySixteenAspectRatio() async throws {
let clip = try TestVideoFactory.makeClip(size: CGSize(width: 400, height: 400), in: tempDirectory)
let info = try await VideoInspector.inspect(clip.url)
XCTAssertFalse(info.isNineBySixteen)
}
func testDetectsMissingAudioTrack() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, in: tempDirectory)
let info = try await VideoInspector.inspect(clip.url)
XCTAssertFalse(info.hasAudioTrack)
}
func testRejectsUnreadableFile() async {
let url = tempDirectory.appendingPathComponent("nope.mov")
do {
_ = try await VideoInspector.inspect(url)
XCTFail("Erwartete Fehler für eine nicht existierende Datei")
} catch is VideoInspectorError {
// erwartet
} catch {
XCTFail("Unerwarteter Fehlertyp: \(error)")
}
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.