import AVKit import SwiftUI import UniformTypeIdentifiers struct ContentView: View { @StateObject private var processor = VideoProcessor() @State private var settings = SettingsStore.load() @State private var selectedClipID: UUID? @State private var variantPickerClip: GrowthLapseProjectClip? @State private var reviewPanelHeight: CGFloat = 460 @State private var showingResult = false var body: some View { HSplitView { Form { Section("Dateien") { pickerRow( title: "Input-Ordner", value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt", buttonTitle: "Auswählen", help: "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen." ) { selectInputFolder() } pickerRow( title: "Output-Datei", value: settings.outputFile?.path(percentEncoded: false) ?? "Nicht gewählt", buttonTitle: "Speichern unter", help: "Ziel-MP4. Neben dieser Datei legt GrowthLapse ein Render-Protokoll mit den ausgeführten Schritten an." ) { selectOutputFile() } Button("Projekt laden") { selectProjectFile() } .help("Öffnet ein gespeichertes Projekt mit Ausschnitten, Varianten und Render-Einstellungen. Deine lokalen FFmpeg-Pfade bleiben erhalten.") } .disabled(processor.state.isRunning) Section("FFmpeg") { pickerRow( title: "ffmpeg", value: settings.ffmpegExecutable?.path(percentEncoded: false) ?? "Automatisch suchen", buttonTitle: "Auswählen", help: "Optionaler Pfad zu einer eigenen ffmpeg-Binary, z.B. mit libvidstab. Leer lassen für automatische Suche." ) { selectExecutable(title: "ffmpeg auswählen") { url in settings.ffmpegExecutable = url } } pickerRow( title: "ffprobe", value: settings.ffprobeExecutable?.path(percentEncoded: false) ?? "Automatisch suchen", buttonTitle: "Auswählen", help: "Optionaler Pfad zu ffprobe passend zu deiner ffmpeg-Version. ffprobe liest Dauer und Videoabmessungen." ) { selectExecutable(title: "ffprobe auswählen") { url in settings.ffprobeExecutable = url } } Button("Automatische Suche verwenden") { settings.ffmpegExecutable = nil settings.ffprobeExecutable = nil } .disabled(settings.ffmpegExecutable == nil && settings.ffprobeExecutable == nil) .help("Entfernt die manuell gewählten Werkzeugpfade und sucht FFmpeg und FFprobe automatisch auf diesem Mac.") } .disabled(processor.state.isRunning) Section("Parameter") { if processor.project != nil { Text("Ausschnittlänge und Start-Offset werden mit ‚Segmente neu planen‘ übernommen. Normales Neurendern erhält die gewählten Ausschnitte.") .font(.caption) .foregroundStyle(.secondary) } doubleStepper("Ausschnittlänge", value: $settings.segmentLength, range: 1...120, step: 1, suffix: "s", help: "So viele Sekunden werden aus jedem Originalvideo entnommen, bevor Stabilisierung und Beschleunigung passieren.") doubleStepper("Ziellänge pro Clip", value: $settings.targetClipLength, range: 0.5...30, step: 0.5, suffix: "s", help: "Länge jedes fertigen Zwischenclips nach der Beschleunigung. Beispiel: 10 Sekunden Segment auf 2 Sekunden ergibt 5x Speed.") doubleStepper("Übergangslänge", value: $settings.transitionLength, range: 0...5, step: 0.1, suffix: "s", help: "Dauer des Crossfade zwischen zwei Clips. 0 verbindet hart per concat.") Stepper(value: $settings.fps, in: 1...120) { HStack { Text("FPS") Spacer() Text("\(settings.fps)") .foregroundStyle(.secondary) } } .help("Bildrate der normalisierten Zwischenclips und des finalen Videos. Für TV sind 30 fps meistens passend.") Stepper(value: $settings.crf, in: 0...51) { HStack { Text("CRF") Spacer() Text("\(settings.crf)") .foregroundStyle(.secondary) } } .help("Qualitätswert für libx264. Niedriger ist bessere Qualität/größere Datei. Wird bei VideoToolbox nicht verwendet.") doubleStepper("Start-Offset", value: $settings.startOffset, range: -60...60, step: 0.5, suffix: "s", help: "Verschiebt den gewählten Ausschnitt relativ zur Mitte. Positive Werte nehmen späteres Material, negative früheres.") } .disabled(processor.state.isRunning) Section("Ausgabe") { Picker("Format", selection: $settings.outputFormat) { ForEach(OutputFormat.allCases) { format in Text(format.rawValue).tag(format) } } .help("Zielbildformat. Für TV ist Landscape 1920x1080 SDR die robuste Wahl.") Picker("Encoder", selection: $settings.videoEncoder) { ForEach(VideoEncoder.allCases) { encoder in Text(encoder.rawValue).tag(encoder) } } .help("VideoToolbox nutzt Apples Hardware-Encoder. libx264 ist CPU-lastiger, aber klassisch kontrollierbar über CRF.") Picker("Sortierung", selection: $settings.sortMode) { ForEach(SortMode.allCases) { sortMode in Text(sortMode.rawValue).tag(sortMode) } } .help("Bestimmt die chronologische Reihenfolge der Quellvideos. Wenn Dateinamen wie '118 Wochen alt' enthalten sind, nutze 'Alter in Wochen aufsteigend'.") Picker("Mehrfach-Versionen", selection: $settings.duplicateClipSelection) { ForEach(DuplicateClipSelection.allCases) { selection in Text(selection.rawValue).tag(selection) } } .help("Nur beim neuen Import: 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 { Text("Hardware-Bitrate") Spacer() Text("\(settings.hardwareBitrateMbps) Mbit/s") .foregroundStyle(.secondary) } } .help("Bitrate für Hardware-Encoding mit VideoToolbox. Höher bedeutet bessere Qualität und größere Dateien.") } Toggle("Audio entfernen", isOn: $settings.removeAudio) .help("Entfernt Tonspuren aus Zwischenclips und finalem Video. Für GrowthLapse normalerweise empfohlen.") Toggle("Segment aus der Mitte nehmen", isOn: $settings.takeMiddleSegment) .help("Wählt den Ausschnitt aus der Videomitte. Start-Offset verschiebt diesen Mittelpunkt.") Toggle("Zwischenclips behalten", isOn: $settings.keepIntermediateClips) .help("Behält nach erfolgreichem Rendern zusätzliche Zwischenstufen zur Kontrolle. Der Cache für die Nachbearbeitung wird separat eingestellt.") Toggle("Render-Cache für Nachbearbeitung behalten", isOn: $settings.keepRenderCacheForReview) .help("Behält die normalisierten clip_*.mp4 Dateien und eine Projektdatei neben dem Output. So können einzelne Segmentfenster später angepasst und schneller neu gerendert werden.") Toggle("Clipnummer einblenden", isOn: $settings.burnInClipNumbers) .help("Brennt die laufende Clipnummer unten links ins Video ein. Praktisch, um im fertigen Film problematische Clips schnell in der Nachbearbeitung zu finden.") } .disabled(processor.state.isRunning) Section("Stabilisierung") { Toggle("Stabilisierung aktivieren", isOn: $settings.stabilizationEnabled) .help("Stabilisiert globale Kamerabewegung. Das ist getrennt von Face Size Normalization und folgt nicht gezielt Gesichtern.") Picker("Methode", selection: $settings.stabilizationMethod) { ForEach(StabilizationMethod.allCases) { method in Text(method.rawValue).tag(method) } } .help("VidStab ist bevorzugt und nutzt Analyse plus Transform. Deshake ist schnellerer Fallback ohne getrennten Analyse-Pass.") .disabled(!settings.stabilizationEnabled) Picker("Stärke", selection: $settings.stabilizationStrength) { ForEach(StabilizationStrength.allCases) { strength in Text(strength.rawValue).tag(strength) } } .help("Stärkere Werte glätten mehr Bewegung. GrowthLapse hält den Sicherheits-Zoom dabei konstant, damit das Bild nicht pumpt.") .disabled(!settings.stabilizationEnabled) Toggle("Auf Hintergrundpunkt fokussieren", isOn: $settings.stabilizationAnchorEnabled) .help("Beschränkt nur die VidStab-Analyse auf einen Bereich um einen festen Hintergrundpunkt. Der Transform bleibt auf dem vollen Bild.") .disabled(!settings.stabilizationEnabled || settings.stabilizationMethod != .vidstab) if settings.stabilizationAnchorEnabled { percentSlider("Punkt X", value: $settings.stabilizationAnchorX, range: 0...1, help: "Horizontale Position des festen Hintergrundpunkts im Bild. 0% links, 100% rechts.") percentSlider("Punkt Y", value: $settings.stabilizationAnchorY, range: 0...1, help: "Vertikale Position des festen Hintergrundpunkts im Bild. 0% oben, 100% unten.") percentSlider("Analysebereich", value: $settings.stabilizationAnchorSize, range: 0.15...0.80, help: "Größe des Bereichs um den Hintergrundpunkt, den VidStab zur Bewegungsanalyse verwendet.") } } .disabled(processor.state.isRunning) Section("Gesicht") { Toggle("Bestes Segment automatisch finden", isOn: $settings.bestSegmentDetectionEnabled) .help("Analysiert das Originalvideo vor dem Schneiden und sucht das Fenster, in dem Gesicht und beide Augen am besten sichtbar sind. Wenn nichts Brauchbares gefunden wird, nutzt GrowthLapse die normale Mitte/Start-Offset-Logik.") Toggle("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled) .help("Verfolgt Gesicht und Augen mit Apple Vision und bewegt den Bildausschnitt geglättet mit. Das Bild bleibt immer vollflächig; fehlt Rand für die Zielgröße oder Drehung, hat ein randloses Bild Vorrang.") HStack { Text("Ziel-Gesichtshöhe") Spacer() Text("\(Int(settings.targetFaceHeightRatio * 100)) %") .foregroundStyle(.secondary) } .help("Angestrebte Gesichtshöhe relativ zur Ausgabehöhe. 28% ist ein guter Startwert für Portrait-/GrowthLapse-Material.") Slider(value: $settings.targetFaceHeightRatio, in: 0.18...0.40, step: 0.01) .help("Gewünschte Gesichtshöhe im Ergebnis: 26 % entspricht etwa einem Viertel der Bildhöhe. Bildränder können die Anpassung begrenzen.") .disabled(!settings.faceNormalizationEnabled) } .disabled(processor.state.isRunning) Section { HStack { Button("Render starten") { processor.start(settings: settings) } .help("Startet die komplette Pipeline: Segment, Stabilisierung, Face-Crop, Normalisierung und Übergänge.") .keyboardShortcut(.return, modifiers: [.command]) .disabled(processor.state.isRunning) Button("STOP") { processor.cancel() } .help("Stoppt den laufenden Render einschließlich Bildanalyse. Das bisherige Ergebnis und dessen Cache bleiben erhalten.") .tint(.red) .disabled(!processor.state.isRunning) Button("Defaults") { settings = SettingsStore.reset() } .help("Setzt alle Einstellungen und gespeicherten Pfade auf Werkseinstellungen zurück.") .disabled(processor.state.isRunning) } if case .finished(let url) = processor.state { Button("Im Finder anzeigen") { processor.showInFinder(url: url) } .help("Zeigt die fertige Output-Datei im Finder an.") } } } .formStyle(.grouped) .frame(minWidth: 390, idealWidth: 430, maxWidth: 500) .padding() VStack(alignment: .leading, spacing: 14) { statusHeader ProgressView(value: processor.progress) .progressViewStyle(.linear) if let project = processor.project { ResizableVerticalStack( topHeight: $reviewPanelHeight, minTopHeight: 340, minBottomHeight: 160 ) { projectReviewPanel(project) .disabled(processor.state.isRunning) } bottom: { logView } } else { logView } } .padding() .frame(minWidth: 520) } .alert("GrowthLapse", isPresented: errorBinding) { Button("OK", role: .cancel) {} } message: { if case .failed(let message) = processor.state { Text(message) } } .onChange(of: settings) { newSettings in SettingsStore.save(newSettings) } .onChange(of: processor.project) { project in guard let project else { selectedClipID = nil return } let orderedClips = orderedProjectClips(project.clips) if selectedClipID == nil || !orderedClips.contains(where: { $0.id == selectedClipID }) { selectedClipID = orderedClips.first?.id } } .onChange(of: settings.sortMode) { sortMode in processor.reorderProject(sortMode: sortMode) } .sheet(isPresented: $showingResult) { if let project = processor.project { OutputPreviewView(url: project.outputFileURL) } } .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 { VStack(alignment: .leading, spacing: 6) { HStack { Text("Render-Status") .font(.title2.bold()) Spacer() Text("\(Int(processor.progress * 100)) %") .font(.headline.monospacedDigit()) .foregroundStyle(.secondary) } Text(processor.progressText) .foregroundStyle(.secondary) if let warning = processor.warningText { Text(warning) .font(.callout) .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) } } } private var logView: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 4) { ForEach(Array(processor.logs.enumerated()), id: \.offset) { index, line in Text(line) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .id(index) } } .padding(10) } .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor)) ) .onChange(of: processor.logs.count) { count in if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) } } } } private func projectReviewPanel(_ project: GrowthLapseProject) -> some View { let orderedClips = orderedProjectClips(project.clips) let selectedClip = orderedClips.first { $0.id == selectedClipID } ?? orderedClips.first return VStack(alignment: .leading, spacing: 10) { HStack { Text("\(project.clips.count) Clips") .font(.headline) Spacer() Text("Ergebnis ca. \(formattedNumber(Double(project.clips.count) * settings.targetClipLength - Double(max(0, project.clips.count - 1)) * settings.transitionLength)) s") .font(.caption) .foregroundStyle(.secondary) Button("Ergebnis ansehen") { showingResult = true } .help("Öffnet das zuletzt gespeicherte Ergebnisvideo in der App. Noch nicht gerenderte Clipänderungen sind darin nicht enthalten.") .disabled(!FileManager.default.fileExists(atPath: project.outputFilePath)) } HStack { Button("Änderungen neu rendern") { processor.rerenderProject(settings: settings) } .disabled(processor.state.isRunning) .help("Übernimmt Ausgabe-Einstellungen und erhält manuelle Ausschnitte. Für neue Ausschnittlänge oder automatische Segmentwahl: Segmente neu planen.") Button("Segmente neu planen") { processor.rerenderProject(settings: settings, replanSegments: true) } .help("Berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu. Ersetzt manuelle Segmentstarts nach erfolgreichem Rendern.") Spacer(minLength: 0) Menu("Projekt") { Button("Projekt speichern") { processor.saveCurrentProject() } .help("Speichert Ausschnitte und Varianten. Render-Einstellungen werden beim erfolgreichen Rendern ins Projekt übernommen.") Button("Render-Cache löschen") { processor.confirmProjectAndDeleteCache() } .help("Gibt den Speicherplatz der Zwischenclips frei. Video, Projekt und Quellen bleiben erhalten. Beim nächsten Rendern werden die Zwischenclips neu erstellt.") } .fixedSize() .help("Projekt speichern oder den zugehörigen Cache löschen. Video und Quelldateien bleiben erhalten.") } HStack(alignment: .top, spacing: 12) { List(orderedClips, selection: $selectedClipID) { clip in VStack(alignment: .leading, spacing: 2) { HStack { 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 { Text("geändert") .font(.caption) .foregroundStyle(.orange) } } Text("Start \(formattedNumber(clip.segmentStart)) s, Länge \(formattedNumber(clip.segmentLength)) s") .font(.caption) .foregroundStyle(.secondary) } .contentShape(Rectangle()) .onTapGesture { selectedClipID = clip.id } .onTapGesture(count: 2) { selectedClipID = clip.id if clip.availableVariants.count > 1 { variantPickerClip = clip } } } .frame(minWidth: 180, idealWidth: 240, maxWidth: 300, maxHeight: .infinity) if let clip = selectedClip { clipEditor(clip) } else { Text("Kein Clip ausgewählt") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } .frame(maxHeight: .infinity, alignment: .top) } .padding(10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor)) ) } private func clipEditor(_ clip: GrowthLapseProjectClip) -> some View { let liveClip = currentClip(id: clip.id) ?? clip let maxStart = max(0, liveClip.duration - liveClip.segmentLength) let startBinding = Binding( get: { currentClip(id: clip.id)?.segmentStart ?? clip.segmentStart }, set: { processor.updateClip(clip, segmentStart: $0) } ) return GeometryReader { proxy in let previewHeight = min(max(150, proxy.size.height - 160), 420) ScrollView { VStack(alignment: .leading, spacing: 8) { ClipPreviewView(clip: liveClip) .id(liveClip.sourcePath) .frame(height: previewHeight) .clipShape(RoundedRectangle(cornerRadius: 6)) Text(liveClip.displayName) .font(.subheadline.weight(.semibold)) .lineLimit(2) HStack { Text("Segmentstart") Spacer() Text("\(formattedNumber(startBinding.wrappedValue)) s / max \(formattedNumber(maxStart)) s") .font(.body.monospacedDigit()) .foregroundStyle(.secondary) } SegmentTimelineView(clip: liveClip) .frame(height: 42) .help("Blau ist das verwendete Segmentfenster. Der markierte rechte Bereich kann nicht als Startpunkt gewählt werden, weil das Segment sonst über das Videoende hinauslaufen würde.") if maxStart > 0 { Slider(value: startBinding, in: 0...maxStart, step: 0.1) .help("Verschiebt den Ausschnitt in Schritten von 0,1 Sekunden. Die Änderung wird gespeichert und mit ‚Änderungen neu rendern‘ ins Video übernommen.") } else { Slider(value: .constant(0), in: 0...1) .disabled(true) .help("Der Ausschnitt umfasst bereits die ganze Quelle und kann nicht verschoben werden.") } HStack { Button("-1 s") { processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 1) } .help("Verschiebt den Ausschnitt um eine Sekunde nach vorn zum Videoanfang. Die Videogrenzen werden eingehalten.") Button("-0,5 s") { processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 0.5) } .help("Verschiebt den Ausschnitt um eine halbe Sekunde zum Videoanfang. Die Videogrenzen werden eingehalten.") Button("Zur Mitte") { processor.updateClip(clip, segmentStart: max(0, (liveClip.duration - liveClip.segmentLength) / 2 + settings.startOffset)) } .help("Setzt den Ausschnitt in die Videomitte und berücksichtigt den eingestellten Start-Offset.") Button("+0,5 s") { processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 0.5) } .help("Verschiebt den Ausschnitt um eine halbe Sekunde zum Videoende. Die Videogrenzen werden eingehalten.") Button("+1 s") { processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1) } .help("Verschiebt den Ausschnitt um eine Sekunde zum Videoende. Die Videogrenzen werden eingehalten.") } .controlSize(.small) .disabled(processor.state.isRunning || maxStart <= 0) } .frame(maxWidth: .infinity, alignment: .topLeading) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func currentClip(id: UUID) -> GrowthLapseProjectClip? { processor.project?.clips.first { $0.id == id } } private func orderedProjectClips(_ clips: [GrowthLapseProjectClip]) -> [GrowthLapseProjectClip] { clips.sorted { left, right in switch settings.sortMode { case .filenameAscending: return left.displayName.localizedStandardCompare(right.displayName) == .orderedAscending case .ageWeeksAscending: if left.ageWeeks != right.ageWeeks { return (left.ageWeeks ?? Int.max) < (right.ageWeeks ?? Int.max) } return left.displayName.localizedStandardCompare(right.displayName) == .orderedAscending case .modificationDateAscending: return left.index < right.index } } .enumerated() .map { offset, clip in var updated = clip updated.index = offset + 1 return updated } } private var errorBinding: Binding { Binding( get: { if case .failed = processor.state { return true } return false }, set: { newValue in if !newValue, case .failed = processor.state { processor.state = .idle } } ) } private func pickerRow(title: String, value: String, buttonTitle: String, help: String? = nil, action: @escaping () -> Void) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { Text(title) Spacer() Button(buttonTitle, action: action) } Text(value) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) .truncationMode(.middle) } .help(help ?? "") } private func doubleStepper( _ title: String, value: Binding, range: ClosedRange, step: Double, suffix: String, help: String ) -> some View { Stepper(value: value, in: range, step: step) { HStack { Text(title) Spacer() Text("\(formattedNumber(value.wrappedValue)) \(suffix)") .font(.body.monospacedDigit()) .foregroundStyle(.secondary) } } .help(help) } private func percentSlider(_ title: String, value: Binding, range: ClosedRange, help: String) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(title) Spacer() Text("\(Int(value.wrappedValue * 100)) %") .font(.body.monospacedDigit()) .foregroundStyle(.secondary) } Slider(value: value, in: range, step: 0.01) } .help(help) } private func formattedNumber(_ 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 func selectInputFolder() { let panel = NSOpenPanel() panel.title = "Input-Ordner auswählen" panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false if panel.runModal() == .OK { settings.inputFolder = panel.url } } private func selectOutputFile() { let panel = NSSavePanel() panel.title = "Output-Datei auswählen" panel.allowedContentTypes = [.mpeg4Movie] panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "GrowthLapse.mp4" if panel.runModal() == .OK, let url = panel.url { settings.outputFile = url.pathExtension.isEmpty ? url.appendingPathExtension("mp4") : url } } private func selectProjectFile() { let panel = NSOpenPanel() panel.title = "GrowthLapse-Projekt laden" panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.allowedContentTypes = [.json] panel.directoryURL = settings.outputFile?.deletingLastPathComponent() if panel.runModal() == .OK, let url = panel.url, let project = processor.loadProject(from: url) { if let saved = project.settings { let ffmpeg = settings.ffmpegExecutable let ffprobe = settings.ffprobeExecutable settings = saved.renderSettings settings.ffmpegExecutable = ffmpeg settings.ffprobeExecutable = ffprobe } settings.outputFile = project.outputFileURL settings.inputFolder = project.clips.first?.sourceURL.deletingLastPathComponent() selectedClipID = project.clips.first?.id } } private func selectExecutable(title: String, onSelect: (URL) -> Void) { let panel = NSOpenPanel() panel.title = title panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.treatsFilePackagesAsDirectories = true if panel.runModal() == .OK, let url = panel.url { onSelect(url) } } } private struct ClipPreviewView: View { let clip: GrowthLapseProjectClip @State private var player = AVPlayer() var body: some View { VideoPlayer(player: player) .onAppear { loadClip() } .onChange(of: clip.id) { _ in loadClip() } .onChange(of: clip.sourcePath) { _ in loadClip() } .onChange(of: clip.segmentStart) { _ in seekToSegmentStart() } } private func loadClip() { player.replaceCurrentItem(with: AVPlayerItem(url: clip.sourceURL)) seekToSegmentStart() } private func seekToSegmentStart() { let time = CMTime(seconds: clip.segmentStart, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) } } private struct ResizableVerticalStack: View { @Binding var topHeight: CGFloat let minTopHeight: CGFloat let minBottomHeight: CGFloat @ViewBuilder let top: () -> Top @ViewBuilder let bottom: () -> Bottom @State private var dragStartHeight: CGFloat? var body: some View { GeometryReader { proxy in let dividerHeight: CGFloat = 14 let availableHeight = max(0, proxy.size.height - dividerHeight) let maxTopHeight = max(minTopHeight, availableHeight - minBottomHeight) let clampedTopHeight = min(max(topHeight, minTopHeight), maxTopHeight) VStack(spacing: 0) { top() .frame(height: clampedTopHeight, alignment: .top) .clipped() SplitHandle() .frame(height: dividerHeight) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in if dragStartHeight == nil { dragStartHeight = clampedTopHeight } let proposed = (dragStartHeight ?? clampedTopHeight) + value.translation.height topHeight = min(max(proposed, minTopHeight), maxTopHeight) } .onEnded { _ in dragStartHeight = nil } ) .help("Ziehen, um Clip-Nachbearbeitung und Log größer oder kleiner zu machen.") bottom() .frame(height: max(minBottomHeight, availableHeight - clampedTopHeight), alignment: .top) .clipped() } .onAppear { topHeight = clampedTopHeight } .onChange(of: proxy.size.height) { _ in topHeight = clampedTopHeight } } } } private struct SplitHandle: View { @State private var isHovering = false var body: some View { ZStack { Rectangle() .fill(Color(nsColor: .windowBackgroundColor)) RoundedRectangle(cornerRadius: 3) .fill(isHovering ? Color.accentColor.opacity(0.75) : Color(nsColor: .separatorColor)) .frame(width: 84, height: 5) } .overlay(alignment: .top) { Rectangle() .fill(Color(nsColor: .separatorColor).opacity(0.45)) .frame(height: 1) } .overlay(alignment: .bottom) { Rectangle() .fill(Color(nsColor: .separatorColor).opacity(0.45)) .frame(height: 1) } .onHover { hovering in isHovering = hovering if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } } } } private struct SegmentTimelineView: View { let clip: GrowthLapseProjectClip var body: some View { VStack(alignment: .leading, spacing: 5) { GeometryReader { proxy in let width = max(1, proxy.size.width) let duration = max(0.001, clip.duration) let maxStart = max(0, clip.duration - clip.segmentLength) let startX = width * CGFloat(clamp(clip.segmentStart / duration)) let segmentWidth = max(3, width * CGFloat(clamp(clip.segmentLength / duration))) let invalidStartX = width * CGFloat(clamp(maxStart / duration)) let invalidWidth = max(0, width - invalidStartX) ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .separatorColor).opacity(0.35)) Rectangle() .fill(Color.orange.opacity(0.16)) .frame(width: invalidWidth) .offset(x: invalidStartX) RoundedRectangle(cornerRadius: 4) .fill(Color.accentColor.opacity(0.75)) .frame(width: min(segmentWidth, width - startX)) .offset(x: startX) Rectangle() .fill(Color.accentColor) .frame(width: 2) .offset(x: startX) } } .frame(height: 14) HStack { Text("0 s") Spacer() Text("Segment \(format(clip.segmentStart))-\(format(min(clip.duration, clip.segmentStart + clip.segmentLength))) s") Spacer() Text("\(format(clip.duration)) s") } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } } private func clamp(_ value: Double) -> Double { min(1, max(0, value)) } private func format(_ value: Double) -> String { let formatter = NumberFormatter() formatter.locale = .current formatter.minimumFractionDigits = value.rounded() == value ? 0 : 1 formatter.maximumFractionDigits = 1 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() } .help("Schließt die Variantenwahl, ohne eine andere Variante zu übernehmen.") } if let selectedVariant { VariantPreviewView(variant: selectedVariant) .id(selectedVariant.sourcePath) .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) .help("Übernimmt diese Aufnahme für den Clip. Anschließend neu rendern, damit sie im Ergebnis erscheint.") } .contentShape(Rectangle()) .onTapGesture { selectedVariantID = variant.id } .onTapGesture(count: 2) { if variant.sourcePath != clip.sourcePath { onSelect(variant) } } } } .padding() .onAppear { selectedVariantID = currentVariantID() } .onChange(of: clip.id) { _ in selectedVariantID = currentVariantID() } .onChange(of: clip.sourcePath) { _ in selectedVariantID = currentVariantID() } } private func currentVariantID() -> UUID? { clip.availableVariants.first(where: { $0.sourcePath == clip.sourcePath })?.id ?? clip.availableVariants.first?.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() } .onChange(of: variant.sourcePath) { _ in loadVariant() } } private func loadVariant() { player.replaceCurrentItem(with: AVPlayerItem(url: variant.sourceURL)) player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) } } private struct OutputPreviewView: View { let url: URL @Environment(\.dismiss) private var dismiss @State private var player: AVPlayer init(url: URL) { self.url = url _player = State(initialValue: AVPlayer(url: url)) } var body: some View { VStack(spacing: 12) { HStack { Text(url.lastPathComponent).font(.headline) Spacer() Button("Schließen") { dismiss() } .help("Schließt die Ergebnisvorschau und stoppt ihre Wiedergabe. Auch mit Escape möglich.") .keyboardShortcut(.cancelAction) } VideoPlayer(player: player) } .padding() .frame(minWidth: 640, idealWidth: 800, minHeight: 440, idealHeight: 540) .onDisappear { player.pause() } } }