ConversionPipeline, JobQueue und droptwo-cli als Prüfstein

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).
main
Samuel Zielke 1 month ago
parent aea8a3e6fd
commit b2e1370ddb

@ -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 <zielordner> <datei> [<datei> ...]")
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")
}
}

@ -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
}
}

@ -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<JobResult> {
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..<min(concurrencyLimit, jobs.count) {
startNext()
}
while let result = await group.next() {
continuation.yield(result)
startNext()
}
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

@ -0,0 +1,94 @@
import XCTest
@testable import DropTwoCore
final class ConversionPipelineTests: XCTestCase {
private var tempDirectory: URL!
private var outputDirectory: URL!
override func setUpWithError() throws {
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
outputDirectory = tempDirectory.appendingPathComponent("output")
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDirectory)
}
func testProcessesValidFileEndToEnd() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory)
let outcome = await ConversionPipeline.process(sourceURL: clip.url, outputRootDirectory: outputDirectory)
guard case .succeeded(let success) = outcome else {
return XCTFail("Erwartete .succeeded, bekam \(outcome)")
}
XCTAssertEqual(success.croppedDestination.lastPathComponent, "SOMOS-4x5-v3-DE.mp4")
XCTAssertEqual(success.croppedDestination.deletingLastPathComponent().lastPathComponent, "SOMOS")
XCTAssertTrue(FileManager.default.fileExists(atPath: success.originalDestination.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: success.croppedDestination.path))
XCTAssertEqual(success.croppedInfo.pixelSize, CropEngine.targetSize(forSourceSize: clip.size))
}
func testSkipsWithoutTouchingFileSystem() async {
let sourceURL = tempDirectory.appendingPathComponent("SOMOS-4x5-v3-DE.mov")
let outcome = await ConversionPipeline.process(sourceURL: sourceURL, outputRootDirectory: outputDirectory)
guard case .skipped(let fileName, let reason) = outcome else {
return XCTFail("Erwartete .skipped, bekam \(outcome)")
}
XCTAssertEqual(fileName, "SOMOS-4x5-v3-DE.mov")
XCTAssertEqual(reason, .alreadyTargetFormat)
XCTAssertTrue(try! FileManager.default.contentsOfDirectory(atPath: outputDirectory.path).isEmpty)
}
func testRejectsWithoutTouchingFileSystem() async {
let sourceURL = tempDirectory.appendingPathComponent("SOMOS-v3-DE.mov")
let outcome = await ConversionPipeline.process(sourceURL: sourceURL, outputRootDirectory: outputDirectory)
guard case .rejected(let fileName, let reason) = outcome else {
return XCTFail("Erwartete .rejected, bekam \(outcome)")
}
XCTAssertEqual(fileName, "SOMOS-v3-DE.mov")
XCTAssertEqual(reason, .formatSegmentMissing)
XCTAssertTrue(try! FileManager.default.contentsOfDirectory(atPath: outputDirectory.path).isEmpty)
}
func testFailsWhenSourceHasWrongAspectRatio() async throws {
let clip = try TestVideoFactory.makeClip(
size: CGSize(width: 400, height: 400),
fileName: "SOMOS-9x16-v3-DE.mov",
in: tempDirectory
)
let outcome = await ConversionPipeline.process(sourceURL: clip.url, outputRootDirectory: outputDirectory)
guard case .failed(let fileName, let error) = outcome else {
return XCTFail("Erwartete .failed, bekam \(outcome)")
}
XCTAssertEqual(fileName, "SOMOS-9x16-v3-DE.mov")
guard case CropEngineError.sourceNotNineBySixteen = error else {
return XCTFail("Erwartete CropEngineError.sourceNotNineBySixteen, bekam \(error)")
}
}
func testCanSkipCopyingTheOriginal() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory)
let outcome = await ConversionPipeline.process(
sourceURL: clip.url,
outputRootDirectory: outputDirectory,
options: .init(copyOriginal: false)
)
guard case .succeeded(let success) = outcome else {
return XCTFail("Erwartete .succeeded, bekam \(outcome)")
}
XCTAssertEqual(success.originalDestination, clip.url)
let projectDirectory = outputDirectory.appendingPathComponent("SOMOS")
let contents = try FileManager.default.contentsOfDirectory(atPath: projectDirectory.path)
XCTAssertEqual(contents, ["SOMOS-4x5-v3-DE.mp4"])
}
}

@ -0,0 +1,104 @@
import XCTest
@testable import DropTwoCore
final class JobQueueTests: XCTestCase {
private var tempDirectory: URL!
private var outputDirectory: URL!
override func setUpWithError() throws {
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
outputDirectory = tempDirectory.appendingPathComponent("output")
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDirectory)
}
func testProcessesAllJobsAndReportsEachOutcome() async throws {
let validClip = try TestVideoFactory.makeNineBySixteenClip(fileName: "SOMOS-9x16-v3-DE.mov", in: tempDirectory)
let skippedURL = tempDirectory.appendingPathComponent("SOMOS-4x5-v3-DE.mov")
let rejectedURL = tempDirectory.appendingPathComponent("SOMOS-v3-DE.mov")
let jobs = [
JobQueue.Job(sourceURL: validClip.url),
JobQueue.Job(sourceURL: skippedURL),
JobQueue.Job(sourceURL: rejectedURL)
]
var results: [JobQueue.JobResult] = []
for await result in JobQueue.run(jobs: jobs, outputRootDirectory: outputDirectory) {
results.append(result)
}
XCTAssertEqual(results.count, 3)
func outcome(for job: JobQueue.Job) -> 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
}
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.