1011 lines
45 KiB
Swift
1011 lines
45 KiB
Swift
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<Double>(
|
||
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<Bool> {
|
||
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<Double>,
|
||
range: ClosedRange<Double>,
|
||
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<Double>, range: ClosedRange<Double>, 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<Top: View, Bottom: View>: 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() }
|
||
}
|
||
}
|