diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 8690831..eb4aa3c 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -6,6 +6,7 @@ struct ContentView: View { @StateObject private var processor = VideoProcessor() @State private var settings = SettingsStore.load() @State private var selectedClipID: UUID? + @State private var variantPickerClip: GrowthLapseProjectClip? var body: some View { HSplitView { @@ -256,6 +257,13 @@ struct ContentView: View { .onChange(of: settings.sortMode) { sortMode in processor.reorderProject(sortMode: sortMode) } + .sheet(item: $variantPickerClip) { clip in + ClipVariantPickerView(clip: currentClip(id: clip.id) ?? clip) { variant in + processor.switchClip(clip, to: variant) + variantPickerClip = nil + } + .frame(minWidth: 560, minHeight: 420) + } } private var statusHeader: some View { @@ -338,6 +346,16 @@ struct ContentView: View { Text(String(format: "%03d", clip.index)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) + if clip.availableVariants.count > 1 { + Text("v\(clip.availableVariants.count)") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help("Mehrere Versionen verfügbar. Doppelklick zum Wechseln.") + } Text(clip.displayName) .lineLimit(1) if clip.needsRender { @@ -350,6 +368,12 @@ struct ContentView: View { .font(.caption) .foregroundStyle(.secondary) } + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + if clip.availableVariants.count > 1 { + variantPickerClip = clip + } + } } .frame(minWidth: 240, idealWidth: 300, maxWidth: 340, minHeight: 220, maxHeight: 300) @@ -651,3 +675,101 @@ private struct SegmentTimelineView: View { return formatter.string(from: NSNumber(value: value)) ?? "\(value)" } } + +private struct ClipVariantPickerView: View { + let clip: GrowthLapseProjectClip + let onSelect: (GrowthLapseClipVariant) -> Void + @Environment(\.dismiss) private var dismiss + @State private var selectedVariantID: UUID? + + private var selectedVariant: GrowthLapseClipVariant? { + let variants = clip.availableVariants + if let selectedVariantID, + let variant = variants.first(where: { $0.id == selectedVariantID }) { + return variant + } + return variants.first(where: { $0.sourcePath == clip.sourcePath }) ?? variants.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Clip-Variante wählen") + .font(.title3.bold()) + Spacer() + Button("Schließen") { + dismiss() + } + } + + if let selectedVariant { + VariantPreviewView(variant: selectedVariant) + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + List(clip.availableVariants, selection: $selectedVariantID) { variant in + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(variant.displayName) + .lineLimit(1) + Text("\(format(variant.duration)) s, \(variant.width)x\(variant.height)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if variant.sourcePath == clip.sourcePath { + Text("aktuell") + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Verwenden") { + onSelect(variant) + } + .disabled(variant.sourcePath == clip.sourcePath) + } + .contentShape(Rectangle()) + .onTapGesture { + selectedVariantID = variant.id + } + .onTapGesture(count: 2) { + if variant.sourcePath != clip.sourcePath { + onSelect(variant) + } + } + } + } + .padding() + .onAppear { + selectedVariantID = selectedVariant?.id + } + } + + private func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.minimumFractionDigits = value.rounded() == value ? 0 : 1 + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +} + +private struct VariantPreviewView: View { + let variant: GrowthLapseClipVariant + @State private var player = AVPlayer() + + var body: some View { + VideoPlayer(player: player) + .onAppear { + loadVariant() + } + .onChange(of: variant.id) { _ in + loadVariant() + } + } + + private func loadVariant() { + player.replaceCurrentItem(with: AVPlayerItem(url: variant.sourceURL)) + player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) + } +} diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift index 8013018..0a8f43c 100644 --- a/Sources/GrowthLapse/RenderSettings.swift +++ b/Sources/GrowthLapse/RenderSettings.swift @@ -284,10 +284,23 @@ struct VideoFile: Identifiable { let duration: Double let width: Int let height: Int + let variants: [GrowthLapseClipVariant] + + init(url: URL, duration: Double, width: Int, height: Int, variants: [GrowthLapseClipVariant] = []) { + self.url = url + self.duration = duration + self.width = width + self.height = height + self.variants = variants + } var displayName: String { url.lastPathComponent } + + func withVariants(_ variants: [GrowthLapseClipVariant]) -> VideoFile { + VideoFile(url: url, duration: duration, width: width, height: height, variants: variants) + } } struct GrowthLapseProject: Codable, Equatable { @@ -327,6 +340,7 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable { var segmentLength: Double var normalizedClipPath: String var needsRender: Bool = false + var variants: [GrowthLapseClipVariant]? var sourceURL: URL { URL(fileURLWithPath: sourcePath) @@ -335,6 +349,24 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable { var normalizedClipURL: URL { URL(fileURLWithPath: normalizedClipPath) } + + var availableVariants: [GrowthLapseClipVariant] { + variants ?? [] + } +} + +struct GrowthLapseClipVariant: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var sourcePath: String + var displayName: String + var duration: Double + var width: Int + var height: Int + var ageWeeks: Int? + + var sourceURL: URL { + URL(fileURLWithPath: sourcePath) + } } struct VideoDimensions: Equatable { diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index dde525a..e477875 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -106,6 +106,27 @@ final class VideoProcessor: ObservableObject { saveProject(project) } + func switchClip(_ clip: GrowthLapseProjectClip, to variant: GrowthLapseClipVariant) { + guard var project, + let index = project.clips.firstIndex(where: { $0.id == clip.id }) else { + return + } + var updated = project.clips[index] + updated.sourcePath = variant.sourcePath + updated.displayName = variant.displayName + updated.duration = variant.duration + updated.width = variant.width + updated.height = variant.height + updated.ageWeeks = variant.ageWeeks + updated.segmentLength = min(updated.segmentLength, variant.duration) + updated.segmentStart = clamp(updated.segmentStart, min: 0, max: max(0, variant.duration - updated.segmentLength)) + updated.needsRender = true + project.clips[index] = updated + self.project = project + saveProject(project) + appendLog("Variante gewechselt: Clip \(updated.index) -> \(updated.displayName)") + } + func reorderProject(sortMode: SortMode) { guard let project else { return @@ -172,7 +193,7 @@ final class VideoProcessor: ObservableObject { let videos: [VideoFile] if let existingProject { videos = existingProject.clips.map { - VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height) + VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height, variants: $0.availableVariants) } appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).") } else { @@ -374,7 +395,7 @@ final class VideoProcessor: ObservableObject { handledGroups.insert(key) let chosen = chooseDuplicateVariant(from: variants, selection: selection) - selected.append(chosen) + selected.append(chosen.withVariants(variants.map { projectVariant(from: $0) })) let skipped = variants .filter { $0.url != chosen.url } .map(\.displayName) @@ -385,6 +406,17 @@ final class VideoProcessor: ObservableObject { return selected } + private func projectVariant(from video: VideoFile) -> GrowthLapseClipVariant { + GrowthLapseClipVariant( + sourcePath: video.url.path, + displayName: video.displayName, + duration: video.duration, + width: video.width, + height: video.height, + ageWeeks: ageInWeeks(from: video.url) + ) + } + private func ageInWeeks(from url: URL) -> Int? { let name = url.deletingPathExtension().lastPathComponent let pattern = #"(\d{1,4})\s*Wochen\s+alt"# @@ -1035,7 +1067,8 @@ final class VideoProcessor: ObservableObject { segmentStart: safeStart, segmentLength: segmentLength, normalizedClipPath: normalizedClipURL.path, - needsRender: true + needsRender: true, + variants: video.variants.isEmpty ? nil : video.variants ) }