From b2e1370ddba91fbe3446be15ec8c86dbb27d9c2a Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Mon, 14 Sep 2026 01:31:25 +0200 Subject: [PATCH] =?UTF-8?q?ConversionPipeline,=20JobQueue=20und=20droptwo-?= =?UTF-8?q?cli=20als=20Pr=C3=BCfstein?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schritt 3 der Umsetzungsreihenfolge. ConversionPipeline bündelt den kompletten Weg einer Datei — Namen parsen, Projekt-Unterordner anlegen, Original kopieren, croppen — als einzelnen, UI-freien Core-Baustein. droptwo-cli und später die App nutzen denselben Aufruf, damit beide garantiert dasselbe tun. JobQueue verarbeitet mehrere Dateien mit begrenzter Parallelität (Standard 2 gleichzeitige Exporte) und liefert Ergebnisse fortlaufend über einen AsyncStream. droptwo-cli ruft nur noch ConversionPipeline pro Datei auf. An echtem 9:16-Testmaterial verifiziert: korrekte Zielauflösung (1080×1920 -> 1080×1350), Dauer/Framerate erhalten, plausible Bitrate, sauberer zentrierter Crop (visuell per Frame-Vergleich geprüft). --- Sources/DropTwoCLI/main.swift | 55 +++++++++ Sources/DropTwoCore/ConversionPipeline.swift | 102 +++++++++++++++++ Sources/DropTwoCore/JobQueue.swift | 64 +++++++++++ .../ConversionPipelineTests.swift | 94 ++++++++++++++++ Tests/DropTwoCoreTests/JobQueueTests.swift | 104 ++++++++++++++++++ 5 files changed, 419 insertions(+) create mode 100644 Sources/DropTwoCLI/main.swift create mode 100644 Sources/DropTwoCore/ConversionPipeline.swift create mode 100644 Sources/DropTwoCore/JobQueue.swift create mode 100644 Tests/DropTwoCoreTests/ConversionPipelineTests.swift create mode 100644 Tests/DropTwoCoreTests/JobQueueTests.swift diff --git a/Sources/DropTwoCLI/main.swift b/Sources/DropTwoCLI/main.swift new file mode 100644 index 0000000..8b5c519 --- /dev/null +++ b/Sources/DropTwoCLI/main.swift @@ -0,0 +1,55 @@ +import CoreMedia +import DropTwoCore +import Foundation + +// Prüfstein fürs Core-Framework (Schritt 3 der Umsetzungsreihenfolge): +// echte Dateien durchjagen und die Ausgabequalität von CropEngine bewerten. +// Nutzt dieselbe ConversionPipeline wie später die App, damit CLI und +// Oberfläche garantiert dasselbe tun. + +let arguments = Array(CommandLine.arguments.dropFirst()) + +guard arguments.count >= 2 else { + print("Usage: droptwo [ ...]") + exit(0) +} + +let outputDirectory = URL(fileURLWithPath: arguments[0], isDirectory: true) +let sourcePaths = arguments.dropFirst() + +func describe(_ info: VideoInfo) -> String { + let seconds = CMTimeGetSeconds(info.duration) + return "\(Int(info.pixelSize.width))×\(Int(info.pixelSize.height)), \(info.codec), " + + "\(String(format: "%.2f", info.frameRate)) fps, \(String(format: "%.2f", seconds))s, " + + "Audio: \(info.hasAudioTrack ? "ja" : "nein")" +} + +func fileSizeInMB(_ url: URL) -> Double? { + guard let size = try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int else { + return nil + } + return Double(size) / 1_000_000 +} + +for sourcePath in sourcePaths { + let sourceURL = URL(fileURLWithPath: sourcePath) + let fileName = sourceURL.lastPathComponent + let start = Date() + + let outcome = await ConversionPipeline.process(sourceURL: sourceURL, outputRootDirectory: outputDirectory) + + switch outcome { + case .skipped(let fileName, let reason): + print("\(fileName): übersprungen (\(reason))") + case .rejected(let fileName, let reason): + print("\(fileName): abgelehnt (\(reason))") + case .failed(let fileName, let error): + print("\(fileName): Fehler — \(error)") + case .succeeded(let success): + let elapsed = Date().timeIntervalSince(start) + let sizeMB = fileSizeInMB(success.croppedDestination).map { String(format: "%.1f MB", $0) } ?? "?" + print(fileName) + print(" Quelle: \(describe(success.sourceInfo))") + print(" -> \(success.croppedDestination.lastPathComponent): \(describe(success.croppedInfo)), \(sizeMB), \(String(format: "%.1f", elapsed))s Renderzeit") + } +} diff --git a/Sources/DropTwoCore/ConversionPipeline.swift b/Sources/DropTwoCore/ConversionPipeline.swift new file mode 100644 index 0000000..313f874 --- /dev/null +++ b/Sources/DropTwoCore/ConversionPipeline.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Der komplette Weg einer einzelnen Datei: Dateiname parsen, bei Erfolg den +/// Projekt-Unterordner anlegen, das unveränderte 9:16-Original ablegen und +/// die 4:5-Fassung rendern. UI-frei und von `droptwo-cli` wie von der App +/// gemeinsam genutzt, damit beide garantiert dasselbe tun. +public enum ConversionPipeline { + public struct Options { + public var cropOptions: CropEngine.Options + /// true = das unveränderte 9:16-Original wird zusätzlich in den + /// Projektordner kopiert (README: "Dort landen beide Fassungen"). + public var copyOriginal: Bool + + public init(cropOptions: CropEngine.Options = CropEngine.Options(), copyOriginal: Bool = true) { + self.cropOptions = cropOptions + self.copyOriginal = copyOriginal + } + } + + public enum Outcome { + case skipped(fileName: String, reason: SkipReason) + case rejected(fileName: String, reason: RejectionReason) + case succeeded(Success) + case failed(fileName: String, error: Error) + } + + public struct Success { + public let originalDestination: URL + public let croppedDestination: URL + public let sourceInfo: VideoInfo + public let croppedInfo: VideoInfo + } + + public static func process( + sourceURL: URL, + outputRootDirectory: URL, + options: Options = Options(), + progress: ((Double) -> Void)? = nil + ) async -> Outcome { + let fileName = sourceURL.lastPathComponent + let result = FileNameParser.parse(fileName) + + switch result.outcome { + case .skipped(let reason): + return .skipped(fileName: fileName, reason: reason) + case .rejected(let reason): + return .rejected(fileName: fileName, reason: reason) + case .parsed(let parsed): + do { + let success = try await convert(sourceURL: sourceURL, parsed: parsed, outputRootDirectory: outputRootDirectory, options: options, progress: progress) + return .succeeded(success) + } catch { + return .failed(fileName: fileName, error: error) + } + } + } + + private static func convert( + sourceURL: URL, + parsed: ParsedFileName, + outputRootDirectory: URL, + options: Options, + progress: ((Double) -> Void)? + ) async throws -> Success { + let projectDirectory = outputRootDirectory.appendingPathComponent(parsed.projectName, isDirectory: true) + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + + var originalDestination = sourceURL + if options.copyOriginal { + let destination = projectDirectory.appendingPathComponent(parsed.originalFileName) + if !FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.copyItem(at: sourceURL, to: destination) + } + originalDestination = destination + } + + let sourceInfo = try await VideoInspector.inspect(sourceURL) + + let croppedFileName = mp4FileName(from: parsed.targetFileName) + let croppedDestination = projectDirectory.appendingPathComponent(croppedFileName) + try await CropEngine.crop( + sourceURL: sourceURL, + destinationURL: croppedDestination, + options: options.cropOptions, + progress: progress + ) + let croppedInfo = try await VideoInspector.inspect(croppedDestination) + + return Success( + originalDestination: originalDestination, + croppedDestination: croppedDestination, + sourceInfo: sourceInfo, + croppedInfo: croppedInfo + ) + } + + /// 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 { + URL(fileURLWithPath: fileName).deletingPathExtension().appendingPathExtension("mp4").lastPathComponent + } +} diff --git a/Sources/DropTwoCore/JobQueue.swift b/Sources/DropTwoCore/JobQueue.swift new file mode 100644 index 0000000..262c855 --- /dev/null +++ b/Sources/DropTwoCore/JobQueue.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Verarbeitet mehrere Dateien über `ConversionPipeline`, begrenzt auf eine +/// feste Anzahl gleichzeitiger Exporte (README: "1–2 parallele Exports"). +/// Ergebnisse kommen fortlaufend rein, sobald ein Job fertig ist, nicht erst +/// am Ende des gesamten Batches — die UI kann so live mitschreiben. +public enum JobQueue { + public struct Job: Sendable { + public let id: UUID + public let sourceURL: URL + + public init(id: UUID = UUID(), sourceURL: URL) { + self.id = id + self.sourceURL = sourceURL + } + } + + public struct JobResult: Sendable { + public let job: Job + public let outcome: ConversionPipeline.Outcome + } + + public static func run( + jobs: [Job], + outputRootDirectory: URL, + options: ConversionPipeline.Options = ConversionPipeline.Options(), + maxConcurrent: Int = 2, + progress: (@Sendable (Job, Double) -> Void)? = nil + ) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + await withTaskGroup(of: JobResult.self) { group in + var iterator = jobs.makeIterator() + + func startNext() { + guard let job = iterator.next() else { return } + group.addTask { + let outcome = await ConversionPipeline.process( + sourceURL: job.sourceURL, + outputRootDirectory: outputRootDirectory, + options: options + ) { value in + progress?(job, value) + } + return JobResult(job: job, outcome: outcome) + } + } + + let concurrencyLimit = max(1, maxConcurrent) + for _ in 0.. ConversionPipeline.Outcome? { + results.first { $0.job.id == job.id }?.outcome + } + + guard case .succeeded = outcome(for: jobs[0]) else { + return XCTFail("Erwartete .succeeded für die valide Datei") + } + guard case .skipped(_, .alreadyTargetFormat) = outcome(for: jobs[1]) else { + return XCTFail("Erwartete .skipped für die bereits-4:5-Datei") + } + guard case .rejected(_, .formatSegmentMissing) = outcome(for: jobs[2]) else { + return XCTFail("Erwartete .rejected für die Datei ohne Format-Segment") + } + } + + func testWorksWithMaxConcurrentOne() async throws { + let clipA = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory) + let clipB = try TestVideoFactory.makeNineBySixteenClip(fileName: "Somos-Pendant-9x16-v3-DE.mov", in: tempDirectory) + + let jobs = [JobQueue.Job(sourceURL: clipA.url), JobQueue.Job(sourceURL: clipB.url)] + + var successCount = 0 + for await result in JobQueue.run(jobs: jobs, outputRootDirectory: outputDirectory, maxConcurrent: 1) { + if case .succeeded = result.outcome { + successCount += 1 + } + } + + XCTAssertEqual(successCount, 2) + } + + func testReportsProgressForJobs() async throws { + let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory) + let job = JobQueue.Job(sourceURL: clip.url) + + let progressBox = ProgressBox() + var results: [JobQueue.JobResult] = [] + for await result in JobQueue.run( + jobs: [job], + outputRootDirectory: outputDirectory, + progress: { _, value in progressBox.record(value) } + ) { + results.append(result) + } + + XCTAssertEqual(results.count, 1) + XCTAssertFalse(progressBox.values.isEmpty) + } + + /// Sammelt Fortschrittswerte thread-sicher (synchron per Lock statt + /// Actor-Hop), da `progress` von einer Hintergrund-Queue innerhalb von + /// CropEngine aufgerufen wird und wir sofort nach dem Batch lesen. + private final class ProgressBox: @unchecked Sendable { + private let lock = NSLock() + private var _values: [Double] = [] + + func record(_ value: Double) { + lock.lock() + _values.append(value) + lock.unlock() + } + + var values: [Double] { + lock.lock() + defer { lock.unlock() } + return _values + } + } +}