Add duplicate clip variant picker
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user