Initial GrowthLapse app
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
.DS_Store
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.m4v
|
||||||
|
*.avi
|
||||||
|
*.mkv
|
||||||
|
*.webm
|
||||||
|
*.trf
|
||||||
|
*.growthlapse-report.txt
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "GrowthLapse",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "GrowthLapse", targets: ["GrowthLapse"])
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "GrowthLapse",
|
||||||
|
path: "Sources/GrowthLapse"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var processor = VideoProcessor()
|
||||||
|
@State private var settings = SettingsStore.load()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.disabled(processor.state.isRunning)
|
||||||
|
|
||||||
|
Section("Parameter") {
|
||||||
|
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: nach Dateiname oder Änderungsdatum.")
|
||||||
|
|
||||||
|
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 segment_*, stabilized_*, clip_* und xfade_* Dateien zur Kontrolle. Sonst wird der Temp-Ordner nach Erfolg gelöscht.")
|
||||||
|
}
|
||||||
|
.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, können aber stärker zoomen/croppen oder schwimmender wirken.")
|
||||||
|
.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("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled)
|
||||||
|
.help("Analysiert mehrere Frames mit Apple Vision und berechnet einen statischen Crop pro Clip. Kein Frame-by-frame Tracking.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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("Bricht den laufenden ffmpeg-Prozess ab und räumt temporäre Dateien auf.")
|
||||||
|
.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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ?? "Joshua_GrowthLapse.mp4"
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
settings.outputFile = url.pathExtension.isEmpty ? url.appendingPathExtension("mp4") : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class LockedOutputBuffer: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storage = ""
|
||||||
|
|
||||||
|
func append(_ value: String) {
|
||||||
|
lock.lock()
|
||||||
|
storage += value
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func value() -> String {
|
||||||
|
lock.lock()
|
||||||
|
let result = storage
|
||||||
|
lock.unlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProcessResult {
|
||||||
|
let exitCode: Int32
|
||||||
|
let output: String
|
||||||
|
}
|
||||||
|
|
||||||
|
actor FFmpegService {
|
||||||
|
private var runningProcess: Process?
|
||||||
|
|
||||||
|
func findExecutable(named name: String) -> URL? {
|
||||||
|
let candidates = [
|
||||||
|
"/opt/homebrew/bin/\(name)",
|
||||||
|
"/usr/local/bin/\(name)",
|
||||||
|
"/usr/bin/\(name)"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
|
||||||
|
return URL(fileURLWithPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let environmentPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||||
|
for directory in environmentPath.split(separator: ":") {
|
||||||
|
let path = String(directory) + "/\(name)"
|
||||||
|
if FileManager.default.isExecutableFile(atPath: path) {
|
||||||
|
return URL(fileURLWithPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTools(ffmpegOverride: URL? = nil, ffprobeOverride: URL? = nil) throws -> (ffmpeg: URL, ffprobe: URL) {
|
||||||
|
let ffmpeg: URL?
|
||||||
|
if let ffmpegOverride {
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: ffmpegOverride.path) else {
|
||||||
|
throw VideoProcessingError.invalidToolPath("ffmpeg", ffmpegOverride.path)
|
||||||
|
}
|
||||||
|
ffmpeg = ffmpegOverride
|
||||||
|
} else {
|
||||||
|
ffmpeg = findExecutable(named: "ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ffprobe: URL?
|
||||||
|
if let ffprobeOverride {
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: ffprobeOverride.path) else {
|
||||||
|
throw VideoProcessingError.invalidToolPath("ffprobe", ffprobeOverride.path)
|
||||||
|
}
|
||||||
|
ffprobe = ffprobeOverride
|
||||||
|
} else {
|
||||||
|
ffprobe = findExecutable(named: "ffprobe")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let ffmpeg else {
|
||||||
|
throw VideoProcessingError.missingTool("ffmpeg")
|
||||||
|
}
|
||||||
|
guard let ffprobe else {
|
||||||
|
throw VideoProcessingError.missingTool("ffprobe")
|
||||||
|
}
|
||||||
|
return (ffmpeg, ffprobe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
runningProcess?.terminate()
|
||||||
|
runningProcess = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(
|
||||||
|
executable: URL,
|
||||||
|
arguments: [String],
|
||||||
|
currentDirectory: URL? = nil,
|
||||||
|
onOutput: @escaping @Sendable (String) -> Void = { _ in }
|
||||||
|
) async throws -> ProcessResult {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
return try await withTaskCancellationHandler {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = executable
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = currentDirectory
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
let nullInput = FileHandle(forReadingAtPath: "/dev/null")
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
process.standardInput = nullInput
|
||||||
|
|
||||||
|
let outputBuffer = LockedOutputBuffer()
|
||||||
|
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outputBuffer.append(chunk)
|
||||||
|
onOutput(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.terminationHandler = { finishedProcess in
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
nullInput?.closeFile()
|
||||||
|
let output = outputBuffer.value()
|
||||||
|
Task {
|
||||||
|
await self.clearRunningProcess(process)
|
||||||
|
}
|
||||||
|
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
runningProcess = process
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
nullInput?.closeFile()
|
||||||
|
runningProcess = nil
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} onCancel: {
|
||||||
|
Task {
|
||||||
|
await self.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableFilters(ffmpeg: URL) async throws -> FFmpegFilterAvailability {
|
||||||
|
let result = try await run(
|
||||||
|
executable: ffmpeg,
|
||||||
|
arguments: ["-hide_banner", "-filters"]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard result.exitCode == 0 else {
|
||||||
|
throw VideoProcessingError.ffmpegFailed("Filterprüfung", result.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FFmpegFilterAvailability(
|
||||||
|
hasVidstabDetect: result.output.contains("vidstabdetect"),
|
||||||
|
hasVidstabTransform: result.output.contains("vidstabtransform"),
|
||||||
|
hasDeshake: result.output.contains("deshake")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearRunningProcess(_ process: Process) {
|
||||||
|
if runningProcess === process {
|
||||||
|
runningProcess = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VideoProcessingError: LocalizedError {
|
||||||
|
case missingTool(String)
|
||||||
|
case invalidToolPath(String, String)
|
||||||
|
case invalidSettings(String)
|
||||||
|
case noVideosFound
|
||||||
|
case ffprobeFailed(URL, String)
|
||||||
|
case ffmpegFailed(String, String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingTool(let tool):
|
||||||
|
return "\(tool) wurde nicht gefunden. Bitte installiere ffmpeg lokal, zum Beispiel mit Homebrew: brew install ffmpeg"
|
||||||
|
case .invalidToolPath(let tool, let path):
|
||||||
|
return "Der angegebene \(tool)-Pfad ist nicht ausführbar:\n\(path)\n\nBitte wähle die ausführbare Datei selbst aus, nicht nur den Ordner."
|
||||||
|
case .invalidSettings(let message):
|
||||||
|
return message
|
||||||
|
case .noVideosFound:
|
||||||
|
return "Im gewählten Ordner wurden keine unterstützten Videodateien gefunden."
|
||||||
|
case .ffprobeFailed(let url, let output):
|
||||||
|
let details = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if details.isEmpty {
|
||||||
|
return "Die Dauer von \(url.lastPathComponent) konnte nicht gelesen werden."
|
||||||
|
}
|
||||||
|
return "Die Dauer von \(url.lastPathComponent) konnte nicht gelesen werden.\n\(shortErrorDetails(details))"
|
||||||
|
case .ffmpegFailed(let step, let output):
|
||||||
|
let details = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if details.isEmpty {
|
||||||
|
return "ffmpeg ist bei Schritt \"\(step)\" fehlgeschlagen. Details stehen im Log."
|
||||||
|
}
|
||||||
|
return "ffmpeg ist bei Schritt \"\(step)\" fehlgeschlagen.\n\(shortErrorDetails(details))\n\nDetails stehen im Log."
|
||||||
|
case .cancelled:
|
||||||
|
return "Rendern wurde abgebrochen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortErrorDetails(_ output: String) -> String {
|
||||||
|
let lines = output
|
||||||
|
.replacingOccurrences(of: "\r", with: "\n")
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map(String.init)
|
||||||
|
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
|
||||||
|
let importantLines = lines.filter { line in
|
||||||
|
let lower = line.lowercased()
|
||||||
|
return lower.contains("error")
|
||||||
|
|| lower.contains("failed")
|
||||||
|
|| lower.contains("invalid")
|
||||||
|
|| lower.contains("not found")
|
||||||
|
|| lower.contains("no such")
|
||||||
|
|| lower.contains("unable")
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected: [String]
|
||||||
|
if importantLines.isEmpty {
|
||||||
|
selected = Array(lines.suffix(8))
|
||||||
|
} else {
|
||||||
|
selected = Array((importantLines.suffix(4) + lines.suffix(4)).suffix(8))
|
||||||
|
}
|
||||||
|
let text = selected.joined(separator: "\n")
|
||||||
|
|
||||||
|
if text.count <= 700 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = text.index(text.startIndex, offsetBy: 700)
|
||||||
|
return String(text[..<endIndex]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct GrowthLapseApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.frame(minWidth: 980, minHeight: 680)
|
||||||
|
}
|
||||||
|
.windowStyle(.titleBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum OutputFormat: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case landscape1920x1080 = "Landscape 1920x1080 SDR"
|
||||||
|
case portrait1080x1920 = "Portrait 1080x1920"
|
||||||
|
case keepOriginal = "Originalformat beibehalten"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortMode: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case filenameAscending = "Dateiname aufsteigend"
|
||||||
|
case modificationDateAscending = "Änderungsdatum aufsteigend"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VideoEncoder: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case x264 = "CPU: libx264"
|
||||||
|
case videoToolbox = "Hardware: VideoToolbox H.264"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StabilizationMethod: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case vidstab = "VidStab (empfohlen)"
|
||||||
|
case deshake = "Deshake (Fallback)"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StabilizationStrength: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case light = "Leicht"
|
||||||
|
case medium = "Mittel"
|
||||||
|
case strong = "Stark"
|
||||||
|
case veryStrong = "Sehr stark"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var shakiness: Int {
|
||||||
|
switch self {
|
||||||
|
case .light: return 4
|
||||||
|
case .medium: return 5
|
||||||
|
case .strong: return 8
|
||||||
|
case .veryStrong: return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accuracy: Int {
|
||||||
|
switch self {
|
||||||
|
case .light: return 10
|
||||||
|
case .medium, .strong, .veryStrong: return 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var smoothing: Int {
|
||||||
|
switch self {
|
||||||
|
case .light: return 6
|
||||||
|
case .medium: return 10
|
||||||
|
case .strong: return 16
|
||||||
|
case .veryStrong: return 28
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxShift: Int {
|
||||||
|
switch self {
|
||||||
|
case .light: return 24
|
||||||
|
case .medium: return 48
|
||||||
|
case .strong: return 96
|
||||||
|
case .veryStrong: return 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxAngle: Double {
|
||||||
|
switch self {
|
||||||
|
case .light: return 0.03
|
||||||
|
case .medium: return 0.06
|
||||||
|
case .strong: return 0.12
|
||||||
|
case .veryStrong: return 0.20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deshakeSearch: Int {
|
||||||
|
switch self {
|
||||||
|
case .light: return 48
|
||||||
|
case .medium: return 64
|
||||||
|
case .strong: return 96
|
||||||
|
case .veryStrong: return 128
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FFmpegFilterAvailability: Equatable {
|
||||||
|
let hasVidstabDetect: Bool
|
||||||
|
let hasVidstabTransform: Bool
|
||||||
|
let hasDeshake: Bool
|
||||||
|
|
||||||
|
var hasVidstab: Bool {
|
||||||
|
hasVidstabDetect && hasVidstabTransform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RenderSettings: Equatable {
|
||||||
|
var inputFolder: URL?
|
||||||
|
var outputFile: URL?
|
||||||
|
var ffmpegExecutable: URL?
|
||||||
|
var ffprobeExecutable: URL?
|
||||||
|
var segmentLength: Double = 20
|
||||||
|
var targetClipLength: Double = 3
|
||||||
|
var transitionLength: Double = 0.5
|
||||||
|
var fps: Int = 30
|
||||||
|
var outputFormat: OutputFormat = .landscape1920x1080
|
||||||
|
var sortMode: SortMode = .filenameAscending
|
||||||
|
var removeAudio: Bool = true
|
||||||
|
var takeMiddleSegment: Bool = true
|
||||||
|
var startOffset: Double = 0
|
||||||
|
var keepIntermediateClips: Bool = false
|
||||||
|
var videoEncoder: VideoEncoder = .videoToolbox
|
||||||
|
var crf: Int = 20
|
||||||
|
var hardwareBitrateMbps: Int = 12
|
||||||
|
var stabilizationEnabled: Bool = false
|
||||||
|
var stabilizationMethod: StabilizationMethod = .vidstab
|
||||||
|
var stabilizationStrength: StabilizationStrength = .medium
|
||||||
|
var stabilizationAnchorEnabled: Bool = false
|
||||||
|
var stabilizationAnchorX: Double = 0.5
|
||||||
|
var stabilizationAnchorY: Double = 0.5
|
||||||
|
var stabilizationAnchorSize: Double = 0.35
|
||||||
|
var faceNormalizationEnabled: Bool = false
|
||||||
|
var targetFaceHeightRatio: Double = 0.28
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PersistedRenderSettings: Codable {
|
||||||
|
var inputFolderPath: String?
|
||||||
|
var outputFilePath: String?
|
||||||
|
var ffmpegExecutablePath: String?
|
||||||
|
var ffprobeExecutablePath: String?
|
||||||
|
var segmentLength: Double
|
||||||
|
var targetClipLength: Double
|
||||||
|
var transitionLength: Double
|
||||||
|
var fps: Int
|
||||||
|
var outputFormat: OutputFormat
|
||||||
|
var sortMode: SortMode
|
||||||
|
var removeAudio: Bool
|
||||||
|
var takeMiddleSegment: Bool
|
||||||
|
var startOffset: Double
|
||||||
|
var keepIntermediateClips: Bool
|
||||||
|
var videoEncoder: VideoEncoder
|
||||||
|
var crf: Int
|
||||||
|
var hardwareBitrateMbps: Int
|
||||||
|
var stabilizationEnabled: Bool
|
||||||
|
var stabilizationMethod: StabilizationMethod
|
||||||
|
var stabilizationStrength: StabilizationStrength
|
||||||
|
var stabilizationAnchorEnabled: Bool
|
||||||
|
var stabilizationAnchorX: Double
|
||||||
|
var stabilizationAnchorY: Double
|
||||||
|
var stabilizationAnchorSize: Double
|
||||||
|
var faceNormalizationEnabled: Bool
|
||||||
|
var targetFaceHeightRatio: Double
|
||||||
|
|
||||||
|
init(settings: RenderSettings) {
|
||||||
|
inputFolderPath = settings.inputFolder?.path
|
||||||
|
outputFilePath = settings.outputFile?.path
|
||||||
|
ffmpegExecutablePath = settings.ffmpegExecutable?.path
|
||||||
|
ffprobeExecutablePath = settings.ffprobeExecutable?.path
|
||||||
|
segmentLength = settings.segmentLength
|
||||||
|
targetClipLength = settings.targetClipLength
|
||||||
|
transitionLength = settings.transitionLength
|
||||||
|
fps = settings.fps
|
||||||
|
outputFormat = settings.outputFormat
|
||||||
|
sortMode = settings.sortMode
|
||||||
|
removeAudio = settings.removeAudio
|
||||||
|
takeMiddleSegment = settings.takeMiddleSegment
|
||||||
|
startOffset = settings.startOffset
|
||||||
|
keepIntermediateClips = settings.keepIntermediateClips
|
||||||
|
videoEncoder = settings.videoEncoder
|
||||||
|
crf = settings.crf
|
||||||
|
hardwareBitrateMbps = settings.hardwareBitrateMbps
|
||||||
|
stabilizationEnabled = settings.stabilizationEnabled
|
||||||
|
stabilizationMethod = settings.stabilizationMethod
|
||||||
|
stabilizationStrength = settings.stabilizationStrength
|
||||||
|
stabilizationAnchorEnabled = settings.stabilizationAnchorEnabled
|
||||||
|
stabilizationAnchorX = settings.stabilizationAnchorX
|
||||||
|
stabilizationAnchorY = settings.stabilizationAnchorY
|
||||||
|
stabilizationAnchorSize = settings.stabilizationAnchorSize
|
||||||
|
faceNormalizationEnabled = settings.faceNormalizationEnabled
|
||||||
|
targetFaceHeightRatio = settings.targetFaceHeightRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderSettings: RenderSettings {
|
||||||
|
RenderSettings(
|
||||||
|
inputFolder: inputFolderPath.map { URL(fileURLWithPath: $0) },
|
||||||
|
outputFile: outputFilePath.map { URL(fileURLWithPath: $0) },
|
||||||
|
ffmpegExecutable: ffmpegExecutablePath.map { URL(fileURLWithPath: $0) },
|
||||||
|
ffprobeExecutable: ffprobeExecutablePath.map { URL(fileURLWithPath: $0) },
|
||||||
|
segmentLength: segmentLength,
|
||||||
|
targetClipLength: targetClipLength,
|
||||||
|
transitionLength: transitionLength,
|
||||||
|
fps: fps,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
sortMode: sortMode,
|
||||||
|
removeAudio: removeAudio,
|
||||||
|
takeMiddleSegment: takeMiddleSegment,
|
||||||
|
startOffset: startOffset,
|
||||||
|
keepIntermediateClips: keepIntermediateClips,
|
||||||
|
videoEncoder: videoEncoder,
|
||||||
|
crf: crf,
|
||||||
|
hardwareBitrateMbps: hardwareBitrateMbps,
|
||||||
|
stabilizationEnabled: stabilizationEnabled,
|
||||||
|
stabilizationMethod: stabilizationMethod,
|
||||||
|
stabilizationStrength: stabilizationStrength,
|
||||||
|
stabilizationAnchorEnabled: stabilizationAnchorEnabled,
|
||||||
|
stabilizationAnchorX: stabilizationAnchorX,
|
||||||
|
stabilizationAnchorY: stabilizationAnchorY,
|
||||||
|
stabilizationAnchorSize: stabilizationAnchorSize,
|
||||||
|
faceNormalizationEnabled: faceNormalizationEnabled,
|
||||||
|
targetFaceHeightRatio: targetFaceHeightRatio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SettingsStore {
|
||||||
|
static func load() -> RenderSettings {
|
||||||
|
let url = settingsURL()
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let persisted = try? JSONDecoder().decode(PersistedRenderSettings.self, from: data) else {
|
||||||
|
return RenderSettings()
|
||||||
|
}
|
||||||
|
return persisted.renderSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ settings: RenderSettings) {
|
||||||
|
let url = settingsURL()
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
let data = try JSONEncoder.prettyPrinted.encode(PersistedRenderSettings(settings: settings))
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
} catch {
|
||||||
|
// Settings persistence must not block rendering or UI changes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() -> RenderSettings {
|
||||||
|
let defaults = RenderSettings()
|
||||||
|
save(defaults)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func settingsURL() -> URL {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||||
|
return base.appendingPathComponent("GrowthLapse", isDirectory: true).appendingPathComponent("settings.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension JSONEncoder {
|
||||||
|
static var prettyPrinted: JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoFile: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let url: URL
|
||||||
|
let duration: Double
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
url.lastPathComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoDimensions: Equatable {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FaceCrop: Equatable {
|
||||||
|
let x: Int
|
||||||
|
let y: Int
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
let faceHeightRatio: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RenderState: Equatable {
|
||||||
|
case idle
|
||||||
|
case running
|
||||||
|
case finished(URL)
|
||||||
|
case failed(String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
var isRunning: Bool {
|
||||||
|
if case .running = self {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user