Make rendering failure-safe and project editing reproducible

This commit is contained in:
Mikei386
2026-09-13 09:20:37 +02:00
parent c530fe025a
commit 73961f6afe
13 changed files with 1019 additions and 212 deletions
+21 -8
View File
@@ -38,7 +38,7 @@ Das Skript baut im Release-Modus für die Architektur des ausführenden Macs und
3. Ausschnittlänge, Ziellänge und Übergang festlegen. Standard: 8 Sekunden Quelle, 1,5 Sekunden pro Ergebnisclip, 0,6 Sekunden Überblendung und 30 FPS. Die Übergangslänge muss kleiner als die Ziellänge sein; 0 bedeutet einen harten Schnitt. 3. Ausschnittlänge, Ziellänge und Übergang festlegen. Standard: 8 Sekunden Quelle, 1,5 Sekunden pro Ergebnisclip, 0,6 Sekunden Überblendung und 30 FPS. Die Übergangslänge muss kleiner als die Ziellänge sein; 0 bedeutet einen harten Schnitt.
4. Ausgabeformat, Encoder und optionale Stabilisierung/Gesichtsausrichtung wählen. Unterstützt sind 1920×1080, 1080×1920 oder die Abmessungen des ersten Videos. Für H.264 stehen CPU (`libx264`) und VideoToolbox zur Auswahl. Audio wird standardmäßig entfernt. 4. Ausgabeformat, Encoder und optionale Stabilisierung/Gesichtsausrichtung wählen. Unterstützt sind 1920×1080, 1080×1920 oder die Abmessungen des ersten Videos. Für H.264 stehen CPU (`libx264`) und VideoToolbox zur Auswahl. Audio wird standardmäßig entfernt.
5. „Render starten“ wählen. Fortschritt und Log erscheinen in der App; „STOP“ bricht die Verarbeitung ab. 5. „Render starten“ wählen. Fortschritt und Log erscheinen in der App; „STOP“ bricht die Verarbeitung ab.
6. In der Clip-Nachbearbeitung Segmentstarts oder Varianten ändern und „Änderungen neu rendern“ wählen. „Projekt laden“ öffnet eine gespeicherte Projektdatei. 6. Mit „Ergebnis ansehen“ das fertige Video direkt in der App prüfen. In der Clip-Nachbearbeitung Segmentstarts oder Varianten ändern und „Änderungen neu rendern“ wählen. Diese Aktion erhält gewählte Ausschnitte. „Segmente neu planen“ berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu; manuelle Ausschnitte werden erst nach erfolgreichem Rendern ersetzt. Die globale Variantenwahl gilt für einen neuen Import; vorhandene Varianten werden pro Clip gewechselt. „Projekt laden“ stellt die gespeicherten Render-Einstellungen wieder her.
Die automatische Segmentwahl sucht nach geeigneten Gesicht-/Augenaufnahmen. Gesichtsausrichtung und Stabilisierung sind heuristisch und sollten anhand des Ergebnisses geprüft werden. Die automatische Segmentwahl sucht nach geeigneten Gesicht-/Augenaufnahmen. Gesichtsausrichtung und Stabilisierung sind heuristisch und sollten anhand des Ergebnisses geprüft werden.
@@ -49,12 +49,14 @@ Neben `Ergebnis.mp4` können folgende Dateien entstehen:
| Datei | Zweck | | Datei | Zweck |
| --- | --- | | --- | --- |
| `Ergebnis.growthlapse-project.json` | Quellen, Ausschnitte, Varianten und Cache-Verweise | | `Ergebnis.growthlapse-project.json` | Quellen, Ausschnitte, Varianten und Cache-Verweise |
| `Ergebnis.growthlapse-cache/` | Zwischenclips für die Nachbearbeitung | | `GrowthLapse-<UUID>.growthlapse-cache/` | Eigener Arbeitsbereich mit Zwischenclips und Eigentumsmarkierung |
| `Ergebnis.growthlapse-report.txt` | Render-Protokoll | | `Ergebnis.growthlapse-report.txt` | Render-Protokoll |
„Ergebnis bestätigen“ löscht den Projekt-Cache. Fehlende Zwischenclips werden beim erneuten Rendern neu erzeugt; die Quelldateien müssen weiterhin vorhanden sein. Ein neuer Render mit demselben Ausgabeziel setzt dessen Review-Cache zurück und kann die vorhandene Ausgabe überschreiben. Im Menü „Projekt“ löscht „Render-Cache löschen“ ausschließlich einen von der neuen Version markierten Cache. Historische, unmarkierte Cache-Verzeichnisse bleiben erhalten und können nach Prüfung im Finder entfernt werden. Fehlende Zwischenclips werden beim erneuten Rendern neu erzeugt; Quellen müssen weiterhin vorhanden sein.
Projekte verwenden absolute Dateipfade und sind daher nicht ohne Anpassung zwischen Rechnern verschiebbar. Die globalen Einstellungen liegen unter `~/Library/Application Support/GrowthLapse/settings.json`. „Defaults“ setzt sie zurück. Projektdateien ersetzen keine vollständige Sicherung der globalen Render-Einstellungen. Jeder Render schreibt in einen eigenen Arbeitsbereich und kopiert verwendbare Cache-Clips dorthin. Die vorhandene Ausgabe und ihr Cache bleiben während des Renders erhalten. Erst nach erfolgreichem Rendern werden Video und Projektdatei übernommen; bei einem Fehler während der Übernahme wird eine Wiederherstellung versucht. Das ist keine absturzsichere Transaktion über mehrere Dateien. Der alte, markierte Cache desselben Ausgabeziels wird erst nach Erfolg entfernt. „Speichern unter“ auf ein anderes Ausgabeziel erhält das ursprüngliche Projekt und dessen Cache.
Projekte enthalten Render-Einstellungen und absolute Dateipfade. Sie sind daher nicht ohne Anpassung zwischen Rechnern verschiebbar. Die lokalen Werkzeugpfade bleiben beim Laden unverändert. Ältere Projekte ohne gespeicherte Einstellungen zeigen einen Hinweis und verwenden die aktuellen Werte. Die globalen Einstellungen liegen unter `~/Library/Application Support/GrowthLapse/settings.json`; „Defaults“ setzt sie zurück.
## Entwicklung und Prüfung ## Entwicklung und Prüfung
@@ -64,7 +66,15 @@ Für die XCTest-Tests ist eine vollständige Xcode-Installation mit ausgewählte
swift test swift test
``` ```
Die Tests prüfen neutrale Startwerte, die JSON-Speicherung und die Kompatibilität älterer Einstellungen. Sie ersetzen keine Prüfung der Videoausgabe. Die XCTest-Tests prüfen neutrale Startwerte, JSON-Speicherung und die Kompatibilität älterer Einstellungen.
Für Integrationstests genügt die Command-Line-Tools-Umgebung mit FFmpeg und FFprobe:
```sh
bash scripts/integration-tests.sh
```
Dieser Test baut einen eigenen Test-Einstieg mit den produktiven Render-Klassen, erzeugt synthetische Videos und prüft unter anderem Speichern/Laden, Cache, Tonspuren, Überblendungen, Endausgabe-Fehler, Wiederherstellung und Abbruch. Er verändert keine persönlichen Einstellungen. Testdateien werden automatisch entfernt; mit `GROWTHLAPSE_KEEP_TEST_OUTPUT=1` bleiben sie zur Kontrolle erhalten.
Für einen manuellen Funktionstest zwei kurze Videos in einen separaten Testordner legen und ein neues Ausgabeziel wählen. Zunächst ohne Stabilisierung und ohne Übergang rendern, danach mit Überblendung und den gewünschten Filtern. Ergebnis abspielen, einen Segmentstart ändern, erneut rendern, Projekt laden und Abbruch während eines weiteren Renders prüfen. Für einen manuellen Funktionstest zwei kurze Videos in einen separaten Testordner legen und ein neues Ausgabeziel wählen. Zunächst ohne Stabilisierung und ohne Übergang rendern, danach mit Überblendung und den gewünschten Filtern. Ergebnis abspielen, einen Segmentstart ändern, erneut rendern, Projekt laden und Abbruch während eines weiteren Renders prüfen.
@@ -73,7 +83,8 @@ Für einen manuellen Funktionstest zwei kurze Videos in einen separaten Testordn
| `GrowthLapseApp.swift` | App-Einstieg und Fensterlebenszyklus | | `GrowthLapseApp.swift` | App-Einstieg und Fensterlebenszyklus |
| `ContentView.swift` | Einstellungen, Vorschau und Clip-Nachbearbeitung | | `ContentView.swift` | Einstellungen, Vorschau und Clip-Nachbearbeitung |
| `RenderSettings.swift` | Datenmodelle und persistierte Einstellungen | | `RenderSettings.swift` | Datenmodelle und persistierte Einstellungen |
| `FFmpegService.swift` | Werkzeugsuche und asynchrone Prozessausführung | | `FFmpegService.swift` | Werkzeugsuche, Encoder-Prüfung und abbrechbare Prozessausführung |
| `RenderWorkspace.swift` | Isolierte Arbeitsverzeichnisse, sichere Cache-Löschung und Übernahme der Ausgabe |
| `VideoProcessor.swift` | Videoanalyse, Render-Pipeline, Cache und Gesichtserkennung | | `VideoProcessor.swift` | Videoanalyse, Render-Pipeline, Cache und Gesichtserkennung |
Alle genannten Swift-Dateien liegen unter `Sources/GrowthLapse/`. Alle genannten Swift-Dateien liegen unter `Sources/GrowthLapse/`.
@@ -81,9 +92,11 @@ Alle genannten Swift-Dateien liegen unter `Sources/GrowthLapse/`.
## Bekannte Grenzen ## Bekannte Grenzen
- Foto-Morphing ist im Quellcode enthalten, aber derzeit deaktiviert. - Foto-Morphing ist im Quellcode enthalten, aber derzeit deaktiviert.
- Verfügbare FFmpeg-Filter und Encoder hängen von der lokalen Installation ab. Wenn VidStab fehlt, wird Deshake versucht; ohne passenden Filter Stabilisierung deaktivieren oder einen geeigneten Build verwenden. - Verfügbare FFmpeg-Filter und Encoder hängen von der lokalen Installation ab. Wenn VidStab fehlt, wird vorhandenes Deshake verwendet; ohne passenden Filter endet die Vorabprüfung mit einer klaren Fehlermeldung.
- Gesichtserkennung garantiert keine passende Ausrichtung für jede Aufnahme. - Gesichtserkennung garantiert keine passende Ausrichtung für jede Aufnahme.
- Automatische Tests für die vollständige Render-Pipeline fehlen bislang. - Synthetische Tests decken zentrale Renderpfade ab; eine Qualitätsprüfung mit realen Gesichts-, HDR- und langen Videos bleibt erforderlich.
- Im Repository ist bisher keine Lizenz festgelegt. - Im Repository ist bisher keine Lizenz festgelegt.
Weitere technische Befunde stehen in [docs/ANALYSE.md](docs/ANALYSE.md). Weitere technische Befunde stehen in [docs/ANALYSE.md](docs/ANALYSE.md).
Die Änderungen der Zuverlässigkeitsüberarbeitung sind in [docs/VERBESSERUNGEN.md](docs/VERBESSERUNGEN.md) dokumentiert.
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>GrowthLapse</string>
<key>CFBundleExecutable</key>
<string>GrowthLapse</string>
<key>CFBundleIconFile</key>
<string>GrowthLapse.icns</string>
<key>CFBundleIdentifier</key>
<string>de.mike-i386.GrowthLapse</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>GrowthLapse</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
+72 -16
View File
@@ -7,7 +7,8 @@ struct ContentView: View {
@State private var settings = SettingsStore.load() @State private var settings = SettingsStore.load()
@State private var selectedClipID: UUID? @State private var selectedClipID: UUID?
@State private var variantPickerClip: GrowthLapseProjectClip? @State private var variantPickerClip: GrowthLapseProjectClip?
@State private var reviewPanelHeight: CGFloat = 340 @State private var reviewPanelHeight: CGFloat = 460
@State private var showingResult = false
var body: some View { var body: some View {
HSplitView { HSplitView {
@@ -70,6 +71,11 @@ struct ContentView: View {
.disabled(processor.state.isRunning) .disabled(processor.state.isRunning)
Section("Parameter") { 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("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("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.") 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.")
@@ -122,7 +128,7 @@ struct ContentView: View {
Text(selection.rawValue).tag(selection) Text(selection.rawValue).tag(selection)
} }
} }
.help("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'.") .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 { if settings.videoEncoder == .videoToolbox {
Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) { Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) {
@@ -244,10 +250,11 @@ struct ContentView: View {
if let project = processor.project { if let project = processor.project {
ResizableVerticalStack( ResizableVerticalStack(
topHeight: $reviewPanelHeight, topHeight: $reviewPanelHeight,
minTopHeight: 260, minTopHeight: 340,
minBottomHeight: 160 minBottomHeight: 160
) { ) {
projectReviewPanel(project) projectReviewPanel(project)
.disabled(processor.state.isRunning)
} bottom: { } bottom: {
logView logView
} }
@@ -282,6 +289,11 @@ struct ContentView: View {
.onChange(of: settings.sortMode) { sortMode in .onChange(of: settings.sortMode) { sortMode in
processor.reorderProject(sortMode: sortMode) processor.reorderProject(sortMode: sortMode)
} }
.sheet(isPresented: $showingResult) {
if let project = processor.project {
OutputPreviewView(url: project.outputFileURL)
}
}
.sheet(item: $variantPickerClip) { clip in .sheet(item: $variantPickerClip) { clip in
ClipVariantPickerView(clip: currentClip(id: clip.id) ?? clip) { variant in ClipVariantPickerView(clip: currentClip(id: clip.id) ?? clip) { variant in
processor.switchClip(clip, to: variant) processor.switchClip(clip, to: variant)
@@ -348,26 +360,34 @@ struct ContentView: View {
return VStack(alignment: .leading, spacing: 10) { return VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
Text("Clip-Nachbearbeitung") Text("\(project.clips.count) Clips")
.font(.headline) .font(.headline)
Spacer() 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 }
.disabled(!FileManager.default.fileExists(atPath: project.outputFilePath))
}
HStack {
Button("Änderungen neu rendern") { Button("Änderungen neu rendern") {
processor.rerenderProject(settings: settings) processor.rerenderProject(settings: settings)
} }
.disabled(processor.state.isRunning) .disabled(processor.state.isRunning)
.help("Übernimmt geänderte Einstellungen, erneuert bei Bedarf den Clip-Cache und baut danach das finale Video erneut.") .help("Übernimmt Ausgabe-Einstellungen und erhält manuelle Ausschnitte. Für neue Ausschnittlänge oder automatische Segmentwahl: Segmente neu planen.")
Button("Projekt speichern") { Button("Segmente neu planen") {
processor.saveCurrentProject() processor.rerenderProject(settings: settings, replanSegments: true)
} }
.disabled(processor.state.isRunning) .help("Berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu. Ersetzt manuelle Segmentstarts nach erfolgreichem Rendern.")
.help("Speichert die aktuelle Nachbearbeitung in die .growthlapse-project.json neben der Output-Datei. Segmentänderungen werden zusätzlich automatisch gespeichert.")
Button("Ergebnis bestätigen") { Spacer(minLength: 0)
processor.confirmProjectAndDeleteCache() Menu("Projekt") {
Button("Projekt speichern") { processor.saveCurrentProject() }
Button("Render-Cache löschen") { processor.confirmProjectAndDeleteCache() }
} }
.disabled(processor.state.isRunning) .fixedSize()
.help("Löscht den Render-Cache. Die Projektdatei und das finale Video bleiben erhalten.") .help("Projekt speichern oder den zugehörigen Cache löschen. Video und Quelldateien bleiben erhalten.")
} }
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
@@ -410,7 +430,7 @@ struct ContentView: View {
} }
} }
} }
.frame(minWidth: 240, idealWidth: 300, maxWidth: 340, maxHeight: .infinity) .frame(minWidth: 180, idealWidth: 240, maxWidth: 300, maxHeight: .infinity)
if let clip = selectedClip { if let clip = selectedClip {
clipEditor(clip) clipEditor(clip)
@@ -443,6 +463,7 @@ struct ContentView: View {
return GeometryReader { proxy in return GeometryReader { proxy in
let previewHeight = min(max(150, proxy.size.height - 160), 420) let previewHeight = min(max(150, proxy.size.height - 160), 420)
ScrollView {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ClipPreviewView(clip: liveClip) ClipPreviewView(clip: liveClip)
.id(liveClip.sourcePath) .id(liveClip.sourcePath)
@@ -489,9 +510,11 @@ struct ContentView: View {
processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1) processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1)
} }
} }
.controlSize(.small)
.disabled(processor.state.isRunning || maxStart <= 0) .disabled(processor.state.isRunning || maxStart <= 0)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} }
@@ -611,7 +634,7 @@ struct ContentView: View {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.title = "Output-Datei auswählen" panel.title = "Output-Datei auswählen"
panel.allowedContentTypes = [.mpeg4Movie] panel.allowedContentTypes = [.mpeg4Movie]
panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "Joshua_GrowthLapse.mp4" panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "GrowthLapse.mp4"
if panel.runModal() == .OK, let url = panel.url { if panel.runModal() == .OK, let url = panel.url {
settings.outputFile = url.pathExtension.isEmpty ? url.appendingPathExtension("mp4") : url settings.outputFile = url.pathExtension.isEmpty ? url.appendingPathExtension("mp4") : url
} }
@@ -628,6 +651,13 @@ struct ContentView: View {
if panel.runModal() == .OK, if panel.runModal() == .OK,
let url = panel.url, let url = panel.url,
let project = processor.loadProject(from: 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.outputFile = project.outputFileURL
settings.inputFolder = project.clips.first?.sourceURL.deletingLastPathComponent() settings.inputFolder = project.clips.first?.sourceURL.deletingLastPathComponent()
selectedClipID = project.clips.first?.id selectedClipID = project.clips.first?.id
@@ -937,3 +967,29 @@ private struct VariantPreviewView: View {
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) 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() }
.keyboardShortcut(.cancelAction)
}
VideoPlayer(player: player)
}
.padding()
.frame(minWidth: 640, idealWidth: 800, minHeight: 440, idealHeight: 540)
.onDisappear { player.pause() }
}
}
+72 -81
View File
@@ -1,38 +1,65 @@
import Foundation import Foundation
final class LockedOutputBuffer: @unchecked Sendable { /// Cancellation belongs to this invocation, including the interval before launch.
private let lock = NSLock() private final class ProcessExecution: @unchecked Sendable {
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
}
}
final class LockedCancellationState: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private let process = Process()
private var cancelled = false private var cancelled = false
func cancel() { func cancel() {
lock.lock() lock.lock()
defer { lock.unlock() }
cancelled = true cancelled = true
lock.unlock() if process.isRunning { process.terminate() }
} }
func isCancelled() -> Bool { func execute(executable: URL, arguments: [String], directory: URL?,
onOutput: @escaping @Sendable (String) -> Void) throws -> ProcessResult {
let pipe = Pipe()
let input = try FileHandle(forReadingFrom: URL(fileURLWithPath: "/dev/null"))
defer {
try? input.close()
try? pipe.fileHandleForReading.close()
try? pipe.fileHandleForWriting.close()
}
process.executableURL = executable
process.arguments = arguments
process.currentDirectoryURL = directory
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = input
lock.lock() lock.lock()
let result = cancelled if cancelled {
lock.unlock() lock.unlock()
return result throw CancellationError()
}
do { try process.run() } catch {
lock.unlock()
throw error
}
lock.unlock()
try? pipe.fileHandleForWriting.close()
var output = Data()
do {
while let chunk = try pipe.fileHandleForReading.read(upToCount: 65536), !chunk.isEmpty {
output.append(chunk)
if output.count > 4 * 1024 * 1024 {
output.removeFirst(output.count - 4 * 1024 * 1024)
}
onOutput(String(decoding: chunk, as: UTF8.self))
}
} catch {
cancel()
process.waitUntilExit()
throw error
}
process.waitUntilExit()
lock.lock()
let wasCancelled = cancelled
lock.unlock()
if wasCancelled { throw CancellationError() }
return ProcessResult(exitCode: process.terminationStatus,
output: String(decoding: output, as: UTF8.self))
} }
} }
@@ -42,7 +69,7 @@ struct ProcessResult {
} }
actor FFmpegService { actor FFmpegService {
private var runningProcess: Process? private var execution: ProcessExecution?
func findExecutable(named name: String) -> URL? { func findExecutable(named name: String) -> URL? {
let candidates = [ let candidates = [
@@ -96,10 +123,7 @@ actor FFmpegService {
return (ffmpeg, ffprobe) return (ffmpeg, ffprobe)
} }
func cancel() { func cancel() { execution?.cancel() }
runningProcess?.terminate()
runningProcess = nil
}
func run( func run(
executable: URL, executable: URL,
@@ -108,62 +132,33 @@ actor FFmpegService {
onOutput: @escaping @Sendable (String) -> Void = { _ in } onOutput: @escaping @Sendable (String) -> Void = { _ in }
) async throws -> ProcessResult { ) async throws -> ProcessResult {
try Task.checkCancellation() try Task.checkCancellation()
let cancellationState = LockedCancellationState() guard execution == nil else {
throw VideoProcessingError.invalidSettings("Ein Werkzeugprozess läuft bereits.")
}
let current = ProcessExecution()
execution = current
defer { execution = nil }
return try await withTaskCancellationHandler { return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in try await withCheckedThrowingContinuation { continuation in
let process = Process() DispatchQueue.global(qos: .userInitiated).async {
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)
}
if cancellationState.isCancelled() {
continuation.resume(throwing: CancellationError())
} else {
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
}
}
do { do {
runningProcess = process continuation.resume(returning: try current.execute(executable: executable,
try process.run() arguments: arguments, directory: currentDirectory, onOutput: onOutput))
} catch { } catch { continuation.resume(throwing: error) }
pipe.fileHandleForReading.readabilityHandler = nil
nullInput?.closeFile()
runningProcess = nil
continuation.resume(throwing: error)
} }
} }
} onCancel: { } onCancel: {
cancellationState.cancel() current.cancel()
Task {
await self.cancel()
} }
} }
func validateEncoder(ffmpeg: URL, encoder: VideoEncoder, audio: Bool) async throws {
let result = try await run(executable: ffmpeg, arguments: ["-hide_banner", "-encoders"])
let name = encoder == .x264 ? "libx264" : "h264_videotoolbox"
guard result.exitCode == 0, result.output.contains(name),
!audio || result.output.contains(" aac ") else {
throw VideoProcessingError.invalidSettings("FFmpeg enthält den gewählten Video-/Audioencoder nicht. Bitte einen anderen Encoder oder FFmpeg-Build wählen.")
}
} }
func availableFilters(ffmpeg: URL) async throws -> FFmpegFilterAvailability { func availableFilters(ffmpeg: URL) async throws -> FFmpegFilterAvailability {
@@ -184,11 +179,7 @@ actor FFmpegService {
) )
} }
private func clearRunningProcess(_ process: Process) {
if runningProcess === process {
runningProcess = nil
}
}
} }
enum VideoProcessingError: LocalizedError { enum VideoProcessingError: LocalizedError {
+8 -1
View File
@@ -156,7 +156,7 @@ struct RenderSettings: Equatable {
var targetFaceHeightRatio: Double = 0.26 var targetFaceHeightRatio: Double = 0.26
} }
struct PersistedRenderSettings: Codable { struct PersistedRenderSettings: Codable, Equatable {
var inputFolderPath: String? var inputFolderPath: String?
var outputFilePath: String? var outputFilePath: String?
var ffmpegExecutablePath: String? var ffmpegExecutablePath: String?
@@ -292,6 +292,10 @@ enum SettingsStore {
} }
private static func settingsURL() -> URL { private static func settingsURL() -> URL {
// Allows UI smoke tests to run without touching the user's preferences.
if let path = ProcessInfo.processInfo.environment["GROWTHLAPSE_SETTINGS_PATH"], path.hasPrefix("/") {
return URL(fileURLWithPath: path)
}
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return base.appendingPathComponent("GrowthLapse", isDirectory: true).appendingPathComponent("settings.json") return base.appendingPathComponent("GrowthLapse", isDirectory: true).appendingPathComponent("settings.json")
@@ -338,6 +342,8 @@ struct GrowthLapseProject: Codable, Equatable {
var targetHeight: Int var targetHeight: Int
var clips: [GrowthLapseProjectClip] var clips: [GrowthLapseProjectClip]
var renderFingerprint: String? var renderFingerprint: String?
var schemaVersion: Int? = 2
var settings: PersistedRenderSettings? = nil
var outputFileURL: URL { var outputFileURL: URL {
URL(fileURLWithPath: outputFilePath) URL(fileURLWithPath: outputFilePath)
@@ -368,6 +374,7 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable {
var segmentStart: Double var segmentStart: Double
var segmentLength: Double var segmentLength: Double
var normalizedClipPath: String var normalizedClipPath: String
var sourceSignature: String? = nil
var needsRender: Bool = false var needsRender: Bool = false
var variants: [GrowthLapseClipVariant]? var variants: [GrowthLapseClipVariant]?
+113
View File
@@ -0,0 +1,113 @@
import Foundation
import Darwin
/// Every render owns a new directory. Existing caches are only read, never edited.
enum RenderWorkspace {
private static let marker = ".growthlapse-owner.json"
static func create(for output: URL) throws -> URL {
let directory = output.deletingLastPathComponent().resolvingSymlinksInPath()
.appendingPathComponent("GrowthLapse-\(UUID().uuidString).growthlapse-cache", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
do {
try JSONEncoder().encode(output.standardizedFileURL.path)
.write(to: directory.appendingPathComponent(marker), options: .atomic)
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
return directory
}
static func isOwned(_ directory: URL, by output: URL) -> Bool {
let normalized = directory.standardizedFileURL
guard normalized.path == normalized.resolvingSymlinksInPath().standardizedFileURL.path,
normalized.deletingLastPathComponent().path == output.standardizedFileURL.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path,
normalized.lastPathComponent.hasPrefix("GrowthLapse-"),
normalized.pathExtension == "growthlapse-cache",
let data = try? Data(contentsOf: normalized.appendingPathComponent(marker)),
let owner = try? JSONDecoder().decode(String.self, from: data),
owner == output.standardizedFileURL.path else { return false }
return true
}
static func remove(_ directory: URL, for output: URL) throws {
guard isOwned(directory, by: output) else {
throw VideoProcessingError.invalidSettings("Dieser Ordner ist kein bestätigter GrowthLapse-Cache und wird nicht gelöscht.")
}
try FileManager.default.removeItem(at: directory)
}
/// Rename on the same volume replaces a file atomically. Backups permit rollback
/// if publishing a later artifact fails (not a crash-proof multi-file transaction).
static func publish(_ files: [(staged: URL, destination: URL)], in directory: URL) throws {
let manager = FileManager.default
var backups: [URL?] = []
for (index, file) in files.enumerated() {
guard (try file.staged.resourceValues(forKeys: [.isRegularFileKey])).isRegularFile == true else {
throw VideoProcessingError.invalidSettings("Die Render-Ausgabe fehlt oder ist keine Datei.")
}
if manager.fileExists(atPath: file.destination.path) {
let values = try file.destination.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
guard values.isRegularFile == true, values.isSymbolicLink != true else {
throw VideoProcessingError.invalidSettings("Das Ausgabeziel muss eine reguläre Datei sein.")
}
let backup = directory.appendingPathComponent("backup-\(index)")
try manager.copyItem(at: file.destination, to: backup)
backups.append(backup)
} else {
backups.append(nil)
}
}
var published = 0
do {
for file in files {
try moveReplacing(file.staged, file.destination)
published += 1
}
} catch {
let originalError = error
do {
for index in (0..<published).reversed() {
if let backup = backups[index] {
try moveReplacing(backup, files[index].destination)
} else {
try manager.removeItem(at: files[index].destination)
}
}
} catch {
throw PublicationRecoveryError(directory: directory, details: error.localizedDescription)
}
throw originalError
}
for backup in backups.compactMap({ $0 }) { try? manager.removeItem(at: backup) }
}
private static func moveReplacing(_ source: URL, _ destination: URL) throws {
guard rename(source.path, destination.path) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
}
/// Detached analysis runs away from the UI, but follows its caller's cancellation.
func cancellableAnalysis<T: Sendable>(
_ operation: @escaping @Sendable () throws -> T
) async throws -> T {
let task = Task.detached(priority: .userInitiated, operation: operation)
return try await withTaskCancellationHandler {
let result = try await task.value
try Task.checkCancellation()
return result
} onCancel: {
task.cancel()
}
}
struct PublicationRecoveryError: LocalizedError {
let directory: URL
let details: String
var errorDescription: String? {
"Ausgabe konnte nicht vollständig wiederhergestellt werden. Sicherungen bleiben unter \(directory.path). \(details)"
}
}
+179 -58
View File
@@ -39,9 +39,6 @@ final class VideoProcessor: ObservableObject {
renderTask = Task { renderTask = Task {
do { do {
let outcome = try await render(settings: settingsCopy, existingProject: nil) let outcome = try await render(settings: settingsCopy, existingProject: nil)
guard !Task.isCancelled else {
throw CancellationError()
}
project = outcome.project project = outcome.project
state = .finished(outcome.outputURL) state = .finished(outcome.outputURL)
progress = 1 progress = 1
@@ -64,7 +61,7 @@ final class VideoProcessor: ObservableObject {
} }
} }
func rerenderProject(settings: RenderSettings) { func rerenderProject(settings: RenderSettings, replanSegments: Bool = false) {
guard !state.isRunning, let project else { guard !state.isRunning, let project else {
return return
} }
@@ -80,10 +77,7 @@ final class VideoProcessor: ObservableObject {
let projectCopy = reorderedProject(project, sortMode: settings.sortMode) let projectCopy = reorderedProject(project, sortMode: settings.sortMode)
renderTask = Task { renderTask = Task {
do { do {
let outcome = try await render(settings: settingsCopy, existingProject: projectCopy) let outcome = try await render(settings: settingsCopy, existingProject: projectCopy, replanSegments: replanSegments)
guard !Task.isCancelled else {
throw CancellationError()
}
self.project = outcome.project self.project = outcome.project
state = .finished(outcome.outputURL) state = .finished(outcome.outputURL)
progress = 1 progress = 1
@@ -153,7 +147,7 @@ final class VideoProcessor: ObservableObject {
appendLog("Kein Projekt zum Speichern geladen.") appendLog("Kein Projekt zum Speichern geladen.")
return return
} }
saveProject(project) guard saveProject(project) else { return }
appendLog("Projekt gespeichert: \(GrowthLapseProject.url(for: project.outputFileURL).path)") appendLog("Projekt gespeichert: \(GrowthLapseProject.url(for: project.outputFileURL).path)")
} }
@@ -161,6 +155,7 @@ final class VideoProcessor: ObservableObject {
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
var loadedProject = try JSONDecoder().decode(GrowthLapseProject.self, from: data) var loadedProject = try JSONDecoder().decode(GrowthLapseProject.self, from: data)
try validateProject(loadedProject)
loadedProject.clips = loadedProject.clips.map { clip in loadedProject.clips = loadedProject.clips.map { clip in
var updated = clip var updated = clip
if !FileManager.default.fileExists(atPath: updated.normalizedClipPath) { if !FileManager.default.fileExists(atPath: updated.normalizedClipPath) {
@@ -168,6 +163,9 @@ final class VideoProcessor: ObservableObject {
} }
return updated return updated
} }
warningText = loadedProject.settings == nil
? "Älteres Projekt ohne Render-Einstellungen: Es gelten die aktuellen Einstellungen."
: nil
project = loadedProject project = loadedProject
state = .finished(loadedProject.outputFileURL) state = .finished(loadedProject.outputFileURL)
progress = 1 progress = 1
@@ -185,11 +183,16 @@ final class VideoProcessor: ObservableObject {
} }
func confirmProjectAndDeleteCache() { func confirmProjectAndDeleteCache() {
guard let project else { guard !state.isRunning, let project else {
return return
} }
try? FileManager.default.removeItem(at: project.cacheDirectoryURL) do {
try RenderWorkspace.remove(project.cacheDirectoryURL, for: project.outputFileURL)
appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)") appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)")
} catch {
warningText = error.localizedDescription
appendLog("Cache bleibt erhalten: \(error.localizedDescription)")
}
} }
func cancel() { func cancel() {
@@ -200,16 +203,13 @@ final class VideoProcessor: ObservableObject {
progressText = "Wird abgebrochen" progressText = "Wird abgebrochen"
appendLog("Abbruch angefordert.") appendLog("Abbruch angefordert.")
renderTask?.cancel() renderTask?.cancel()
Task {
await service.cancel()
}
} }
func showInFinder(url: URL) { func showInFinder(url: URL) {
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
} }
private func render(settings: RenderSettings, existingProject: GrowthLapseProject?) async throws -> RenderOutcome { private func render(settings: RenderSettings, existingProject: GrowthLapseProject?, replanSegments: Bool = false) async throws -> RenderOutcome {
var effectiveSettings = settings var effectiveSettings = settings
try validate(effectiveSettings) try validate(effectiveSettings)
let tools = try await service.validateTools( let tools = try await service.validateTools(
@@ -229,7 +229,7 @@ final class VideoProcessor: ObservableObject {
throw VideoProcessingError.invalidSettings("Clipnummern können nicht eingeblendet werden, weil dieser ffmpeg-Build den Filter drawtext nicht enthält.") throw VideoProcessingError.invalidSettings("Clipnummern können nicht eingeblendet werden, weil dieser ffmpeg-Build den Filter drawtext nicht enthält.")
} }
if effectiveSettings.stabilizationEnabled { if effectiveSettings.stabilizationEnabled {
effectiveSettings.stabilizationMethod = resolveStabilizationMethod( effectiveSettings.stabilizationMethod = try resolveStabilizationMethod(
requested: effectiveSettings.stabilizationMethod, requested: effectiveSettings.stabilizationMethod,
availability: filters availability: filters
) )
@@ -240,23 +240,45 @@ final class VideoProcessor: ObservableObject {
} }
} }
try await service.validateEncoder(ffmpeg: tools.ffmpeg, encoder: effectiveSettings.videoEncoder,
audio: !effectiveSettings.removeAudio)
let inputFolder = settings.inputFolder! let inputFolder = settings.inputFolder!
let outputFile = settings.outputFile! let outputFile = settings.outputFile!
let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil
let tempDirectory = try createWorkingDirectory( let previousOnDisk = (try? Data(contentsOf: GrowthLapseProject.url(for: outputFile)))
keepDebugFiles: settings.keepIntermediateClips, .flatMap { try? JSONDecoder().decode(GrowthLapseProject.self, from: $0) }
keepReviewCache: useReviewCache, if let existingProject { try validateProject(existingProject) }
outputFile: outputFile, if existingProject == nil, FileManager.default.fileExists(atPath: outputFile.path),
resetCache: existingProject == nil inputFolder.resolvingSymlinksInPath().standardizedFileURL.path == outputFile.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path,
) previousOnDisk?.outputFileURL.standardizedFileURL.path != outputFile.standardizedFileURL.path {
throw VideoProcessingError.invalidSettings("Eine vorhandene Videodatei im Input-Ordner darf nicht als neues Ausgabeziel verwendet werden. Bitte einen neuen Dateinamen wählen.")
}
let tempDirectory = try RenderWorkspace.create(for: outputFile)
temporaryDirectory = tempDirectory temporaryDirectory = tempDirectory
appendLog("Temporärer Ordner: \(tempDirectory.path)") var committed = false
defer {
temporaryDirectory = nil
if !committed { try? RenderWorkspace.remove(tempDirectory, for: outputFile) }
}
let stagedOutput = tempDirectory.appendingPathComponent("result.mp4")
appendLog("Arbeitsordner: \(tempDirectory.path)")
let videos: [VideoFile] let videos: [VideoFile]
if let existingProject { if let existingProject {
videos = existingProject.clips.map { var refreshed: [VideoFile] = []
VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height, variants: $0.availableVariants) for clip in existingProject.clips {
try Task.checkCancellation()
if try sourceSignature(clip.sourceURL) != clip.sourceSignature {
let duration = try await probeDuration(url: clip.sourceURL, ffprobe: tools.ffprobe)
let dimensions = try await probeDimensions(url: clip.sourceURL, ffprobe: tools.ffprobe)
refreshed.append(VideoFile(url: clip.sourceURL, duration: duration,
width: dimensions.width, height: dimensions.height, variants: clip.availableVariants))
} else {
refreshed.append(VideoFile(url: clip.sourceURL, duration: clip.duration,
width: clip.width, height: clip.height, variants: clip.availableVariants))
} }
}
videos = refreshed
appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).") appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).")
} else { } else {
videos = try await discoverVideos( videos = try await discoverVideos(
@@ -267,12 +289,16 @@ final class VideoProcessor: ObservableObject {
excluding: outputFile excluding: outputFile
) )
} }
guard !videos.isEmpty else { throw VideoProcessingError.noVideosFound }
guard !videos.contains(where: { $0.url.resolvingSymlinksInPath().standardizedFileURL == outputFile.resolvingSymlinksInPath().standardizedFileURL }) else {
throw VideoProcessingError.invalidSettings("Die Ausgabe darf keine Quelldatei ersetzen.")
}
appendLog("\(videos.count) Video(s) gefunden.") appendLog("\(videos.count) Video(s) gefunden.")
let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0]) let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)") appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")
var renderProject: GrowthLapseProject var renderProject: GrowthLapseProject
if let existingProject { if let existingProject, !replanSegments {
renderProject = existingProject renderProject = existingProject
} else { } else {
renderProject = try await makeProject( renderProject = try await makeProject(
@@ -284,6 +310,32 @@ final class VideoProcessor: ObservableObject {
) )
} }
renderProject.outputFilePath = outputFile.path
renderProject.cacheDirectoryPath = tempDirectory.path
renderProject.targetWidth = targetDimensions.width
renderProject.targetHeight = targetDimensions.height
renderProject.schemaVersion = 2
renderProject.settings = PersistedRenderSettings(settings: effectiveSettings)
for index in renderProject.clips.indices {
let old = renderProject.clips[index]
let destination = tempDirectory.appendingPathComponent("clip-\(old.id.uuidString).mp4")
let signature = try sourceSignature(old.sourceURL)
let canReuse = !old.needsRender && old.sourceSignature == signature
&& FileManager.default.fileExists(atPath: old.normalizedClipPath)
if canReuse {
try FileManager.default.copyItem(at: old.normalizedClipURL, to: destination)
}
renderProject.clips[index].duration = videos[index].duration
renderProject.clips[index].width = videos[index].width
renderProject.clips[index].height = videos[index].height
renderProject.clips[index].segmentLength = min(old.segmentLength, videos[index].duration)
renderProject.clips[index].segmentStart = min(old.segmentStart,
max(0, videos[index].duration - renderProject.clips[index].segmentLength))
renderProject.clips[index].normalizedClipPath = destination.path
renderProject.clips[index].sourceSignature = signature
renderProject.clips[index].needsRender = !canReuse
}
let fingerprint = renderFingerprint(settings: effectiveSettings, targetDimensions: targetDimensions) let fingerprint = renderFingerprint(settings: effectiveSettings, targetDimensions: targetDimensions)
if renderProject.renderFingerprint != fingerprint { if renderProject.renderFingerprint != fingerprint {
if renderProject.renderFingerprint != nil { if renderProject.renderFingerprint != nil {
@@ -306,32 +358,47 @@ final class VideoProcessor: ObservableObject {
) )
if effectiveSettings.transitionLength <= 0 { if effectiveSettings.transitionLength <= 0 {
try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf) try await concatenate(clips: normalizedClips, outputFile: stagedOutput, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf)
} else { } else {
try await crossfade( try await crossfade(
clips: normalizedClips, clips: normalizedClips,
settings: effectiveSettings, settings: effectiveSettings,
targetDimensions: targetDimensions, targetDimensions: targetDimensions,
outputFile: outputFile, outputFile: stagedOutput,
ffmpeg: tools.ffmpeg ffmpeg: tools.ffmpeg
) )
} }
let reportURL = writeRenderReport( try Task.checkCancellation()
settings: effectiveSettings, let stagedProject = tempDirectory.appendingPathComponent("project.json")
outputFile: outputFile, let encoder = JSONEncoder()
targetDimensions: targetDimensions, encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
project: renderProject try encoder.encode(renderProject).write(to: stagedProject, options: .atomic)
) do {
try RenderWorkspace.publish([
(stagedOutput, outputFile),
(stagedProject, GrowthLapseProject.url(for: outputFile))
], in: tempDirectory)
} catch let error as PublicationRecoveryError {
committed = true // Retain backups for manual recovery if rollback itself failed.
throw error
}
committed = true
let reportURL = writeRenderReport(settings: effectiveSettings, outputFile: outputFile,
targetDimensions: targetDimensions, project: renderProject)
appendLog("Render-Protokoll: \(reportURL.path)") appendLog("Render-Protokoll: \(reportURL.path)")
saveProject(renderProject)
appendLog("Projektdatei: \(GrowthLapseProject.url(for: outputFile).path)") appendLog("Projektdatei: \(GrowthLapseProject.url(for: outputFile).path)")
if !settings.keepIntermediateClips && !useReviewCache { if !settings.keepIntermediateClips && !useReviewCache {
try? FileManager.default.removeItem(at: tempDirectory) try? RenderWorkspace.remove(tempDirectory, for: outputFile)
} else { } else {
cleanupTransformFiles(in: tempDirectory) cleanupTransformFiles(in: tempDirectory)
} }
for previous in [existingProject, previousOnDisk].compactMap({ $0 }) {
if previous.outputFileURL.standardizedFileURL == outputFile.standardizedFileURL,
RenderWorkspace.isOwned(previous.cacheDirectoryURL, by: outputFile) {
try? RenderWorkspace.remove(previous.cacheDirectoryURL, for: outputFile)
}
}
return RenderOutcome(outputURL: outputFile, project: renderProject) return RenderOutcome(outputURL: outputFile, project: renderProject)
} }
@@ -565,7 +632,10 @@ final class VideoProcessor: ObservableObject {
} }
} }
private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod { private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) throws -> StabilizationMethod {
guard (requested == .vidstab && availability.hasVidstab) || availability.hasDeshake else {
throw VideoProcessingError.invalidSettings("Der FFmpeg-Build enthält keinen passenden Stabilisierungsfilter. Bitte Stabilisierung deaktivieren oder FFmpeg mit VidStab/Deshake auswählen.")
}
switch requested { switch requested {
case .vidstab: case .vidstab:
if availability.hasVidstab { if availability.hasVidstab {
@@ -585,7 +655,56 @@ final class VideoProcessor: ObservableObject {
} }
} }
private func sourceSignature(_ url: URL) throws -> String {
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey])
guard values.isRegularFile == true else {
throw VideoProcessingError.invalidSettings("Quelldatei fehlt: \(url.path)")
}
return "\(values.fileSize ?? -1)|\(values.contentModificationDate?.timeIntervalSince1970 ?? 0)"
}
private func validateProject(_ project: GrowthLapseProject) throws {
guard (project.schemaVersion ?? 1) <= 2, !project.clips.isEmpty,
project.targetWidth > 0, project.targetHeight > 0,
project.outputFilePath.hasPrefix("/"), project.cacheDirectoryPath.hasPrefix("/"),
Set(project.clips.map(\.id)).count == project.clips.count else {
throw VideoProcessingError.invalidSettings("Ungültiges oder nicht unterstütztes GrowthLapse-Projekt.")
}
if let saved = project.settings { try validate(saved.renderSettings) }
let cache = project.cacheDirectoryURL.resolvingSymlinksInPath().standardizedFileURL.path
for clip in project.clips {
guard clip.sourcePath.hasPrefix("/"), clip.normalizedClipPath.hasPrefix("/"),
clip.duration.isFinite, clip.duration > 0,
clip.segmentStart.isFinite, clip.segmentStart >= 0,
clip.segmentLength.isFinite, clip.segmentLength > 0,
clip.segmentStart + clip.segmentLength <= clip.duration + 0.001,
clip.width > 0, clip.height > 0,
clip.normalizedClipURL.resolvingSymlinksInPath().deletingLastPathComponent().standardizedFileURL.path == cache,
clip.normalizedClipURL.pathExtension.lowercased() == "mp4",
clip.normalizedClipURL.resolvingSymlinksInPath() != clip.sourceURL.resolvingSymlinksInPath() else {
throw VideoProcessingError.invalidSettings("Ungültige Clipdaten: \(clip.displayName)")
}
for variant in clip.availableVariants {
guard variant.sourcePath.hasPrefix("/"), variant.duration.isFinite, variant.duration > 0,
variant.width > 0, variant.height > 0 else {
throw VideoProcessingError.invalidSettings("Ungültige Clip-Variante: \(variant.displayName)")
}
}
}
}
private func validate(_ settings: RenderSettings) throws { private func validate(_ settings: RenderSettings) throws {
guard [settings.segmentLength, settings.targetClipLength, settings.transitionLength,
settings.startOffset, settings.targetFaceHeightRatio, settings.stabilizationAnchorX,
settings.stabilizationAnchorY, settings.stabilizationAnchorSize].allSatisfy({ $0.isFinite }) else {
throw VideoProcessingError.invalidSettings("Render-Parameter müssen endliche Zahlen sein.")
}
if let output = settings.outputFile {
guard output.pathExtension.lowercased() == "mp4" else {
throw VideoProcessingError.invalidSettings("Bitte eine MP4-Ausgabedatei auswählen.")
}
}
guard settings.inputFolder != nil else { guard settings.inputFolder != nil else {
throw VideoProcessingError.invalidSettings("Bitte wähle einen Input-Ordner aus.") throw VideoProcessingError.invalidSettings("Bitte wähle einen Input-Ordner aus.")
} }
@@ -634,7 +753,8 @@ final class VideoProcessor: ObservableObject {
) )
var urls = contents.filter { url in var urls = contents.filter { url in
supportedExtensions.contains(url.pathExtension.lowercased()) (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
&& supportedExtensions.contains(url.pathExtension.lowercased())
&& url.standardizedFileURL.path != excludedPath && url.standardizedFileURL.path != excludedPath
} }
if let excludedPath, contents.contains(where: { $0.standardizedFileURL.path == excludedPath }) { if let excludedPath, contents.contains(where: { $0.standardizedFileURL.path == excludedPath }) {
@@ -1571,6 +1691,8 @@ final class VideoProcessor: ObservableObject {
} }
appendLog("Bestes Segment Clip \(clipNumber): Start \(formatSeconds(result.start)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount), Score \(String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), result.score))") appendLog("Bestes Segment Clip \(clipNumber): Start \(formatSeconds(result.start)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount), Score \(String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), result.score))")
return result.start return result.start
} catch is CancellationError {
throw CancellationError()
} catch { } catch {
appendLog("Warnung: Bestes Segment Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription). Nutze Fallback @ \(formatSeconds(safeFallback))") appendLog("Warnung: Bestes Segment Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription). Nutze Fallback @ \(formatSeconds(safeFallback))")
return safeFallback return safeFallback
@@ -1604,6 +1726,7 @@ final class VideoProcessor: ObservableObject {
var copy = project var copy = project
copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in
var updated = clip var updated = clip
if updated.index != offset + 1 { updated.needsRender = true }
updated.index = offset + 1 updated.index = offset + 1
return updated return updated
} }
@@ -1635,15 +1758,19 @@ final class VideoProcessor: ObservableObject {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
} }
private func saveProject(_ project: GrowthLapseProject) { @discardableResult
private func saveProject(_ project: GrowthLapseProject) -> Bool {
let url = GrowthLapseProject.url(for: project.outputFileURL) let url = GrowthLapseProject.url(for: project.outputFileURL)
do { do {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(project) let data = try encoder.encode(project)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
return true
} catch { } catch {
appendLog("Warnung: Projektdatei konnte nicht geschrieben werden: \(error.localizedDescription)") warningText = "Projektdatei konnte nicht gespeichert werden: \(error.localizedDescription)"
appendLog(warningText!)
return false
} }
} }
@@ -2053,16 +2180,10 @@ final class VideoProcessor: ObservableObject {
private func handleCancellation() { private func handleCancellation() {
cancellationRequested = false cancellationRequested = false
Task {
await service.cancel()
}
state = .cancelled state = .cancelled
progressText = "Abgebrochen" progressText = "Abgebrochen"
appendLog("Rendern abgebrochen.") appendLog("Rendern abgebrochen.")
if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) {
cleanupTransformFiles(in: temporaryDirectory)
try? FileManager.default.removeItem(at: temporaryDirectory)
}
} }
} }
@@ -2095,7 +2216,7 @@ private enum PhotoMorphRenderer {
targetFaceHeightRatio: Double, targetFaceHeightRatio: Double,
progress: @escaping (Int, Int, String, Bool) -> Void progress: @escaping (Int, Int, String, Bool) -> Void
) async throws -> [PreparedMorphImage] { ) async throws -> [PreparedMorphImage] {
try await Task.detached(priority: .userInitiated) { try await cancellableAnalysis {
var result: [PreparedMorphImage] = [] var result: [PreparedMorphImage] = []
for (index, url) in urls.enumerated() { for (index, url) in urls.enumerated() {
try Task.checkCancellation() try Task.checkCancellation()
@@ -2111,7 +2232,7 @@ private enum PhotoMorphRenderer {
progress(index + 1, urls.count, url.lastPathComponent, normalized.usedLandmarks) progress(index + 1, urls.count, url.lastPathComponent, normalized.usedLandmarks)
} }
return result return result
}.value }
} }
static func renderFrames( static func renderFrames(
@@ -2121,7 +2242,7 @@ private enum PhotoMorphRenderer {
secondsPerTransition: Double, secondsPerTransition: Double,
progress: @escaping (Int, Int) -> Void progress: @escaping (Int, Int) -> Void
) async throws -> Int { ) async throws -> Int {
try await Task.detached(priority: .userInitiated) { try await cancellableAnalysis {
guard images.count >= 2 else { guard images.count >= 2 else {
return 0 return 0
} }
@@ -2154,7 +2275,7 @@ private enum PhotoMorphRenderer {
try writePNG(last, to: outputDirectory.appendingPathComponent(String(format: "frame_%06d.png", written))) try writePNG(last, to: outputDirectory.appendingPathComponent(String(format: "frame_%06d.png", written)))
progress(written, totalFrames) progress(written, totalFrames)
return written return written
}.value }
} }
private static func loadImage(_ url: URL) throws -> CGImage { private static func loadImage(_ url: URL) throws -> CGImage {
@@ -2461,7 +2582,7 @@ private enum BestSegmentAnalyzer {
duration: Double, duration: Double,
segmentLength: Double segmentLength: Double
) async throws -> BestSegmentResult? { ) async throws -> BestSegmentResult? {
try await Task.detached(priority: .userInitiated) { try await cancellableAnalysis {
try Task.checkCancellation() try Task.checkCancellation()
let sampleInterval = duration <= 60 ? 0.5 : 1.0 let sampleInterval = duration <= 60 ? 0.5 : 1.0
@@ -2519,11 +2640,11 @@ private enum BestSegmentAnalyzer {
return nil return nil
} }
return best return best
}.value }
} }
private static func analyzeSamples(source: URL, duration: Double, interval: Double) throws -> [EyeVisibilitySample] { private static func analyzeSamples(source: URL, duration: Double, interval: Double) throws -> [EyeVisibilitySample] {
let asset = AVAsset(url: source) let asset = AVURLAsset(url: source)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 960, height: 960) generator.maximumSize = CGSize(width: 960, height: 960)
@@ -2609,10 +2730,10 @@ private enum FaceAlignmentAnalyzer {
targetDimensions: VideoDimensions, targetDimensions: VideoDimensions,
targetFaceHeightRatio: Double targetFaceHeightRatio: Double
) async throws -> FaceAlignment? { ) async throws -> FaceAlignment? {
try await Task.detached(priority: .userInitiated) { try await cancellableAnalysis {
try Task.checkCancellation() try Task.checkCancellation()
let asset = AVAsset(url: source) let asset = AVURLAsset(url: source)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600) generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600)
@@ -2663,7 +2784,7 @@ private enum FaceAlignmentAnalyzer {
targetDimensions: targetDimensions, targetDimensions: targetDimensions,
targetFaceHeightRatio: targetFaceHeightRatio targetFaceHeightRatio: targetFaceHeightRatio
) )
}.value }
} }
private static func sampleTimes(start: Double, duration: Double) -> [CMTime] { private static func sampleTimes(start: Double, duration: Double) -> [CMTime] {
+302
View File
@@ -0,0 +1,302 @@
import Foundation
private enum TestFailure: Error { case failed(String) }
@main
struct RenderIntegration {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
guard condition() else { throw TestFailure.failed(message) }
}
@MainActor
static func wait(_ processor: VideoProcessor) async throws {
let deadline = Date().addingTimeInterval(60)
while processor.state.isRunning, Date() < deadline {
try await Task.sleep(nanoseconds: 20_000_000)
}
if processor.state.isRunning { processor.cancel() }
guard case .finished = processor.state else {
throw TestFailure.failed("Render: \(processor.state)\n\(processor.logs.suffix(15).joined(separator: "\n"))")
}
}
static func probe(_ url: URL, tools: FFmpegService, ffprobe: URL) async throws -> [String: Any] {
let result = try await tools.run(executable: ffprobe,
arguments: ["-v", "error", "-show_streams", "-show_format", "-of", "json", url.path])
try require(result.exitCode == 0, "ffprobe failed")
return try JSONSerialization.jsonObject(with: Data(result.output.utf8)) as! [String: Any]
}
@MainActor
static func main() async throws {
let defaults = RenderSettings()
try require(defaults.inputFolder == nil && defaults.outputFile == nil
&& defaults.ffmpegExecutable == nil && defaults.ffprobeExecutable == nil, "Machine-specific defaults")
var custom = defaults
custom.segmentLength = 12
custom.inputFolder = URL(fileURLWithPath: "/tmp/Meine Videos")
let encoded = try JSONEncoder().encode(PersistedRenderSettings(settings: custom))
let restored = try JSONDecoder().decode(PersistedRenderSettings.self, from: encoded)
try require(restored.renderSettings == custom, "Settings round-trip")
var older = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
for key in ["renderMode", "duplicateClipSelection", "keepRenderCacheForReview", "burnInClipNumbers", "bestSegmentDetectionEnabled"] {
older.removeValue(forKey: key)
}
let migrated = try JSONDecoder().decode(PersistedRenderSettings.self,
from: JSONSerialization.data(withJSONObject: older)).renderSettings
try require(migrated.duplicateClipSelection == .first && migrated.keepRenderCacheForReview
&& !migrated.burnInClipNumbers && !migrated.bestSegmentDetectionEnabled, "Old settings migration")
print("PASS neutral defaults, settings round-trip and legacy migration")
let manager = FileManager.default
let root = manager.temporaryDirectory.resolvingSymlinksInPath()
.appendingPathComponent("GrowthLapse-tests-\(UUID().uuidString)", isDirectory: true)
try manager.createDirectory(at: root, withIntermediateDirectories: true)
defer {
if ProcessInfo.processInfo.environment["GROWTHLAPSE_KEEP_TEST_OUTPUT"] == "1" {
print("Testdateien: \(root.path)")
} else {
try? manager.removeItem(at: root)
}
}
let input = root.appendingPathComponent("input", isDirectory: true)
try manager.createDirectory(at: input, withIntermediateDirectories: true)
let service = FFmpegService()
let tools = try await service.validateTools()
// Pipe output must be complete even when the child exits immediately.
for _ in 0..<20 {
let result = try await service.run(executable: URL(fileURLWithPath: "/usr/bin/printf"),
arguments: ["Grüße 🌱\nEND\n"])
try require(result.output == "Grüße 🌱\nEND\n", "Truncated process output")
}
let sleeping = Task { try await service.run(executable: URL(fileURLWithPath: "/bin/sleep"), arguments: ["10"]) }
try await Task.sleep(nanoseconds: 100_000_000)
let cancellationStart = Date()
sleeping.cancel()
do { _ = try await sleeping.value; throw TestFailure.failed("Process ignored cancellation") }
catch is CancellationError {}
try require(Date().timeIntervalSince(cancellationStart) < 2, "Slow process cancellation")
print("PASS process output and cancellation")
let analysis = Task {
try await cancellableAnalysis {
while true { try Task.checkCancellation(); Thread.sleep(forTimeInterval: 0.01) }
}
}
analysis.cancel()
do { _ = try await analysis.value; throw TestFailure.failed("Analysis ignored cancellation") }
catch is CancellationError {}
print("PASS analysis cancellation forwarding")
for (name, color, audio) in [("Z 1 Wochen alt V1.mp4", "red", true), ("A 2 Wochen alt V1.mp4", "blue", false)] {
var args = ["-v", "error", "-y", "-f", "lavfi", "-i", "color=c=\(color):s=160x120:r=10:d=4"]
if audio { args += ["-f", "lavfi", "-i", "sine=frequency=440:duration=4", "-c:a", "aac"] }
args += ["-c:v", "libx264", "-pix_fmt", "yuv420p", input.appendingPathComponent(name).path]
let result = try await service.run(executable: tools.ffmpeg, arguments: args)
try require(result.exitCode == 0, "Fixture generation: \(result.output)")
}
var settings = RenderSettings()
settings.inputFolder = input
settings.outputFile = root.appendingPathComponent("A.mp4")
settings.ffmpegExecutable = tools.ffmpeg
settings.ffprobeExecutable = tools.ffprobe
settings.videoEncoder = .x264
settings.outputFormat = .keepOriginal
settings.segmentLength = 2
settings.targetClipLength = 1
settings.transitionLength = 0
settings.fps = 10
settings.bestSegmentDetectionEnabled = false
settings.faceNormalizationEnabled = false
settings.stabilizationEnabled = false
settings.removeAudio = true
settings.startOffset = 0
let processor = VideoProcessor()
processor.start(settings: settings)
try await wait(processor)
let first = processor.project!
try require(first.clips.count == 2, "Clip discovery")
try require(RenderWorkspace.isOwned(first.cacheDirectoryURL, by: first.outputFileURL), "Cache ownership")
let originalOutput = try Data(contentsOf: first.outputFileURL)
let metadata = try await probe(first.outputFileURL, tools: service, ffprobe: tools.ffprobe)
let streams = metadata["streams"] as! [[String: Any]]
try require(streams.count == 1 && streams[0]["width"] as? Int == 160, "Silent native-size render")
print("PASS two-clip render, ownership, dimensions and audio removal")
let alias = root.appendingPathComponent("GrowthLapse-alias.growthlapse-cache")
try manager.createSymbolicLink(at: alias, withDestinationURL: first.cacheDirectoryURL)
try require(!RenderWorkspace.isOwned(alias, by: first.outputFileURL), "Symlink cache accepted")
var sourceTarget = settings
sourceTarget.outputFile = first.clips[0].sourceURL
let sourceBytes = try Data(contentsOf: first.clips[0].sourceURL)
let protect = VideoProcessor()
protect.start(settings: sourceTarget)
while protect.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
guard case .failed = protect.state else { throw TestFailure.failed("Source overwrite permitted") }
let protectedBytes = try Data(contentsOf: first.clips[0].sourceURL)
try require(sourceBytes == protectedBytes, "Source was modified")
print("PASS symlink deletion and accidental source overwrite blocked")
// A failed rerender must leave all previous work byte-for-byte intact.
var invalid = settings
invalid.outputFile = root.appendingPathComponent("missing/B.mp4")
processor.rerenderProject(settings: invalid)
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
guard case .failed = processor.state else { throw TestFailure.failed("Invalid destination accepted") }
let afterFailure = try Data(contentsOf: first.outputFileURL)
try require(afterFailure == originalOutput, "Existing output changed on failure")
try require(manager.fileExists(atPath: first.clips[0].normalizedClipPath), "Old cache lost")
settings.outputFile = root.appendingPathComponent("B.mp4")
processor.rerenderProject(settings: settings)
try await wait(processor)
let second = processor.project!
try require(second.outputFileURL == settings.outputFile, "Project output not migrated")
try require(second.cacheDirectoryPath != first.cacheDirectoryPath, "Cache not isolated")
try require(processor.logs.contains(where: { $0.contains("Cache verwendet") }), "Cache reuse missing")
try require(manager.fileExists(atPath: first.clips[0].normalizedClipPath), "Save-as deleted A cache")
try require(second.settings?.renderSettings == settings, "Project settings not saved")
let loaded = VideoProcessor()
try require(loaded.loadProject(from: GrowthLapseProject.url(for: second.outputFileURL)) != nil, "Project reload")
print("PASS failure preservation, save-as, cache reuse and project reload")
// Exercise failure after normalization, while FFmpeg is producing the final video.
let wrapper = root.appendingPathComponent("ffmpeg-fail")
let quotedFFmpeg = "'" + tools.ffmpeg.path.replacingOccurrences(of: "'", with: "'\\''") + "'"
let wrapperText = "#!/bin/bash\nfor arg in \"$@\"; do\n if [ \"$arg\" = -filter_complex ]; then exit 42; fi\ndone\nexec \(quotedFFmpeg) \"$@\"\n"
try wrapperText.write(to: wrapper, atomically: true, encoding: .utf8)
try manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path)
var failingSettings = settings
failingSettings.ffmpegExecutable = wrapper
failingSettings.transitionLength = 0.2
let priorVideo = try Data(contentsOf: second.outputFileURL)
let priorProject = try Data(contentsOf: GrowthLapseProject.url(for: second.outputFileURL))
processor.rerenderProject(settings: failingSettings)
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
guard case .failed = processor.state else { throw TestFailure.failed("End-encoder failure not surfaced") }
let failedVideo = try Data(contentsOf: second.outputFileURL)
let failedProject = try Data(contentsOf: GrowthLapseProject.url(for: second.outputFileURL))
try require(failedVideo == priorVideo && failedProject == priorProject, "End-encoder failure damaged saved work")
print("PASS end-encoder failure preserves video and project")
let transaction = try RenderWorkspace.create(for: root.appendingPathComponent("transaction.mp4"))
let firstStaged = transaction.appendingPathComponent("first")
let secondStaged = transaction.appendingPathComponent("second")
try Data("new".utf8).write(to: firstStaged)
try Data("new".utf8).write(to: secondStaged)
do {
try RenderWorkspace.publish([(firstStaged, second.outputFileURL),
(secondStaged, root.appendingPathComponent("missing/project.json"))], in: transaction)
throw TestFailure.failed("Publish unexpectedly succeeded")
} catch is POSIXError {}
let rolledBack = try Data(contentsOf: second.outputFileURL)
try require(rolledBack == priorVideo, "Publication rollback failed")
print("PASS publication rollback after partial publish")
// Normal rerender preserves manually selected segments; explicit planning applies new lengths.
settings.segmentLength = 1
processor.rerenderProject(settings: settings)
try await wait(processor)
try require(processor.project!.clips[0].segmentLength == 2, "Manual segment unexpectedly reset")
processor.rerenderProject(settings: settings, replanSegments: true)
try await wait(processor)
try require(processor.project!.clips.allSatisfy { $0.segmentLength == 1 }, "Replan ignored new length")
processor.reorderProject(sortMode: .filenameAscending)
try require(processor.project!.clips.allSatisfy(\.needsRender), "Reorder did not invalidate clips")
print("PASS explicit segment planning and reordered-cache invalidation")
settings.removeAudio = false
settings.transitionLength = 0.2
processor.rerenderProject(settings: settings)
try await wait(processor)
let mixed = try await probe(settings.outputFile!, tools: service, ffprobe: tools.ffprobe)
let mixedStreams = mixed["streams"] as! [[String: Any]]
let duration = Double((mixed["format"] as! [String: Any])["duration"] as! String)!
try require(mixedStreams.contains { $0["codec_type"] as? String == "audio" }, "Missing mixed audio")
try require(abs(duration - 1.8) < 0.25, "Crossfade duration \(duration)")
print("PASS crossfade and mixed source audio (\(duration)s)")
let beforeStopProject = processor.project!
let beforeStopOutput = try Data(contentsOf: beforeStopProject.outputFileURL)
let slowText = wrapperText.replacingOccurrences(of: "exit 42", with: "exec /bin/sleep 10")
try slowText.write(to: wrapper, atomically: true, encoding: .utf8)
failingSettings = settings
failingSettings.ffmpegExecutable = wrapper
processor.rerenderProject(settings: failingSettings)
let stopDeadline = Date().addingTimeInterval(10)
while processor.state.isRunning, processor.progressText != "Erzeuge Übergänge", Date() < stopDeadline {
try await Task.sleep(nanoseconds: 20_000_000)
}
try require(processor.progressText == "Erzeuge Übergänge", "Never reached final export")
try await Task.sleep(nanoseconds: 100_000_000)
processor.cancel()
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
let afterStopOutput = try Data(contentsOf: beforeStopProject.outputFileURL)
try require(processor.state == .cancelled && afterStopOutput == beforeStopOutput, "Active cancellation damaged output")
try require(manager.fileExists(atPath: beforeStopProject.clips[0].normalizedClipPath), "Active cancellation removed old cache")
print("PASS cancellation during final export preserves previous output and cache")
let changedSource = input.appendingPathComponent("Z 1 Wochen alt V1.mp4")
let replacement = try await service.run(executable: tools.ffmpeg, arguments: ["-v", "error", "-y",
"-f", "lavfi", "-i", "color=c=green:s=200x150:r=10:d=1.5", "-c:v", "libx264", changedSource.path])
try require(replacement.exitCode == 0, "Replacement fixture failed")
processor.rerenderProject(settings: settings)
try await wait(processor)
guard let refreshed = processor.project!.clips.first(where: {
$0.sourceURL.resolvingSymlinksInPath().standardizedFileURL.path == changedSource.resolvingSymlinksInPath().standardizedFileURL.path
}) else { throw TestFailure.failed("Changed source missing from project") }
try require(refreshed.width == 200 && abs(refreshed.duration - 1.5) < 0.1, "Source metadata was not refreshed")
try require(refreshed.segmentStart + refreshed.segmentLength <= refreshed.duration + 0.001, "Changed source segment out of bounds")
print("PASS externally changed source invalidates cache and refreshes metadata")
let soloFolder = root.appendingPathComponent("solo", isDirectory: true)
try manager.createDirectory(at: soloFolder, withIntermediateDirectories: true)
try manager.copyItem(at: input.appendingPathComponent("A 2 Wochen alt V1.mp4"), to: soloFolder.appendingPathComponent("solo.mp4"))
var soloSettings = settings
soloSettings.inputFolder = soloFolder
soloSettings.outputFile = root.appendingPathComponent("Solo.mp4")
soloSettings.faceNormalizationEnabled = true
soloSettings.bestSegmentDetectionEnabled = true
soloSettings.stabilizationStrength = .light
let availability = try await service.availableFilters(ffmpeg: tools.ffmpeg)
soloSettings.stabilizationEnabled = availability.hasVidstab || availability.hasDeshake
let solo = VideoProcessor()
solo.start(settings: soloSettings)
try await wait(solo)
try require(solo.project!.clips.count == 1, "Single clip failed")
print("PASS single clip, Vision fallback and available stabilization (enabled: \(soloSettings.stabilizationEnabled))")
// A loaded JSON must not authorize recursive deletion of an arbitrary folder.
var tampered = processor.project!
let protected = root.appendingPathComponent("protected", isDirectory: true)
try manager.createDirectory(at: protected, withIntermediateDirectories: true)
let marker = protected.appendingPathComponent("keep.txt")
try Data("keep".utf8).write(to: marker)
tampered.cacheDirectoryPath = protected.path
for index in tampered.clips.indices {
tampered.clips[index].normalizedClipPath = protected.appendingPathComponent("clip-\(index).mp4").path
}
let tamperedURL = root.appendingPathComponent("tampered.json")
try JSONEncoder().encode(tampered).write(to: tamperedURL)
_ = loaded.loadProject(from: tamperedURL)
loaded.confirmProjectAndDeleteCache()
try require(manager.fileExists(atPath: marker.path), "Unsafe cache deletion")
tampered.clips = []
try JSONEncoder().encode(tampered).write(to: tamperedURL)
try require(loaded.loadProject(from: tamperedURL) == nil, "Empty project accepted")
print("PASS unsafe deletion blocked and empty project rejected")
let goodProject = processor.project!
processor.rerenderProject(settings: settings)
processor.cancel()
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
try require(processor.state == .cancelled, "Immediate cancellation failed")
try require(manager.fileExists(atPath: goodProject.clips[0].normalizedClipPath), "Cancellation destroyed prior cache")
processor.confirmProjectAndDeleteCache()
try require(!manager.fileExists(atPath: goodProject.cacheDirectoryPath), "Owned cache was not deleted")
try require(manager.fileExists(atPath: goodProject.outputFilePath), "Confirm deleted output")
print("PASS cancelled rerender preserves old cache and confirm only deletes owned cache")
print("All integration checks passed.")
}
}
+2
View File
@@ -1,5 +1,7 @@
# Technische Bestandsaufnahme # Technische Bestandsaufnahme
> Historische Bestandsaufnahme vor Version 0.3.0. Umgesetzte Korrekturen und aktuelle Grenzen stehen in [VERBESSERUNGEN.md](VERBESSERUNGEN.md).
Stand: 13. September 2026, Ausgangscommit `0b85f7d73a8ae09a8711038a4d08ae34642c6243`. Stand: 13. September 2026, Ausgangscommit `0b85f7d73a8ae09a8711038a4d08ae34642c6243`.
## Bestand ## Bestand
+109
View File
@@ -0,0 +1,109 @@
# Funktionsanalyse GrowthLapse
> Historische Bestandsaufnahme vor Version 0.3.0. Umgesetzte Korrekturen und aktuelle Grenzen stehen in [VERBESSERUNGEN.md](VERBESSERUNGEN.md).
Stand: 13. September 2026, Commit `c530fe0`. Grundlage ist die Prüfung der aktiven Quellcodepfade. Die unten angegebenen Szenarien sind aus dem Code hergeleitet, nicht durch vollständige UI-/Video-Tests reproduziert. Es wurden keine Laufzeitfunktionen geändert.
## Funktionsumfang und Ablauf
1. **Import:** Dateien eines Ordners einlesen, Ausgabe ausschließen, Dauer und Abmessungen mit FFprobe ermitteln, sortieren und Varianten auswählen.
2. **Segmentplanung:** Ausschnitt aus Mitte/Offset oder anhand von Gesicht-/Augenbewertung wählen. Segmentlänge auf Quelldauer begrenzen.
3. **Verarbeitung:** Optional stabilisieren, Gesicht ausrichten, Geschwindigkeit und Format normalisieren. Audio entfernen oder zeitlich anpassen; bei fehlendem Audio eine stille Spur erzeugen.
4. **Zusammenfügen:** Ohne Übergänge Stream-Copy; mit Übergängen Video-Xfade und optional Audio-Crossfade.
5. **Nachbearbeitung:** Projekt speichern/laden, Segmentstarts und Varianten ändern, Clips umsortieren und Cache wiederverwenden.
Die Funktionen decken einen persönlichen Wachstums-Zeitraffer gut ab. Der größte Verbesserungsbedarf liegt bei verlässlicher Nachbearbeitung und Erhaltung bereits erstellter Ergebnisse.
## Priorisierte Befunde
### P1: Ausgabeziel und Projekt laufen beim erneuten Rendern auseinander
**Szenario:** Projekt A rendern, in der Oberfläche Ausgabe B wählen, „Änderungen neu rendern“ ausführen.
**Befund:** `VideoProcessor.swift:243` verwendet das neue Ziel und erstellt dessen Arbeitsordner. Bei Zeile 275 wird aber das alte Projekt unverändert übernommen. Dessen `outputFilePath`, `cacheDirectoryPath` und Clip-Pfade zeigen weiter auf A; `saveProject` bei Zeile 1638 speichert entsprechend bei A. Auch die gespeicherten Zielabmessungen werden bei Formatänderungen nicht aktualisiert.
**Folge:** Video B und gespeichertes Projekt passen nicht zusammen. Bei fehlendem alten Cache können Schreibzugriffe auf die alten Clip-Pfade scheitern, obwohl der neue Cache angelegt wurde.
**Vorschlag:** Einen expliziten „Projekt speichern unter“-Ablauf einführen. Ausgabe, Cache, Clip-Pfade und Zielabmessungen gemeinsam aktualisieren; vorhandene gültige Clips kontrolliert übernehmen.
**Abnahmetest:** A nach B exportieren, B-Projekt laden und einen Clip erneut rendern. A bleibt unverändert, alle B-Verweise sind konsistent.
### P1: Abbruch und Fehler können vorhandene Ergebnisse oder Cache vernichten
**Befund:** `handleCancellation` (`VideoProcessor.swift:2054`) löscht `temporaryDirectory` vollständig, auch wenn dies der bestehende Review-Cache ist. Ein neuer Render löscht den alten Cache bereits bei `createWorkingDirectory` (Zeile 1469), vor erfolgreichem Import. FFmpeg schreibt mit `-y` direkt ins Ausgabeziel; `replaceOutput` (Zeile 1650) löscht zunächst das alte Ergebnis und kopiert danach.
**Folge:** Ein abgebrochener Nachbearbeitungsversuch entfernt auch unveränderte Cache-Clips. Ein Fehler während der Endausgabe kann ein zuvor gutes Video durch eine unvollständige Datei ersetzen.
**Vorschlag:** Neue oder geänderte Clips und Endausgabe zunächst in einen separaten Arbeitsbereich schreiben, nach Erfolg übernehmen. Bei Abbruch nur Dateien des aktuellen Durchlaufs entfernen. Bereits bestätigte Ergebnisse bis zur erfolgreichen Übernahme erhalten.
**Abnahmetest:** Ein vorhandenes Ergebnis samt Cache sichern, Neurender während Normalisierung und Endausgabe abbrechen sowie einen Schreibfehler provozieren. Das bisherige Ergebnis bleibt abspielbar und unveränderte Cache-Clips bleiben erhalten.
### P1: Cache-Löschung vertraut ungeprüften Projektpfaden
**Befund:** `loadProject` (`VideoProcessor.swift:160`) decodiert externe JSON-Pfade ohne Verzeichnisvalidierung. „Ergebnis bestätigen“ entfernt bei Zeile 191 rekursiv genau `cacheDirectoryPath`.
**Folge:** Eine falsch bearbeitete oder fremde Projektdatei kann ein anderes beschreibbares Verzeichnis als Cache deklarieren. Die Löschaktion ist nicht auf einen von GrowthLapse erzeugten Cache begrenzt.
**Vorschlag:** Cache-Verzeichnis über eine eigene Kennung und validierte Projektzuordnung absichern; Pfade einschließlich Symlinks prüfen. Ungültige Projekte vor Übernahme ablehnen. Löschfehler anzeigen, statt anschließend immer Erfolg zu protokollieren.
**Abnahmetest:** Ein Projekt verweist auf einen separaten Testordner mit einer Markerdatei. Laden oder Löschen wird abgelehnt; Marker bleibt erhalten. Ausschließlich mit temporären Testverzeichnissen prüfen.
### P2: Globale Segmentparameter werden bei Nachbearbeitung ignoriert
**Szenario:** Nach dem ersten Render die Ausschnittlänge von 8 auf 4 Sekunden ändern und erneut rendern.
**Befund:** `normalizeVideos` (`VideoProcessor.swift:915`) verwendet weiterhin `projectClip.segmentLength`. `makeProject` und `initialSegmentStart` laufen nur für neue Projekte. Der Fingerprint (Zeile 1580) enthält weder Ausschnittlänge noch Offset oder automatische Segmentwahl.
**Folge:** Die Oberfläche suggeriert die Übernahme geänderter Einstellungen, die Segmentplanung bleibt jedoch unverändert. Auch die globale Variantenwahl wird beim Neurender nicht erneut angewandt.
**Vorschlag:** Einstellungen als „nur für neuen Import“ kennzeichnen oder eine ausdrückliche Aktion „Segmente neu berechnen“ anbieten. Manuelle Clip-Korrekturen dabei gezielt erhalten oder nach Auswahl ersetzen.
**Abnahmetest:** Globale Änderung verändert die geplanten Clips nachvollziehbar oder die Oberfläche erklärt eindeutig, warum sie nicht gilt.
### P2: Eingebrannte Clipnummern bleiben nach Umsortieren veraltet
**Befund:** `reorderedProject` (`VideoProcessor.swift:1603`) ändert `index`, setzt aber nicht `needsRender`. Der Fingerprint berücksichtigt keine Reihenfolge. `normalizeVideos` verwendet gültige Cache-Clips unverändert; darin ist die Nummer bereits eingebrannt.
**Folge:** Nach geänderter Sortierung stimmen Nummer im Video und Position in der Projektliste nicht mehr überein.
**Vorschlag:** Bei aktivierten Nummern verschobene Clips invalidieren oder Nummern erst beim finalen Export einblenden.
**Abnahmetest:** Zwei Clips mit Nummern rendern, Reihenfolge wechseln, neu rendern. Sichtbare Nummern folgen der neuen Position.
### P2: STOP erreicht laufende Bildanalysen nicht direkt
**Befund:** `BestSegmentAnalyzer.detectBestSegment` (Zeile 2459) und `FaceAlignmentAnalyzer.detect` (Zeile 2605) starten `Task.detached` und warten auf `.value`. Ein Handle zur expliziten Weiterleitung des Abbruchs fehlt. Die Abbruchprüfungen laufen innerhalb des eigenständigen Tasks.
**Folge:** STOP kann während einer umfangreichen Analyse bis zu deren Ende warten, obwohl der äußere Render-Task bereits abgebrochen ist.
**Vorschlag:** Analyse-Task per Cancellation-Handler explizit abbrechen und CancellationError nicht als gewöhnlichen Analyse-Fallback behandeln.
**Abnahmetest:** Lange Analyse abbrechen; messen, dass nach Abschluss der gerade laufenden Einzeloperation keine weiteren Frames analysiert werden.
### P2: Projekte speichern keine vollständigen Render-Einstellungen
**Befund:** `GrowthLapseProject` speichert Clipdaten und Fingerprint, aber keinen vollständigen Einstellungssatz. `ContentView.swift:620` lädt nur Ausgabe- und Eingabepfad ins aktuelle Einstellungsmodell.
**Folge:** Projekt A nach Arbeit an B laden und neu rendern kann A mit den globalen Einstellungen von B erzeugen. Ein Fingerprint erkennt Unterschiede, stellt aber die ursprünglichen Werte nicht wieder her.
**Vorschlag:** Versioniertes Projektschema mit Render-Einstellungen; Migration alter Dateien mit sichtbarem Hinweis. Werkzeugsuche und andere reine Rechnerpräferenzen separat halten.
**Abnahmetest:** Zwei Projekte mit verschiedenen FPS, Audio- und Ausgabeformaten abwechselnd laden; jedes stellt seine eigenen Einstellungen wieder her.
## Weitere Verbesserungen
- **Vorabprüfung:** Filter und Encoder vor dem Rendern vollständig prüfen. VidStab-Fallback darf Deshake nur wählen, wenn vorhanden (`VideoProcessor.swift:568`).
- **Cache-Gültigkeit:** Quellgröße und Änderungszeit oder einen Inhaltshash erfassen. Ein extern ersetztes Video am selben Pfad invalidiert aktuell keinen Cache automatisch.
- **Projektvalidierung:** Leere Cliplisten, ungültige Längen, fehlende Quellen und inkonsistente Pfade ablehnen. `videos[0]` in Zeile 271 setzt eine nicht leere Liste voraus; die Oberfläche verhindert nicht alle Wege mit einem leeren geladenen Projekt.
- **Prozessausgabe:** Den Pipe-Reader bis EOF auslesen und Exit sowie Ausgabe synchron zusammenführen (`FFmpegService.swift:137`). Ob heute Ausgabe verloren geht, muss ein gezielter Stresstest klären.
- **Skalierung:** Xfade öffnet alle Clips in einem Prozess (`VideoProcessor.swift:1387`). Mit 10/50/100 Clips Speicherbedarf und Laufzeit messen, dann gegebenenfalls blockweise rendern. Ein Ressourcenproblem ist hier noch nicht gemessen.
- **Bedienung:** Importliste vor dem Rendern, geschätzte Enddauer, Vergleich Original/Ergebnis, sichtbare Kennzeichnung gecachter und neu zu rendernder Clips. Gesichtsauswahl bei mehreren Personen wäre eine sinnvolle Erweiterung.
- **Portabilität:** Relative Projektpfade und eine Funktion zum Wiederfinden verschobener Quellen ergänzen.
## Empfohlene Reihenfolge
1. Ausgabe und Cache gegen Abbruch/Fehler schützen, geladene Pfade validieren.
2. Projektziele und Einstellungen konsistent machen; Segment-Neuberechnung und Cache-Invalidierung klären.
3. STOP für Analysen, Werkzeug-Vorabprüfung und Prozessausgabe absichern.
4. Automatisierte Integrationstests mit synthetischen Videos ergänzen: ein/zwei Clips, Audio gemischt, Übergänge an/aus, Formatwechsel, Neurender, Speichern/Laden und Abbruch. Dauer, Auflösung, FPS und Audiospuren per FFprobe prüfen.
5. Erst danach größere Komfortfunktionen oder das deaktivierte Foto-Morphing ausbauen.
+49
View File
@@ -0,0 +1,49 @@
# GrowthLapse 0.3.0 – zuverlässige Nachbearbeitung
## Änderungen
- Jeder Render erhält einen eigenen Arbeitsbereich. Bestehende Zwischenclips werden nur gelesen/kopiert. Abbruch und Renderfehler entfernen ausschließlich den neuen Arbeitsbereich.
- Video und Projektdatei werden erst nach erfolgreichem Rendern übernommen. Dateiaustausch erfolgt per Rename; bei einem nachfolgenden Fehler wird zurückgerollt. Scheitert auch die Wiederherstellung, bleiben Sicherungen erhalten und ihr Pfad wird angezeigt.
- Ausgabeziel, Cache-Pfade und Projektabmessungen bleiben beim Neurender und bei einem neuen Ausgabeziel konsistent. Das ursprüngliche Projekt bleibt beim Export unter anderem Namen erhalten.
- Cache-Löschung verlangt eine Eigentumsmarkierung und passende Verzeichniszuordnung; Symlinks werden abgelehnt. Historische unmarkierte Caches werden nicht automatisch entfernt.
- Projektschema 2 speichert Render-Einstellungen. Alte Projekte bleiben lesbar; fehlende Einstellungen werden mit einem Hinweis kenntlich gemacht. Lokal gewählte FFmpeg-/FFprobe-Pfade werden beim Laden erhalten.
- Geänderte Quelldateien werden anhand von Größe/Änderungsdatum erkannt, erneut geprüft und neu gerendert. Segmentgrenzen werden an eine geänderte Quelldauer angepasst.
- Geänderte Reihenfolge invalidiert verschobene Clips, sodass eingebrannte Nummern korrekt erneuert werden.
- „Änderungen neu rendern“ erhält manuelle Ausschnitte. „Segmente neu planen“ wendet Länge, Offset und automatische Segmentwahl auf alle vorhandenen Clips an.
- Bildanalysen erhalten den Abbruch des aufrufenden Render-Tasks. FFmpeg-Aufrufe besitzen einen eigenen Abbruchzustand und lesen ihre Ausgabe bis EOF.
- Fehlende Encoder/Stabilisierungsfilter führen vor der Verarbeitung zu einer verständlichen Fehlermeldung.
- Leere/inkonsistente Projekte, ungültige Clipdaten und versehentlich gewählte Quellvideos als neue Ausgabe werden abgefangen.
- Nachbearbeitung ist während eines Renders gesperrt. Die Oberfläche zeigt die erwartete Ergebnisdauer, bietet eine Ergebnisvorschau und macht den Clip-Editor scrollbar.
- Versionierte Bundle-Metadaten liegen unter `Resources/Info.plist`; die historische App in `dist/` wird nicht als aktuelle Version ausgegeben.
## Verifikation
`bash scripts/integration-tests.sh` kompiliert die produktiven Render-Klassen ohne XCTest und prüft mit synthetischen Videos:
1. Neutrale Startwerte, Einstellungs-Roundtrip und ältere JSON-Einstellungen.
2. Vollständige Ausgabe kurzlebiger Prozesse einschließlich Unicode sowie Prozessabbruch.
3. Abbruchweiterleitung an eine Bildanalyse.
4. Zwei-Clip-Render mit Originalauflösung und entfernter Tonspur.
5. Ablehnung von Cache-Symlinks und versehentlichem Überschreiben von Quellen.
6. Erhalt der bisherigen Dateien bei ungültigem Ausgabeziel.
7. Neues Ausgabeziel, Cache-Wiederverwendung, Projektdatei und erneutes Laden.
8. Erhalt von Video/Projekt bei simuliertem FFmpeg-Fehler in der Endausgabe.
9. Wiederherstellung nach einem Fehler beim zweiten Dateiaustausch.
10. Erhalt manueller Ausschnitte, explizite Neuplanung und Invalidierung bei Sortierung.
11. Überblendung mit gemischten Quellen mit/ohne Audio; gemessene Gesamtdauer 1,8 Sekunden.
12. Abbruch während laufender Endausgabe ohne Verlust des alten Videos/Caches.
13. Erneute Metadatenanalyse und gültige Segmentgrenzen nach Änderung einer Quelle.
14. Einzelclip mit Stabilisierung und Vision-Fallback bei fehlendem Gesicht.
15. Schutz fremder Verzeichnisse, Ablehnung leerer Projekte, sofortiger Abbruch und kontrollierte Cache-Löschung.
Diese Integrationstests sind auf dem lokalen Mac bestanden. Die bestehenden XCTest-Dateien benötigen weiterhin vollständiges Xcode; ihre Einstellungsfälle werden zusätzlich im Integrationstest geprüft. Release-Build und lokale Ad-hoc-Signierung funktionieren. Projektladen, Neurender, das korrigierte Clip-Layout und Wiedergabe in der neuen Ergebnisvorschau wurden zusätzlich über die native Oberfläche mit Testdateien geprüft.
## Verbleibende Grenzen
- Die Übernahme mehrerer Dateien ist nicht gegen einen Stromausfall zwischen zwei Dateiaustauschen abgesichert.
- Während des Renders wird zusätzlicher Speicherplatz für neue Clips und Sicherungen benötigt.
- Cache-Erkennung nutzt Größe und Änderungsdatum, keinen vollständigen Inhaltshash.
- Reale Gesichtsaufnahmen, HDR-Farben und sehr große Clipmengen benötigen weiterhin Qualitäts-/Belastungstests. Synthetische Bilder belegen keine Qualität der Gesichtsausrichtung.
- Foto-Morphing bleibt deaktiviert. Notarisierung, universelle Builds und Lizenzentscheidung sind nicht Teil dieser Überarbeitung.
Für isolierte Oberflächentests kann `GROWTHLAPSE_SETTINGS_PATH` auf einen absoluten Pfad zu einer separaten Einstellungsdatei gesetzt werden.
+1 -1
View File
@@ -7,7 +7,7 @@ bin_dir="$(swift build -c release --show-bin-path)"
app="build/GrowthLapse.app" app="build/GrowthLapse.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$bin_dir/GrowthLapse" "$app/Contents/MacOS/GrowthLapse" cp "$bin_dir/GrowthLapse" "$app/Contents/MacOS/GrowthLapse"
cp dist/GrowthLapse.app/Contents/Info.plist "$app/Contents/Info.plist" cp Resources/Info.plist "$app/Contents/Info.plist"
cp dist/GrowthLapse.app/Contents/Resources/GrowthLapse.icns "$app/Contents/Resources/GrowthLapse.icns" cp dist/GrowthLapse.app/Contents/Resources/GrowthLapse.icns "$app/Contents/Resources/GrowthLapse.icns"
codesign --force --deep --sign - "$app" codesign --force --deep --sign - "$app"
printf 'App erstellt: %s/%s\n' "$PWD" "$app" printf 'App erstellt: %s/%s\n' "$PWD" "$app"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p .build/integration
swiftc -parse-as-library \
Sources/GrowthLapse/RenderSettings.swift \
Sources/GrowthLapse/RenderWorkspace.swift \
Sources/GrowthLapse/FFmpegService.swift \
Sources/GrowthLapse/VideoProcessor.swift \
Tests/Integration/RenderIntegration.swift \
-o .build/integration/render-tests
.build/integration/render-tests