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