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
parent
aea8a3e6fd
commit
b2e1370ddb
@ -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,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…
Reference in new issue