From 8547621cac65abe75d66d9076d65b96729e5cad1 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Sun, 11 Oct 2026 23:10:55 +0200 Subject: [PATCH] Core: Ausschnitt verschiebbar, Positionsmarken, lockere Dateinamen, iPhone-Drehung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01ShKCZzkdZhym6mHuPqXQAT --- Sources/DropTwoCore/ConversionPipeline.swift | 39 ++++++- Sources/DropTwoCore/CropEngine.swift | 81 ++++++++++---- Sources/DropTwoCore/CropPlan.swift | 94 ++++++++++++++++ Sources/DropTwoCore/FileNameParser.swift | 102 ++++++++++++------ Sources/DropTwoCore/JobQueue.swift | 21 +++- .../ConversionPipelineTests.swift | 22 ++++ Tests/DropTwoCoreTests/CropEngineTests.swift | 80 ++++++++++++++ Tests/DropTwoCoreTests/CropPlanTests.swift | 85 +++++++++++++++ .../FileNameParserTests.swift | 63 ++++++++++- Tests/DropTwoCoreTests/TestVideoFactory.swift | 80 +++++++++++++- 10 files changed, 599 insertions(+), 68 deletions(-) create mode 100644 Sources/DropTwoCore/CropPlan.swift create mode 100644 Tests/DropTwoCoreTests/CropPlanTests.swift diff --git a/Sources/DropTwoCore/ConversionPipeline.swift b/Sources/DropTwoCore/ConversionPipeline.swift index 23619c6..b51c23d 100644 --- a/Sources/DropTwoCore/ConversionPipeline.swift +++ b/Sources/DropTwoCore/ConversionPipeline.swift @@ -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 { diff --git a/Sources/DropTwoCore/CropEngine.swift b/Sources/DropTwoCore/CropEngine.swift index 5f31b6d..a410c81 100644 --- a/Sources/DropTwoCore/CropEngine.swift +++ b/Sources/DropTwoCore/CropEngine.swift @@ -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) diff --git a/Sources/DropTwoCore/CropPlan.swift b/Sources/DropTwoCore/CropPlan.swift new file mode 100644 index 0000000..fc311eb --- /dev/null +++ b/Sources/DropTwoCore/CropPlan.swift @@ -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) + } +} diff --git a/Sources/DropTwoCore/FileNameParser.swift b/Sources/DropTwoCore/FileNameParser.swift index 4be4d69..2f4790f 100644 --- a/Sources/DropTwoCore/FileNameParser.swift +++ b/Sources/DropTwoCore/FileNameParser.swift @@ -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 = #"(? 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[.. 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? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let fullRange = NSRange(stem.startIndex.. 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) { diff --git a/Sources/DropTwoCore/JobQueue.swift b/Sources/DropTwoCore/JobQueue.swift index 262c855..93e6551 100644 --- a/Sources/DropTwoCore/JobQueue.swift +++ b/Sources/DropTwoCore/JobQueue.swift @@ -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) } diff --git a/Tests/DropTwoCoreTests/ConversionPipelineTests.swift b/Tests/DropTwoCoreTests/ConversionPipelineTests.swift index e9a9cb3..9fec000 100644 --- a/Tests/DropTwoCoreTests/ConversionPipelineTests.swift +++ b/Tests/DropTwoCoreTests/ConversionPipelineTests.swift @@ -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) + } } diff --git a/Tests/DropTwoCoreTests/CropEngineTests.swift b/Tests/DropTwoCoreTests/CropEngineTests.swift index 8f79427..fea4dec 100644 --- a/Tests/DropTwoCoreTests/CropEngineTests.swift +++ b/Tests/DropTwoCoreTests/CropEngineTests.swift @@ -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") diff --git a/Tests/DropTwoCoreTests/CropPlanTests.swift b/Tests/DropTwoCoreTests/CropPlanTests.swift new file mode 100644 index 0000000..f021e44 --- /dev/null +++ b/Tests/DropTwoCoreTests/CropPlanTests.swift @@ -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) + } +} diff --git a/Tests/DropTwoCoreTests/FileNameParserTests.swift b/Tests/DropTwoCoreTests/FileNameParserTests.swift index ac67e11..2bf1e3f 100644 --- a/Tests/DropTwoCoreTests/FileNameParserTests.swift +++ b/Tests/DropTwoCoreTests/FileNameParserTests.swift @@ -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 diff --git a/Tests/DropTwoCoreTests/TestVideoFactory.swift b/Tests/DropTwoCoreTests/TestVideoFactory.swift index c425aba..a420bca 100644 --- a/Tests/DropTwoCoreTests/TestVideoFactory.swift +++ b/Tests/DropTwoCoreTests/TestVideoFactory.swift @@ -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.. 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) 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