Add duplicate clip variant picker

This commit is contained in:
Mikei386
2026-06-04 09:50:03 +02:00
parent 7c0eb7b0d4
commit 50c1253e5c
3 changed files with 190 additions and 3 deletions
+122
View File
@@ -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)
}
}
+32
View File
@@ -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 {
+36 -3
View File
@@ -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
)
}