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
parent
2b7839592b
commit
aea8a3e6fd
@ -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,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…
Reference in new issue