Add duplicate clip variant picker
This commit is contained in:
@@ -6,6 +6,7 @@ struct ContentView: View {
|
|||||||
@StateObject private var processor = VideoProcessor()
|
@StateObject private var processor = VideoProcessor()
|
||||||
@State private var settings = SettingsStore.load()
|
@State private var settings = SettingsStore.load()
|
||||||
@State private var selectedClipID: UUID?
|
@State private var selectedClipID: UUID?
|
||||||
|
@State private var variantPickerClip: GrowthLapseProjectClip?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
@@ -256,6 +257,13 @@ struct ContentView: View {
|
|||||||
.onChange(of: settings.sortMode) { sortMode in
|
.onChange(of: settings.sortMode) { sortMode in
|
||||||
processor.reorderProject(sortMode: sortMode)
|
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 {
|
private var statusHeader: some View {
|
||||||
@@ -338,6 +346,16 @@ struct ContentView: View {
|
|||||||
Text(String(format: "%03d", clip.index))
|
Text(String(format: "%03d", clip.index))
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
Text(clip.displayName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if clip.needsRender {
|
if clip.needsRender {
|
||||||
@@ -350,6 +368,12 @@ struct ContentView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.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)"
|
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 duration: Double
|
||||||
let width: Int
|
let width: Int
|
||||||
let height: 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 {
|
var displayName: String {
|
||||||
url.lastPathComponent
|
url.lastPathComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withVariants(_ variants: [GrowthLapseClipVariant]) -> VideoFile {
|
||||||
|
VideoFile(url: url, duration: duration, width: width, height: height, variants: variants)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GrowthLapseProject: Codable, Equatable {
|
struct GrowthLapseProject: Codable, Equatable {
|
||||||
@@ -327,6 +340,7 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable {
|
|||||||
var segmentLength: Double
|
var segmentLength: Double
|
||||||
var normalizedClipPath: String
|
var normalizedClipPath: String
|
||||||
var needsRender: Bool = false
|
var needsRender: Bool = false
|
||||||
|
var variants: [GrowthLapseClipVariant]?
|
||||||
|
|
||||||
var sourceURL: URL {
|
var sourceURL: URL {
|
||||||
URL(fileURLWithPath: sourcePath)
|
URL(fileURLWithPath: sourcePath)
|
||||||
@@ -335,6 +349,24 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable {
|
|||||||
var normalizedClipURL: URL {
|
var normalizedClipURL: URL {
|
||||||
URL(fileURLWithPath: normalizedClipPath)
|
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 {
|
struct VideoDimensions: Equatable {
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ final class VideoProcessor: ObservableObject {
|
|||||||
saveProject(project)
|
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) {
|
func reorderProject(sortMode: SortMode) {
|
||||||
guard let project else {
|
guard let project else {
|
||||||
return
|
return
|
||||||
@@ -172,7 +193,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
let videos: [VideoFile]
|
let videos: [VideoFile]
|
||||||
if let existingProject {
|
if let existingProject {
|
||||||
videos = existingProject.clips.map {
|
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).")
|
appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).")
|
||||||
} else {
|
} else {
|
||||||
@@ -374,7 +395,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
handledGroups.insert(key)
|
handledGroups.insert(key)
|
||||||
|
|
||||||
let chosen = chooseDuplicateVariant(from: variants, selection: selection)
|
let chosen = chooseDuplicateVariant(from: variants, selection: selection)
|
||||||
selected.append(chosen)
|
selected.append(chosen.withVariants(variants.map { projectVariant(from: $0) }))
|
||||||
let skipped = variants
|
let skipped = variants
|
||||||
.filter { $0.url != chosen.url }
|
.filter { $0.url != chosen.url }
|
||||||
.map(\.displayName)
|
.map(\.displayName)
|
||||||
@@ -385,6 +406,17 @@ final class VideoProcessor: ObservableObject {
|
|||||||
return selected
|
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? {
|
private func ageInWeeks(from url: URL) -> Int? {
|
||||||
let name = url.deletingPathExtension().lastPathComponent
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
let pattern = #"(\d{1,4})\s*Wochen\s+alt"#
|
let pattern = #"(\d{1,4})\s*Wochen\s+alt"#
|
||||||
@@ -1035,7 +1067,8 @@ final class VideoProcessor: ObservableObject {
|
|||||||
segmentStart: safeStart,
|
segmentStart: safeStart,
|
||||||
segmentLength: segmentLength,
|
segmentLength: segmentLength,
|
||||||
normalizedClipPath: normalizedClipURL.path,
|
normalizedClipPath: normalizedClipURL.path,
|
||||||
needsRender: true
|
needsRender: true,
|
||||||
|
variants: video.variants.isEmpty ? nil : video.variants
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user