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