Add duplicate video variant selection

This commit is contained in:
Mikei386
2026-06-02 21:18:52 +02:00
parent 4ea04dc3e4
commit 13169cac8e
3 changed files with 138 additions and 3 deletions
+7
View File
@@ -108,6 +108,13 @@ struct ContentView: View {
} }
.help("Bestimmt die chronologische Reihenfolge der Quellvideos: nach Dateiname oder Änderungsdatum.") .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 { if settings.videoEncoder == .videoToolbox {
Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) { Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) {
HStack { HStack {
+13
View File
@@ -15,6 +15,15 @@ enum SortMode: String, CaseIterable, Identifiable, Codable {
var id: String { rawValue } 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 { enum VideoEncoder: String, CaseIterable, Identifiable, Codable {
case x264 = "CPU: libx264" case x264 = "CPU: libx264"
case videoToolbox = "Hardware: VideoToolbox H.264" case videoToolbox = "Hardware: VideoToolbox H.264"
@@ -111,6 +120,7 @@ struct RenderSettings: Equatable {
var fps: Int = 30 var fps: Int = 30
var outputFormat: OutputFormat = .landscape1920x1080 var outputFormat: OutputFormat = .landscape1920x1080
var sortMode: SortMode = .filenameAscending var sortMode: SortMode = .filenameAscending
var duplicateClipSelection: DuplicateClipSelection = .first
var removeAudio: Bool = true var removeAudio: Bool = true
var takeMiddleSegment: Bool = true var takeMiddleSegment: Bool = true
var startOffset: Double = 0 var startOffset: Double = 0
@@ -140,6 +150,7 @@ struct PersistedRenderSettings: Codable {
var fps: Int var fps: Int
var outputFormat: OutputFormat var outputFormat: OutputFormat
var sortMode: SortMode var sortMode: SortMode
var duplicateClipSelection: DuplicateClipSelection?
var removeAudio: Bool var removeAudio: Bool
var takeMiddleSegment: Bool var takeMiddleSegment: Bool
var startOffset: Double var startOffset: Double
@@ -168,6 +179,7 @@ struct PersistedRenderSettings: Codable {
fps = settings.fps fps = settings.fps
outputFormat = settings.outputFormat outputFormat = settings.outputFormat
sortMode = settings.sortMode sortMode = settings.sortMode
duplicateClipSelection = settings.duplicateClipSelection
removeAudio = settings.removeAudio removeAudio = settings.removeAudio
takeMiddleSegment = settings.takeMiddleSegment takeMiddleSegment = settings.takeMiddleSegment
startOffset = settings.startOffset startOffset = settings.startOffset
@@ -198,6 +210,7 @@ struct PersistedRenderSettings: Codable {
fps: fps, fps: fps,
outputFormat: outputFormat, outputFormat: outputFormat,
sortMode: sortMode, sortMode: sortMode,
duplicateClipSelection: duplicateClipSelection ?? .first,
removeAudio: removeAudio, removeAudio: removeAudio,
takeMiddleSegment: takeMiddleSegment, takeMiddleSegment: takeMiddleSegment,
startOffset: startOffset, startOffset: startOffset,
+118 -3
View File
@@ -90,7 +90,13 @@ final class VideoProcessor: ObservableObject {
temporaryDirectory = tempDirectory temporaryDirectory = tempDirectory
appendLog("Temporärer Ordner: \(tempDirectory.path)") 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.") appendLog("\(videos.count) Video(s) gefunden.")
let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0]) let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)") 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 supportedExtensions = Set(["mp4", "mov", "m4v", "avi", "mkv", "webm"])
let excludedPath = outputFile?.standardizedFileURL.path let excludedPath = outputFile?.standardizedFileURL.path
let contents = try FileManager.default.contentsOfDirectory( 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)) result.append(VideoFile(url: url, duration: duration, width: dimensions.width, height: dimensions.height))
appendLog("Dauer \(url.lastPathComponent): \(formatSeconds(duration)), \(dimensions.width)x\(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<String>()
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 { private func probeDuration(url: URL, ffprobe: URL) async throws -> Double {