diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index e2e55fe..6434de3 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -108,6 +108,13 @@ struct ContentView: View { } .help("Bestimmt die chronologische Reihenfolge der Quellvideos: nach Dateiname oder Änderungsdatum.") + Picker("Mehrfach-Versionen", selection: $settings.duplicateClipSelection) { + ForEach(DuplicateClipSelection.allCases) { selection in + Text(selection.rawValue).tag(selection) + } + } + .help("Wenn mehrere Dateien denselben Namen mit Endung 01, 02, 03 haben, wählt GrowthLapse nur eine Variante. Beispiel: '... 118 Wochen alt 01.MOV' und '... 118 Wochen alt 02.MOV'.") + if settings.videoEncoder == .videoToolbox { Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) { HStack { diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift index f71aada..a0ba7a2 100644 --- a/Sources/GrowthLapse/RenderSettings.swift +++ b/Sources/GrowthLapse/RenderSettings.swift @@ -15,6 +15,15 @@ enum SortMode: String, CaseIterable, Identifiable, Codable { var id: String { rawValue } } +enum DuplicateClipSelection: String, CaseIterable, Identifiable, Codable { + case first = "Variante 1" + case second = "Variante 2" + case thirdIfAvailable = "Variante 3 wenn verfügbar" + case longest = "Längste Variante" + + var id: String { rawValue } +} + enum VideoEncoder: String, CaseIterable, Identifiable, Codable { case x264 = "CPU: libx264" case videoToolbox = "Hardware: VideoToolbox H.264" @@ -111,6 +120,7 @@ struct RenderSettings: Equatable { var fps: Int = 30 var outputFormat: OutputFormat = .landscape1920x1080 var sortMode: SortMode = .filenameAscending + var duplicateClipSelection: DuplicateClipSelection = .first var removeAudio: Bool = true var takeMiddleSegment: Bool = true var startOffset: Double = 0 @@ -140,6 +150,7 @@ struct PersistedRenderSettings: Codable { var fps: Int var outputFormat: OutputFormat var sortMode: SortMode + var duplicateClipSelection: DuplicateClipSelection? var removeAudio: Bool var takeMiddleSegment: Bool var startOffset: Double @@ -168,6 +179,7 @@ struct PersistedRenderSettings: Codable { fps = settings.fps outputFormat = settings.outputFormat sortMode = settings.sortMode + duplicateClipSelection = settings.duplicateClipSelection removeAudio = settings.removeAudio takeMiddleSegment = settings.takeMiddleSegment startOffset = settings.startOffset @@ -198,6 +210,7 @@ struct PersistedRenderSettings: Codable { fps: fps, outputFormat: outputFormat, sortMode: sortMode, + duplicateClipSelection: duplicateClipSelection ?? .first, removeAudio: removeAudio, takeMiddleSegment: takeMiddleSegment, startOffset: startOffset, diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index d24747d..32f9b5c 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -90,7 +90,13 @@ final class VideoProcessor: ObservableObject { temporaryDirectory = tempDirectory appendLog("Temporärer Ordner: \(tempDirectory.path)") - let videos = try await discoverVideos(in: inputFolder, sortMode: settings.sortMode, ffprobe: tools.ffprobe, excluding: outputFile) + let videos = try await discoverVideos( + in: inputFolder, + sortMode: settings.sortMode, + duplicateClipSelection: settings.duplicateClipSelection, + ffprobe: tools.ffprobe, + excluding: outputFile + ) appendLog("\(videos.count) Video(s) gefunden.") let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0]) appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)") @@ -173,7 +179,13 @@ final class VideoProcessor: ObservableObject { } } - private func discoverVideos(in folder: URL, sortMode: SortMode, ffprobe: URL, excluding outputFile: URL?) async throws -> [VideoFile] { + private func discoverVideos( + in folder: URL, + sortMode: SortMode, + duplicateClipSelection: DuplicateClipSelection, + ffprobe: URL, + excluding outputFile: URL? + ) async throws -> [VideoFile] { let supportedExtensions = Set(["mp4", "mov", "m4v", "avi", "mkv", "webm"]) let excludedPath = outputFile?.standardizedFileURL.path let contents = try FileManager.default.contentsOfDirectory( @@ -212,7 +224,110 @@ final class VideoProcessor: ObservableObject { result.append(VideoFile(url: url, duration: duration, width: dimensions.width, height: dimensions.height)) appendLog("Dauer \(url.lastPathComponent): \(formatSeconds(duration)), \(dimensions.width)x\(dimensions.height)") } - return result + return selectDuplicateVariants(result, selection: duplicateClipSelection) + } + + private func selectDuplicateVariants(_ videos: [VideoFile], selection: DuplicateClipSelection) -> [VideoFile] { + var groups: [String: [VideoFile]] = [:] + for video in videos { + guard let key = duplicateGroupKey(for: video.url) else { + continue + } + groups[key, default: []].append(video) + } + + var selected: [VideoFile] = [] + var handledGroups = Set() + + for video in videos { + guard let key = duplicateGroupKey(for: video.url), + let variants = groups[key], + variants.count > 1 else { + selected.append(video) + continue + } + + guard !handledGroups.contains(key) else { + continue + } + handledGroups.insert(key) + + let chosen = chooseDuplicateVariant(from: variants, selection: selection) + selected.append(chosen) + let skipped = variants + .filter { $0.url != chosen.url } + .map(\.displayName) + .joined(separator: ", ") + appendLog("Mehrfach-Version: \(duplicateDisplayName(for: key)) -> \(chosen.displayName) (\(selection.rawValue)); übersprungen: \(skipped)") + } + + return selected + } + + private func chooseDuplicateVariant(from variants: [VideoFile], selection: DuplicateClipSelection) -> VideoFile { + let sortedVariants = variants.sorted { left, right in + let leftVersion = duplicateVersionNumber(for: left.url) ?? Int.max + let rightVersion = duplicateVersionNumber(for: right.url) ?? Int.max + if leftVersion != rightVersion { + return leftVersion < rightVersion + } + return left.url.lastPathComponent.localizedStandardCompare(right.url.lastPathComponent) == .orderedAscending + } + + switch selection { + case .first: + return variant(number: 1, in: sortedVariants) ?? sortedVariants[0] + case .second: + return variant(number: 2, in: sortedVariants) ?? sortedVariants[0] + case .thirdIfAvailable: + return variant(number: 3, in: sortedVariants) ?? sortedVariants.last ?? sortedVariants[0] + case .longest: + return sortedVariants.max { left, right in + if left.duration != right.duration { + return left.duration < right.duration + } + let leftVersion = duplicateVersionNumber(for: left.url) ?? Int.max + let rightVersion = duplicateVersionNumber(for: right.url) ?? Int.max + return leftVersion > rightVersion + } ?? sortedVariants[0] + } + } + + private func variant(number: Int, in variants: [VideoFile]) -> VideoFile? { + variants.first { duplicateVersionNumber(for: $0.url) == number } + } + + private func duplicateGroupKey(for url: URL) -> String? { + let name = url.deletingPathExtension().lastPathComponent + guard let parsed = parseDuplicateSuffix(from: name) else { + return nil + } + return parsed.base.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func duplicateDisplayName(for key: String) -> String { + key.isEmpty ? "Unbenannte Gruppe" : key + } + + private func duplicateVersionNumber(for url: URL) -> Int? { + parseDuplicateSuffix(from: url.deletingPathExtension().lastPathComponent)?.version + } + + private func parseDuplicateSuffix(from filenameWithoutExtension: String) -> (base: String, version: Int)? { + let pattern = #"^(.*\S)\s+(\d{1,3})$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: filenameWithoutExtension, + range: NSRange(filenameWithoutExtension.startIndex..., in: filenameWithoutExtension) + ), + match.numberOfRanges == 3, + let baseRange = Range(match.range(at: 1), in: filenameWithoutExtension), + let versionRange = Range(match.range(at: 2), in: filenameWithoutExtension), + let version = Int(filenameWithoutExtension[versionRange]), + version > 0 else { + return nil + } + return (String(filenameWithoutExtension[baseRange]), version) } private func probeDuration(url: URL, ffprobe: URL) async throws -> Double {