Core: Ausschnitt verschiebbar, Positionsmarken, lockere Dateinamen, iPhone-Drehung

- CropPlan: Ausschnitt-Offset pro Datei, mit Marken ab Zeitpunkt X (harte
  Schnitte). CropEngine schneidet pro Frame nach Plan; cropRect ist die
  gemeinsame Geometrie für Vorschau und Render.
- CropEngine wendet die Track-Transformation an: iPhone-Clips (quer
  gespeichert, per 90°-Drehung hochkant) wurden bisher seitlich und mit
  schwarzem Balken gerendert.
- FileNameParser erkennt 9x16/9X16/9×16, getrennt mit -, _ oder Leerzeichen,
  Trenner bleiben erhalten; Rest hinter dem Format optional.
  appendingTargetFormat hängt bei fehlendem Format "4x5" an.
- ConversionPipeline/JobQueue: appendFormatWhenMissing und CropPlan pro Job.
- Tests: Render-Richtung, Marken mitten im Clip, gedrehte Quelle, Parser-
  Schreibweisen, Anhängen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShKCZzkdZhym6mHuPqXQAT
main
Samuel Zielke 2 weeks ago
parent b12cdb2cd1
commit 8547621cac

@ -10,10 +10,18 @@ public enum ConversionPipeline {
/// true = das unveränderte 9:16-Original wird zusätzlich in den
/// Projektordner kopiert (README: "Dort landen beide Fassungen").
public var copyOriginal: Bool
/// true = Dateien ohne Format im Namen werden trotzdem verarbeitet;
/// "4x5" wird an den Namen angehängt (`FileNameParser.appendingTargetFormat`).
public var appendFormatWhenMissing: Bool
public init(cropOptions: CropEngine.Options = CropEngine.Options(), copyOriginal: Bool = true) {
public init(
cropOptions: CropEngine.Options = CropEngine.Options(),
copyOriginal: Bool = true,
appendFormatWhenMissing: Bool = false
) {
self.cropOptions = cropOptions
self.copyOriginal = copyOriginal
self.appendFormatWhenMissing = appendFormatWhenMissing
}
}
@ -36,8 +44,13 @@ public enum ConversionPipeline {
/// kann (dann übernimmt `process` die Skip/Reject-Meldung). Für die
/// Oberfläche gedacht: vor dem eigentlichen Lauf prüfen, ob eine Zieldatei
/// schon existiert, und bei Bedarf eine Rückfrage stellen.
public static func previewDestination(sourceURL: URL, outputRootDirectory: URL) -> URL? {
guard case .parsed(let parsed) = FileNameParser.parse(sourceURL.lastPathComponent).outcome else {
public static func previewDestination(
sourceURL: URL,
outputRootDirectory: URL,
appendFormatWhenMissing: Bool = false
) -> URL? {
let outcome = resolve(sourceURL.lastPathComponent, appendFormatWhenMissing: appendFormatWhenMissing)
guard case .parsed(let parsed) = outcome else {
return nil
}
let projectDirectory = outputRootDirectory.appendingPathComponent(parsed.projectName, isDirectory: true)
@ -51,9 +64,8 @@ public enum ConversionPipeline {
progress: ((Double) -> Void)? = nil
) async -> Outcome {
let fileName = sourceURL.lastPathComponent
let result = FileNameParser.parse(fileName)
switch result.outcome {
switch resolve(fileName, appendFormatWhenMissing: options.appendFormatWhenMissing) {
case .skipped(let reason):
return .skipped(fileName: fileName, reason: reason)
case .rejected(let reason):
@ -68,6 +80,18 @@ public enum ConversionPipeline {
}
}
/// Wie `FileNameParser.parse`, nur dass ein fehlendes Format auf Wunsch
/// angehängt statt abgelehnt wird.
private static func resolve(_ fileName: String, appendFormatWhenMissing: Bool) -> FileNameParseResult.Outcome {
let outcome = FileNameParser.parse(fileName).outcome
if appendFormatWhenMissing,
case .rejected(.formatSegmentMissing) = outcome,
let appended = FileNameParser.appendingTargetFormat(to: fileName) {
return .parsed(appended)
}
return outcome
}
private static func convert(
sourceURL: URL,
parsed: ParsedFileName,
@ -107,6 +131,11 @@ public enum ConversionPipeline {
)
}
/// Wie die 4:5-Fassung tatsächlich heißt: Name aus dem Parser, Endung fest `.mp4`.
public static func mp4Name(_ fileName: String) -> String {
mp4FileName(from: fileName)
}
/// Zielformat ist fest MP4 (siehe CropEngine) — die Endung aus dem
/// geparsten Dateinamen wird dafür überschrieben, nicht übernommen.
private static func mp4FileName(from fileName: String) -> String {

@ -21,9 +21,9 @@ import CoreVideo
/// ü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
/// Wo der Ausschnitt über die Laufzeit sitzt. Standard: mittig, ohne
/// Positionsmarken.
public var cropPlan: CropPlan
/// false = eine bestehende Zieldatei wird nie stillschweigend überschrieben.
public var overwrite: Bool
/// Video-Zielbitrate in bit/s. nil = automatisch aus Auflösung und
@ -33,12 +33,12 @@ public enum CropEngine {
public var audioBitRate: Int?
public init(
verticalOffset: CGFloat = 0,
cropPlan: CropPlan = CropPlan(),
overwrite: Bool = false,
videoBitRate: Int? = nil,
audioBitRate: Int? = nil
) {
self.verticalOffset = verticalOffset
self.cropPlan = cropPlan
self.overwrite = overwrite
self.videoBitRate = videoBitRate
self.audioBitRate = audioBitRate
@ -53,6 +53,19 @@ public enum CropEngine {
return CGSize(width: sourceSize.width, height: evenHeight)
}
/// Der 4:5-Ausschnitt in Bildkoordinaten mit Ursprung **oben links** —
/// so, wie ihn Vorschau und Nutzer sehen. `offset` wie in `CropPlan`:
/// -1 = ganz oben, 0 = mittig, 1 = ganz unten. Die Oberkante landet auf
/// einer geraden Pixelzeile.
public static func cropRect(forSourceSize sourceSize: CGSize, offset: CGFloat) -> CGRect {
let size = targetSize(forSourceSize: sourceSize)
let slack = max(sourceSize.height - size.height, 0)
let clampedOffset = min(max(offset, -1), 1)
let top = slack / 2 + clampedOffset * slack / 2
let evenTop = min((top / 2).rounded() * 2, slack)
return CGRect(x: 0, y: evenTop, width: size.width, height: size.height)
}
public static func crop(
sourceURL: URL,
destinationURL: URL,
@ -70,12 +83,6 @@ public enum CropEngine {
}
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)
@ -83,6 +90,10 @@ public enum CropEngine {
throw CropEngineError.readerSetupFailed(underlying: VideoInspectorError.noVideoTrack)
}
let audioTrack = tracks.first(where: { $0.mediaType == .audio })
// iPhone-Clips liegen oft quer im Container und werden nur über die
// Track-Transformation hochkant angezeigt. Der Reader liefert die
// Frames ungedreht — die Drehung muss vor dem Crop angewendet werden.
let orientation = try await imageOrientation(of: videoTrack)
let videoSettings = videoEncoderSettings(size: cropSize, frameRate: info.frameRate, bitRateOverride: options.videoBitRate)
if options.overwrite {
@ -177,7 +188,9 @@ public enum CropEngine {
output: videoOutput,
input: videoInput,
adaptor: pixelBufferAdaptor,
cropRect: cropRect,
sourceSize: info.pixelSize,
orientation: orientation,
cropPlan: options.cropPlan,
ciContext: ciContext,
durationSeconds: durationSeconds,
progress: progress
@ -205,12 +218,34 @@ public enum CropEngine {
// 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
/// Übersetzt die Track-Transformation in eine Bildausrichtung. Erkannt
/// werden die vier Drehungen um 90°; alles andere gilt als unverändert.
static func orientation(for transform: CGAffineTransform) -> CGImagePropertyOrientation {
switch (transform.a.rounded(), transform.b.rounded(), transform.c.rounded(), transform.d.rounded()) {
case (0, 1, -1, 0): return .right
case (0, -1, 1, 0): return .left
case (-1, 0, 0, -1): return .down
default: return .up
}
}
private static func imageOrientation(of track: AVAssetTrack) async throws -> CGImagePropertyOrientation {
orientation(for: try await track.load(.preferredTransform))
}
/// Dreht ein Frame aufrecht und legt es wieder auf den Ursprung, damit
/// `coreImageCropRect` (Ursprung 0/0, Größe = angezeigte Größe) passt.
private static func upright(_ image: CIImage, orientation: CGImagePropertyOrientation) -> CIImage {
guard orientation != .up else { return image }
let oriented = image.oriented(orientation)
return oriented.transformed(by: CGAffineTransform(translationX: -oriented.extent.minX, y: -oriented.extent.minY))
}
/// `cropRect` in CoreImage-Koordinaten: Ursprung **unten links**, die
/// oberste Pixelzeile eines `CIImage(cvPixelBuffer:)` liegt bei y = Höhe.
private static func coreImageCropRect(sourceSize: CGSize, offset: CGFloat) -> CGRect {
let rect = cropRect(forSourceSize: sourceSize, offset: offset)
return CGRect(x: rect.minX, y: sourceSize.height - rect.maxY, width: rect.width, height: rect.height)
}
// MARK: - Container & Codec
@ -273,7 +308,9 @@ public enum CropEngine {
output: AVAssetReaderTrackOutput,
input: AVAssetWriterInput,
adaptor: AVAssetWriterInputPixelBufferAdaptor,
cropRect: CGRect,
sourceSize: CGSize,
orientation: CGImagePropertyOrientation,
cropPlan: CropPlan,
ciContext: CIContext,
durationSeconds: Double,
progress: ((Double) -> Void)?
@ -312,7 +349,11 @@ public enum CropEngine {
return
}
let croppedImage = CIImage(cvPixelBuffer: sourceBuffer)
let cropRect = coreImageCropRect(
sourceSize: sourceSize,
offset: cropPlan.offset(at: CMTimeGetSeconds(presentationTime))
)
let croppedImage = upright(CIImage(cvPixelBuffer: sourceBuffer), orientation: orientation)
.cropped(to: cropRect)
.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
ciContext.render(croppedImage, to: outBuffer)

@ -0,0 +1,94 @@
import CoreGraphics
import Foundation
/// Wo der 4:5-Ausschnitt über die Laufzeit eines Clips sitzt. Eine Liste von
/// Positionsmarken: jede Marke gilt ab ihrem Zeitpunkt bis zur nächsten —
/// harte Schnitte, keine Kamerafahrt dazwischen. Gedacht für Clips mit ein,
/// zwei Szenen, in denen das Framing jeweils anders sitzen muss.
///
/// Invariante: Es gibt immer mindestens eine Marke, und die erste liegt bei 0.
/// Ein Plan mit genau einer Marke ist damit schlicht ein fester Offset für
/// die ganze Datei.
public struct CropPlan: Equatable, Sendable {
public struct Keyframe: Equatable, Sendable, Identifiable {
public let id: UUID
/// Sekunden ab Clip-Beginn.
public var time: Double
/// -1 = Ausschnitt ganz oben, 0 = mittig, 1 = ganz unten.
public var offset: CGFloat
public init(id: UUID = UUID(), time: Double, offset: CGFloat) {
self.id = id
self.time = time
self.offset = offset
}
}
/// Zwei Marken näher beieinander als das gelten als dieselbe Stelle.
/// Fängt auch Rundungsrauschen zwischen Player-Zeit und Frame-PTS ab.
public static let timeTolerance: Double = 0.001
public private(set) var keyframes: [Keyframe]
public init(offset: CGFloat = 0) {
keyframes = [Keyframe(time: 0, offset: Self.clamp(offset))]
}
/// Baut einen Plan aus beliebigen Marken: sortiert, Offsets geklemmt, die
/// früheste Marke auf 0 gezogen. Leere Liste = mittig.
public init(keyframes: [Keyframe]) {
var sorted = keyframes
.map { Keyframe(id: $0.id, time: max($0.time, 0), offset: Self.clamp($0.offset)) }
.sorted { $0.time < $1.time }
if sorted.isEmpty {
sorted = [Keyframe(time: 0, offset: 0)]
}
sorted[0].time = 0
self.keyframes = sorted
}
/// true = mittig, keine Marken — also genau das bisherige Verhalten.
public var isDefault: Bool {
keyframes.count == 1 && keyframes[0].offset == 0
}
/// Index der Marke, die zum Zeitpunkt `seconds` gilt.
public func keyframeIndex(at seconds: Double) -> Int {
keyframes.lastIndex { $0.time <= seconds + Self.timeTolerance } ?? 0
}
public func offset(at seconds: Double) -> CGFloat {
keyframes[keyframeIndex(at: seconds)].offset
}
/// Verschiebt den Ausschnitt der Marke, die zum Zeitpunkt `seconds` gilt —
/// also des ganzen Abschnitts bis zur nächsten Marke.
public mutating func setOffset(_ offset: CGFloat, at seconds: Double) {
keyframes[keyframeIndex(at: seconds)].offset = Self.clamp(offset)
}
/// Setzt eine neue Marke bei `seconds`; sie übernimmt den Offset, der dort
/// bisher galt, damit sich am Bild erst mal nichts ändert. Liegt dort schon
/// eine Marke, passiert nichts.
@discardableResult
public mutating func addKeyframe(at seconds: Double) -> Keyframe? {
let time = max(seconds, 0)
guard !keyframes.contains(where: { abs($0.time - time) <= Self.timeTolerance }) else {
return nil
}
let keyframe = Keyframe(time: time, offset: offset(at: time))
keyframes.append(keyframe)
keyframes.sort { $0.time < $1.time }
return keyframe
}
/// Die erste Marke (bei 0) bleibt immer erhalten.
public mutating func removeKeyframe(id: Keyframe.ID) {
guard let index = keyframes.firstIndex(where: { $0.id == id }), index > 0 else { return }
keyframes.remove(at: index)
}
private static func clamp(_ offset: CGFloat) -> CGFloat {
min(max(offset, -1), 1)
}
}

@ -15,7 +15,8 @@ public struct FileNameParseResult: Equatable {
/// Erfolgreich zerlegter Dateiname nach dem Schema
/// `[Projekt]-[Format]-[Version]-[Sprache].[ext]`, wobei das Format-Segment
/// (`9x16`) als Anker dient und nicht die Position.
/// (`9x16`) als Anker dient und nicht die Position. Als Trenner zählen `-`,
/// `_` und Leerzeichen; sie bleiben in der Zielfassung zeichengleich erhalten.
public struct ParsedFileName: Equatable {
/// Wörtlich wie übergeben — wird nicht aus den Segmenten rekonstruiert,
/// damit ein Rundtrip-Fehler (Groß-/Kleinschreibung, Sonderzeichen) nie
@ -23,17 +24,23 @@ public struct ParsedFileName: Equatable {
public let originalFileName: String
public let projectName: String
/// Das Format-Segment, wie es im Original vorgefunden wurde (z.B. "9X16").
/// Leer, wenn das Format per `FileNameParser.appendingTargetFormat` erst
/// angehängt wird.
public let sourceFormatToken: String
/// Alles nach dem Format-Segment, zeichengleich, z.B. "v3-DE" oder "v3-EN-US".
/// Alles nach dem Format-Segment ohne den Trenner davor, zeichengleich,
/// z.B. "v3-DE" oder "v3-EN-US". Darf leer sein.
public let remainder: String
/// Ohne führenden Punkt, unverändert übernommen (Groß-/Kleinschreibung inklusive).
public let fileExtension: String
/// Alles vor bzw. nach dem Format-Segment, inklusive der Trenner.
let head: String
let tail: String
/// Zieldateiname mit ausschließlich dem Format-Segment ersetzt (immer
/// klein geschrieben als "4x5"). Alle anderen Segmente bleiben zeichengleich.
/// klein geschrieben als "4x5"). Alle anderen Zeichen bleiben gleich.
public var targetFileName: String {
let ext = fileExtension.isEmpty ? "" : ".\(fileExtension)"
return "\(projectName)-\(FileNameParser.targetFormatToken)-\(remainder)\(ext)"
return "\(head)\(FileNameParser.targetFormatToken)\(tail)\(ext)"
}
}
@ -45,8 +52,8 @@ public enum SkipReason: Equatable {
public enum RejectionReason: Equatable {
/// Dateistamm endet auf ein Finder-Duplikat-Suffix wie " (1)".
case finderDuplicate
/// Kein nutzbares Format-Segment gefunden: weder "9x16" noch "4x5" als
/// eigenständiges Segment mit mindestens einem Segment davor und danach.
/// Kein nutzbares Format-Segment gefunden: weder "9x16" mit einem
/// Projektnamen davor noch "4x5". Ausweg: `appendingTargetFormat`.
case formatSegmentMissing
}
@ -54,9 +61,15 @@ public enum RejectionReason: Equatable {
/// und zustandslos — reine Zeichenketten rein, reine Zeichenketten (bzw.
/// Ergebnistypen) raus.
public enum FileNameParser {
fileprivate static let sourceFormatToken = "9x16"
fileprivate static let targetFormatToken = "4x5"
/// Format-Token als eigenständiges Segment: `9x16`, `9X16`, `9×16`, auch
/// mit Leerzeichen um das x. Davor und danach darf kein Buchstabe und keine
/// Ziffer stehen — `-`, `_`, Leerzeichen, Klammern oder Namensanfang/-ende.
private static let sourceFormatPattern = #"(?<![\p{L}\p{N}])9 ?[xX×] ?16(?![\p{L}\p{N}])"#
private static let targetFormatPattern = #"(?<![\p{L}\p{N}])4 ?[xX×] ?5(?![\p{L}\p{N}])"#
private static let separators = CharacterSet(charactersIn: "-_ ()[]")
public static func parse(_ fileName: String) -> FileNameParseResult {
let (stem, fileExtension) = splitExtension(fileName)
@ -67,45 +80,68 @@ public enum FileNameParser {
return FileNameParseResult(fileName: fileName, outcome: .rejected(.finderDuplicate))
}
let segments = stem.components(separatedBy: "-")
if let index = lastIndex(ofSegmentMatching: sourceFormatToken, in: segments),
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
let parsed = ParsedFileName(
originalFileName: fileName,
projectName: segments[..<index].joined(separator: "-"),
sourceFormatToken: segments[index],
remainder: segments[(index + 1)...].joined(separator: "-"),
fileExtension: fileExtension
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
if let range = lastRange(of: sourceFormatPattern, in: stem) {
let head = String(stem[..<range.lowerBound])
let tail = String(stem[range.upperBound...])
let projectName = head.trimmingCharacters(in: separators)
if !projectName.isEmpty {
let parsed = ParsedFileName(
originalFileName: fileName,
projectName: projectName,
sourceFormatToken: String(stem[range]),
remainder: tail.trimmingCharacters(in: separators),
fileExtension: fileExtension,
head: head,
tail: tail
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
}
}
if let index = lastIndex(ofSegmentMatching: targetFormatToken, in: segments),
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
if lastRange(of: targetFormatPattern, in: stem) != nil {
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
}
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
}
/// Ausweg für Dateien ohne Format im Namen: der ganze Dateistamm wird zum
/// Projekt, "4x5" wird hinten angehängt — mit dem Trenner, den der Name
/// schon benutzt. `nil` für Finder-Duplikate und Namen, die regulär
/// geparst werden können.
public static func appendingTargetFormat(to fileName: String) -> ParsedFileName? {
guard case .rejected(.formatSegmentMissing) = parse(fileName).outcome else { return nil }
let (stem, fileExtension) = splitExtension(fileName)
let projectName = stem.trimmingCharacters(in: separators)
guard !projectName.isEmpty else { return nil }
return ParsedFileName(
originalFileName: fileName,
projectName: projectName,
sourceFormatToken: "",
remainder: "",
fileExtension: fileExtension,
head: projectName + preferredSeparator(in: projectName),
tail: ""
)
}
/// Sucht von rechts, damit bei mehrfachem Vorkommen (z.B. weil der
/// Projektname selbst "9x16" enthält) das echte Format-Segment gewinnt —
/// das direkt vor Version/Sprache.
private static func lastIndex(ofSegmentMatching token: String, in segments: [String]) -> Int? {
for index in stride(from: segments.count - 1, through: 0, by: -1) {
if segments[index].caseInsensitiveCompare(token) == .orderedSame {
return index
}
}
return nil
private static func lastRange(of pattern: String, in stem: String) -> Range<String.Index>? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let fullRange = NSRange(stem.startIndex..<stem.endIndex, in: stem)
guard let match = regex.matches(in: stem, range: fullRange).last else { return nil }
return Range(match.range, in: stem)
}
/// Mindestens ein Segment davor (Projekt) und mindestens eins danach
/// (Rest). Bewusst NICHT auf genau ein Segment danach geprüft — sonst
/// bricht ein mehrteiliges Sprachkürzel wie "EN-US".
private static func hasProjectAndRemainder(atIndex index: Int, segmentCount: Int) -> Bool {
index > 0 && index < segmentCount - 1
/// Der Trenner, den ein Name schon benutzt: `_` oder Leerzeichen, wenn
/// nur dieser vorkommt, sonst `-` wie in der Namenskonvention.
private static func preferredSeparator(in name: String) -> String {
let hasHyphen = name.contains("-")
if !hasHyphen && name.contains("_") { return "_" }
if !hasHyphen && name.contains(" ") { return " " }
return "-"
}
private static func splitExtension(_ fileName: String) -> (stem: String, extension: String) {

@ -8,10 +8,18 @@ public enum JobQueue {
public struct Job: Sendable {
public let id: UUID
public let sourceURL: URL
/// Ausschnitt nur für diese Datei. nil = der aus den gemeinsamen
/// `options` des Batches.
public let cropPlan: CropPlan?
/// Format anhängen, falls es im Namen fehlt — nur für diese Datei.
/// nil = wie in den gemeinsamen `options`.
public let appendFormatWhenMissing: Bool?
public init(id: UUID = UUID(), sourceURL: URL) {
public init(id: UUID = UUID(), sourceURL: URL, cropPlan: CropPlan? = nil, appendFormatWhenMissing: Bool? = nil) {
self.id = id
self.sourceURL = sourceURL
self.cropPlan = cropPlan
self.appendFormatWhenMissing = appendFormatWhenMissing
}
}
@ -34,11 +42,18 @@ public enum JobQueue {
func startNext() {
guard let job = iterator.next() else { return }
group.addTask {
var jobOptions = options
if let cropPlan = job.cropPlan {
jobOptions.cropOptions.cropPlan = cropPlan
}
if let appendFormatWhenMissing = job.appendFormatWhenMissing {
jobOptions.appendFormatWhenMissing = appendFormatWhenMissing
}
group.addTask { [jobOptions] in
let outcome = await ConversionPipeline.process(
sourceURL: job.sourceURL,
outputRootDirectory: outputRootDirectory,
options: options
options: jobOptions
) { value in
progress?(job, value)
}

@ -109,4 +109,26 @@ final class ConversionPipelineTests: XCTestCase {
let contents = try FileManager.default.contentsOfDirectory(atPath: projectDirectory.path)
XCTAssertEqual(contents, ["SOMOS-4x5-v3-DE.mp4"])
}
func testAppendsFormatWhenMissingAndAllowed() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "Sommerfest Teaser.mov", in: tempDirectory)
let preview = ConversionPipeline.previewDestination(
sourceURL: clip.url,
outputRootDirectory: outputDirectory,
appendFormatWhenMissing: true
)
let outcome = await ConversionPipeline.process(
sourceURL: clip.url,
outputRootDirectory: outputDirectory,
options: .init(appendFormatWhenMissing: true)
)
guard case .succeeded(let success) = outcome else {
return XCTFail("Erwartete .succeeded, bekam \(outcome)")
}
XCTAssertEqual(success.croppedDestination.lastPathComponent, "Sommerfest Teaser 4x5.mp4")
XCTAssertEqual(success.croppedDestination.deletingLastPathComponent().lastPathComponent, "Sommerfest Teaser")
XCTAssertEqual(preview, success.croppedDestination)
}
}

@ -34,8 +34,88 @@ final class CropEngineTests: XCTestCase {
XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0)
}
func testCropRectMapsOffsetToTopCenterBottom() {
let source = CGSize(width: 1080, height: 1920)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: -1).minY, 0)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 0).minY, 286)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 1).maxY, 1920)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 5).maxY, 1920)
}
func testCropRectTopEdgeIsEven() {
let rect = CropEngine.cropRect(forSourceSize: CGSize(width: 1080, height: 1920), offset: 0.3333)
XCTAssertEqual(rect.minY.truncatingRemainder(dividingBy: 2), 0)
}
// MARK: - End-to-End gegen einen synthetischen Clip
/// Quelle: obere Hälfte weiß, untere schwarz. Ausschnitt ganz oben muss
/// also deutlich heller sein als ganz unten — prüft die Richtung des
/// Offsets im tatsächlichen Render, nicht nur in der Geometrie.
func testOffsetMovesRenderedCropUpAndDown() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
let top = tempDirectory.appendingPathComponent("top.mp4")
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
// 384er Quelle, 270er Ausschnitt: oben 192/270 ≈ 0.71 weiß, unten 78/270 ≈ 0.29.
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
}
/// iPhone-Fall: Container quer (384×216), per 90°-Transformation hochkant.
/// Links im gespeicherten Bild ist nach der Drehung oben — der Ausschnitt
/// "ganz oben" muss also hell sein, und das Ergebnis darf nicht quer sein.
func testRotatedSourceIsCroppedUpright() async throws {
let clip = try TestVideoFactory.makeClip(
size: CGSize(width: 384, height: 216),
includeAudio: false,
pattern: .brightLeftDarkRight,
transform: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 216, ty: 0),
in: tempDirectory
)
let top = tempDirectory.appendingPathComponent("top.mp4")
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
let resultInfo = try await VideoInspector.inspect(top)
XCTAssertEqual(resultInfo.pixelSize, CGSize(width: 216, height: 270))
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
}
func testOrientationFromTrackTransform() {
XCTAssertEqual(CropEngine.orientation(for: .identity), .up)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 1080, ty: 0)), .right)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 1920)), .left)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 1080, ty: 1920)), .down)
}
func testKeyframeSwitchesCropMidClip() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
var plan = CropPlan(offset: -1)
plan.addKeyframe(at: 1)
plan.setOffset(1, at: 1)
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination, options: .init(cropPlan: plan))
let before = try await TestVideoFactory.averageBrightness(of: destination, at: 0.5)
let after = try await TestVideoFactory.averageBrightness(of: destination, at: 1.5)
XCTAssertEqual(before, 0.71, accuracy: 0.08)
XCTAssertEqual(after, 0.29, accuracy: 0.08)
}
func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")

@ -0,0 +1,85 @@
import XCTest
@testable import DropTwoCore
final class CropPlanTests: XCTestCase {
func testDefaultPlanIsCenteredWithSingleKeyframe() {
let plan = CropPlan()
XCTAssertTrue(plan.isDefault)
XCTAssertEqual(plan.keyframes.count, 1)
XCTAssertEqual(plan.offset(at: 0), 0)
XCTAssertEqual(plan.offset(at: 100), 0)
}
func testOffsetIsClamped() {
XCTAssertEqual(CropPlan(offset: 3).offset(at: 0), 1)
XCTAssertEqual(CropPlan(offset: -3).offset(at: 0), -1)
}
func testKeyframeHoldsUntilNextOne() {
var plan = CropPlan(offset: -1)
plan.addKeyframe(at: 2)
plan.setOffset(1, at: 2)
XCTAssertEqual(plan.offset(at: 0), -1)
XCTAssertEqual(plan.offset(at: 1.99), -1)
XCTAssertEqual(plan.offset(at: 2), 1)
XCTAssertEqual(plan.offset(at: 10), 1)
XCTAssertFalse(plan.isDefault)
}
func testNewKeyframeTakesOverCurrentOffset() {
var plan = CropPlan(offset: 0.4)
let keyframe = plan.addKeyframe(at: 3)
XCTAssertEqual(keyframe?.offset, 0.4)
}
func testKeyframeAtExistingTimeIsIgnored() {
var plan = CropPlan()
plan.addKeyframe(at: 2)
XCTAssertNil(plan.addKeyframe(at: 2.0005))
XCTAssertNil(plan.addKeyframe(at: 0))
XCTAssertEqual(plan.keyframes.count, 2)
}
func testKeyframesStaySorted() {
var plan = CropPlan()
plan.addKeyframe(at: 5)
plan.addKeyframe(at: 1)
plan.addKeyframe(at: 3)
XCTAssertEqual(plan.keyframes.map(\.time), [0, 1, 3, 5])
}
func testFirstKeyframeCannotBeRemoved() {
var plan = CropPlan()
let second = plan.addKeyframe(at: 2)!
plan.removeKeyframe(id: plan.keyframes[0].id)
XCTAssertEqual(plan.keyframes.count, 2)
plan.removeKeyframe(id: second.id)
XCTAssertEqual(plan.keyframes.count, 1)
}
func testInitFromKeyframesPullsEarliestToZero() {
let plan = CropPlan(keyframes: [
.init(time: 4, offset: 1),
.init(time: 1.5, offset: -1)
])
XCTAssertEqual(plan.keyframes.map(\.time), [0, 4])
XCTAssertEqual(plan.offset(at: 0), -1)
}
func testToleranceAbsorbsPlayerRoundingNoise() {
var plan = CropPlan()
plan.addKeyframe(at: 1.0)
plan.setOffset(1, at: 1.0)
// Frame-PTS minimal vor der Marke, wie es aus CMTime-Rundung kommt.
XCTAssertEqual(plan.offset(at: 0.9999), 1)
}
}

@ -123,10 +123,69 @@ final class FileNameParserTests: XCTestCase {
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
}
func testRejectsWhenNoRemainderSegment() {
func testParsesWithoutRemainderSegment() {
let result = FileNameParser.parse("SOMOS-9x16.mp4")
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "SOMOS")
XCTAssertEqual(parsed.remainder, "")
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5.mp4")
}
// MARK: - d) Lockere Schreibweisen
func testUnderscoreAndSpaceSeparatorsArePreserved() {
let cases = [
("SOMOS_9x16_v3_DE.mp4", "SOMOS", "v3_DE", "SOMOS_4x5_v3_DE.mp4"),
("SOMOS 9x16 v3 DE.mov", "SOMOS", "v3 DE", "SOMOS 4x5 v3 DE.mov"),
("Somos Pendant - 9X16_v3-DE.mp4", "Somos Pendant", "v3-DE", "Somos Pendant - 4x5_v3-DE.mp4"),
("SOMOS-9×16-v3.mp4", "SOMOS", "v3", "SOMOS-4x5-v3.mp4"),
("SOMOS (9 x 16) v3.mp4", "SOMOS", "v3", "SOMOS (4x5) v3.mp4")
]
for (fileName, project, remainder, target) in cases {
guard case .parsed(let parsed) = FileNameParser.parse(fileName).outcome else {
XCTFail("Erwartete .parsed für \(fileName)")
continue
}
XCTAssertEqual(parsed.projectName, project, fileName)
XCTAssertEqual(parsed.remainder, remainder, fileName)
XCTAssertEqual(parsed.targetFileName, target, fileName)
}
}
func testTokenInsideWordIsNotAFormat() {
XCTAssertEqual(FileNameParser.parse("SOMOS-19x160-v3.mp4").outcome, .rejected(.formatSegmentMissing))
XCTAssertEqual(FileNameParser.parse("SOMOSx9x16v3.mp4").outcome, .rejected(.formatSegmentMissing))
}
func testTargetFormatInAnySpellingIsSkipped() {
for fileName in ["SOMOS_4X5_v3.mp4", "SOMOS 4x5.mp4", "SOMOS-4×5-v3-DE.mp4"] {
XCTAssertEqual(FileNameParser.parse(fileName).outcome, .skipped(.alreadyTargetFormat), fileName)
}
}
// MARK: - e) Format anhängen, wenn es fehlt
func testAppendingFormatUsesWholeStemAsProject() {
let parsed = FileNameParser.appendingTargetFormat(to: "Sommerfest Teaser.mov")
XCTAssertEqual(parsed?.projectName, "Sommerfest Teaser")
XCTAssertEqual(parsed?.targetFileName, "Sommerfest Teaser 4x5.mov")
XCTAssertEqual(parsed?.originalFileName, "Sommerfest Teaser.mov")
}
func testAppendingFormatPicksSeparatorFromName() {
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS-v3-DE.mp4")?.targetFileName, "SOMOS-v3-DE-4x5.mp4")
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS_v3.mp4")?.targetFileName, "SOMOS_v3_4x5.mp4")
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS.mp4")?.targetFileName, "SOMOS-4x5.mp4")
}
func testAppendingFormatOnlyForMissingFormat() {
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-9x16-v3-DE.mp4"))
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-4x5-v3-DE.mp4"))
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-v3-DE (1).mp4"))
}
// MARK: - c) Format-Segment in der Ausgabe immer klein geschrieben

@ -12,6 +12,18 @@ enum TestVideoFactory {
let hasAudio: Bool
}
/// Bildinhalt der erzeugten Frames.
enum Pattern {
/// Ganze Fläche einfarbig grau, Helligkeit wechselt von Frame zu Frame.
case flatByFrameIndex
/// Obere Bildhälfte weiß, untere schwarz — damit lässt sich prüfen,
/// welcher vertikale Ausschnitt tatsächlich gerendert wurde.
case brightTopDarkBottom
/// Linke Bildhälfte weiß, rechte schwarz — für quer gespeicherte,
/// per Transformation gedrehte Clips (iPhone).
case brightLeftDarkRight
}
enum FactoryError: Error {
case writerSetupFailed(underlying: Error?)
case pixelBufferPoolUnavailable
@ -25,6 +37,7 @@ enum TestVideoFactory {
frameRate: Int32 = 10,
seconds: Double = 2,
includeAudio: Bool = true,
pattern: Pattern = .flatByFrameIndex,
fileName: String? = nil,
in directory: URL
) throws -> GeneratedVideo {
@ -33,6 +46,7 @@ enum TestVideoFactory {
frameRate: frameRate,
seconds: seconds,
includeAudio: includeAudio,
pattern: pattern,
fileName: fileName,
in: directory
)
@ -43,6 +57,8 @@ enum TestVideoFactory {
frameRate: Int32 = 10,
seconds: Double = 2,
includeAudio: Bool = true,
pattern: Pattern = .flatByFrameIndex,
transform: CGAffineTransform = .identity,
fileName: String? = nil,
in directory: URL
) throws -> GeneratedVideo {
@ -63,6 +79,7 @@ enum TestVideoFactory {
]
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
videoInput.expectsMediaDataInRealTime = false
videoInput.transform = transform
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
@ -104,7 +121,7 @@ enum TestVideoFactory {
writer.startSession(atSourceTime: .zero)
let frameCount = Int(seconds * Double(frameRate))
try appendFrames(count: frameCount, frameRate: frameRate, input: videoInput, adaptor: adaptor)
try appendFrames(count: frameCount, frameRate: frameRate, pattern: pattern, input: videoInput, adaptor: adaptor)
if let audioInput {
try appendAudio(seconds: seconds, sampleRate: 44_100, input: audioInput)
@ -131,6 +148,7 @@ enum TestVideoFactory {
private static func appendFrames(
count: Int,
frameRate: Int32,
pattern: Pattern,
input: AVAssetWriterInput,
adaptor: AVAssetWriterInputPixelBufferAdaptor
) throws {
@ -160,7 +178,7 @@ enum TestVideoFactory {
semaphore.signal()
return
}
fill(pixelBuffer: pixelBuffer, frameIndex: nextFrameIndex)
fill(pixelBuffer: pixelBuffer, frameIndex: nextFrameIndex, pattern: pattern)
let presentationTime = CMTime(value: CMTimeValue(nextFrameIndex), timescale: frameRate)
adaptor.append(pixelBuffer, withPresentationTime: presentationTime)
nextFrameIndex += 1
@ -173,14 +191,66 @@ enum TestVideoFactory {
}
}
private static func fill(pixelBuffer: CVPixelBuffer, frameIndex: Int) {
private static func fill(pixelBuffer: CVPixelBuffer, frameIndex: Int, pattern: Pattern) {
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)
switch pattern {
case .flatByFrameIndex:
let value = Int32(UInt8((frameIndex * 37) % 256))
memset(base, value, bytesPerRow * height)
case .brightTopDarkBottom:
let topRows = height / 2
memset(base, 255, bytesPerRow * topRows)
memset(base + bytesPerRow * topRows, 0, bytesPerRow * (height - topRows))
case .brightLeftDarkRight:
let width = CVPixelBufferGetWidth(pixelBuffer)
for row in 0..<height {
let rowStart = base + row * bytesPerRow
memset(rowStart, 255, (width / 2) * 4)
memset(rowStart + (width / 2) * 4, 0, bytesPerRow - (width / 2) * 4)
}
}
}
// MARK: - Auswertung
/// Mittlere Helligkeit (0…1) des Frames bei `seconds` — gerechnet über
/// alle Pixel, auf ein kleines Graubild heruntergerechnet.
static func averageBrightness(of url: URL, at seconds: Double) async throws -> Double {
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = .zero
let time = CMTime(seconds: seconds, preferredTimescale: 600)
let image = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CGImage, Error>) in
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, image, _, _, error in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(throwing: error ?? FactoryError.pixelBufferCreationFailed)
}
}
}
let side = 16
var pixels = [UInt8](repeating: 0, count: side * side)
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(
data: buffer.baseAddress,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: side,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return false }
context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side))
return true
}
guard drawn else { throw FactoryError.pixelBufferCreationFailed }
return Double(pixels.reduce(0) { $0 + Int($1) }) / Double(pixels.count * 255)
}
// MARK: - Audio

Loading…
Cancel
Save

Powered by TurnKey Linux.