Files
GrowthLapse/Sources/GrowthLapse/ContentView.swift
T

1011 lines
45 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }
}
}