Add duplicate video variant selection
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
Reference in New Issue
Block a user