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