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.

371 lines
16 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 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?)
}

Powered by TurnKey Linux.