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 /// 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, appendFormatWhenMissing: Bool = false ) { self.cropOptions = cropOptions self.copyOriginal = copyOriginal self.appendFormatWhenMissing = appendFormatWhenMissing } } 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 } /// Berechnet nur, wohin die 4:5-Fassung geschrieben würde — ohne /// irgendetwas anzufassen. `nil`, wenn der Dateiname nicht geparst werden /// 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, 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) return projectDirectory.appendingPathComponent(mp4FileName(from: parsed.targetFileName)) } public static func process( sourceURL: URL, outputRootDirectory: URL, options: Options = Options(), progress: ((Double) -> Void)? = nil ) async -> Outcome { let fileName = sourceURL.lastPathComponent switch resolve(fileName, appendFormatWhenMissing: options.appendFormatWhenMissing) { 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) } } } /// 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, 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 ) } /// 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 { URL(fileURLWithPath: fileName).deletingPathExtension().appendingPathExtension("mp4").lastPathComponent } }