commit 4ea04dc3e4d590a5f48d8d35c92729c66abab4c3 Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue Jun 2 20:36:04 2026 +0200 Initial GrowthLapse app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f26127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.build/ +.swiftpm/ +.DS_Store +*.mp4 +*.mov +*.m4v +*.avi +*.mkv +*.webm +*.trf +*.growthlapse-report.txt +DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..17d55dd --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift new file mode 100644 index 0000000..e2e55fe --- /dev/null +++ b/Sources/GrowthLapse/ContentView.swift @@ -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 { + Binding( + get: { + if case .failed = processor.state { + return true + } + return false + }, + set: { newValue in + if !newValue, case .failed = processor.state { + processor.state = .idle + } + } + ) + } + + private func pickerRow(title: String, value: String, buttonTitle: String, help: String? = nil, action: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + Spacer() + Button(buttonTitle, action: action) + } + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + .help(help ?? "") + } + + private func doubleStepper( + _ title: String, + value: Binding, + range: ClosedRange, + step: Double, + suffix: String, + help: String + ) -> some View { + Stepper(value: value, in: range, step: step) { + HStack { + Text(title) + Spacer() + Text("\(formattedNumber(value.wrappedValue)) \(suffix)") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .help(help) + } + + private func percentSlider(_ title: String, value: Binding, range: ClosedRange, help: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + Spacer() + Text("\(Int(value.wrappedValue * 100)) %") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + Slider(value: value, in: range, step: 0.01) + } + .help(help) + } + + private func formattedNumber(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.minimumFractionDigits = value.rounded() == value ? 0 : 1 + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + private func selectInputFolder() { + let panel = NSOpenPanel() + panel.title = "Input-Ordner auswählen" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK { + settings.inputFolder = panel.url + } + } + + private func selectOutputFile() { + let panel = NSSavePanel() + panel.title = "Output-Datei auswählen" + panel.allowedContentTypes = [.mpeg4Movie] + panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "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) + } + } +} diff --git a/Sources/GrowthLapse/FFmpegService.swift b/Sources/GrowthLapse/FFmpegService.swift new file mode 100644 index 0000000..13df718 --- /dev/null +++ b/Sources/GrowthLapse/FFmpegService.swift @@ -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[.. 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 + } +} diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift new file mode 100644 index 0000000..d24747d --- /dev/null +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -0,0 +1,1129 @@ +import AppKit +import AVFoundation +import Foundation +import Vision + +@MainActor +final class VideoProcessor: ObservableObject { + @Published var progress: Double = 0 + @Published var progressText: String = "Bereit" + @Published var logs: [String] = [] + @Published var state: RenderState = .idle + @Published var warningText: String? + + private let service = FFmpegService() + private var renderTask: Task? + private var temporaryDirectory: URL? + + func start(settings: RenderSettings) { + guard !state.isRunning else { + return + } + + logs.removeAll() + warningText = nil + progress = 0 + progressText = "Vorbereitung" + state = .running + + let settingsCopy = settings + renderTask = Task { + do { + let outputURL = try await render(settings: settingsCopy) + guard !Task.isCancelled else { + throw VideoProcessingError.cancelled + } + state = .finished(outputURL) + progress = 1 + progressText = "Fertig" + appendLog("Fertig: \(outputURL.path)") + } catch is CancellationError { + handleCancellation() + } catch VideoProcessingError.cancelled { + handleCancellation() + } catch { + state = .failed(error.localizedDescription) + progressText = "Fehler" + appendLog("Fehler: \(error.localizedDescription)") + } + renderTask = nil + } + } + + func cancel() { + guard state.isRunning else { + return + } + renderTask?.cancel() + Task { + await service.cancel() + } + handleCancellation() + } + + func showInFinder(url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private func render(settings: RenderSettings) async throws -> URL { + var effectiveSettings = settings + try validate(effectiveSettings) + let tools = try await service.validateTools( + ffmpegOverride: effectiveSettings.ffmpegExecutable, + ffprobeOverride: effectiveSettings.ffprobeExecutable + ) + appendLog("ffmpeg: \(tools.ffmpeg.path)") + appendLog("ffprobe: \(tools.ffprobe.path)") + + if effectiveSettings.stabilizationEnabled { + let filters = try await service.availableFilters(ffmpeg: tools.ffmpeg) + effectiveSettings.stabilizationMethod = resolveStabilizationMethod( + requested: effectiveSettings.stabilizationMethod, + availability: filters + ) + appendLog("Stabilisierung: \(effectiveSettings.stabilizationMethod.rawValue), \(effectiveSettings.stabilizationStrength.rawValue)") + } + + let inputFolder = settings.inputFolder! + let outputFile = settings.outputFile! + let tempDirectory = try createTemporaryDirectory(keep: settings.keepIntermediateClips, outputFile: outputFile) + temporaryDirectory = tempDirectory + appendLog("Temporärer Ordner: \(tempDirectory.path)") + + let videos = try await discoverVideos(in: inputFolder, sortMode: settings.sortMode, ffprobe: tools.ffprobe, excluding: outputFile) + appendLog("\(videos.count) Video(s) gefunden.") + let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0]) + appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)") + + let normalizedClips = try await normalizeVideos(videos, settings: effectiveSettings, targetDimensions: targetDimensions, ffmpeg: tools.ffmpeg, tempDirectory: tempDirectory) + + if effectiveSettings.transitionLength <= 0 { + try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf) + } else { + try await crossfadeIteratively(clips: normalizedClips, settings: effectiveSettings, targetDimensions: targetDimensions, outputFile: outputFile, ffmpeg: tools.ffmpeg, tempDirectory: tempDirectory) + } + + let reportURL = writeRenderReport( + settings: effectiveSettings, + outputFile: outputFile, + targetDimensions: targetDimensions, + inputClipCount: videos.count + ) + appendLog("Render-Protokoll: \(reportURL.path)") + + if !settings.keepIntermediateClips { + try? FileManager.default.removeItem(at: tempDirectory) + } else { + cleanupTransformFiles(in: tempDirectory) + } + + return outputFile + } + + private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod { + switch requested { + case .vidstab: + if availability.hasVidstab { + return .vidstab + } + let warning = "VidStab ist in diesem ffmpeg nicht verfügbar. GrowthLapse nutzt automatisch Deshake als Fallback." + warningText = warning + appendLog("Warnung: \(warning)") + return .deshake + case .deshake: + if !availability.hasDeshake { + let warning = "Deshake wurde in ffmpeg nicht gefunden. Der Render kann bei aktivierter Stabilisierung fehlschlagen." + warningText = warning + appendLog("Warnung: \(warning)") + } + return .deshake + } + } + + private func validate(_ settings: RenderSettings) throws { + guard settings.inputFolder != nil else { + throw VideoProcessingError.invalidSettings("Bitte wähle einen Input-Ordner aus.") + } + guard settings.outputFile != nil else { + throw VideoProcessingError.invalidSettings("Bitte wähle eine Output-Datei aus.") + } + guard settings.segmentLength > 0 else { + throw VideoProcessingError.invalidSettings("Die Ausschnittlänge muss größer als 0 sein.") + } + guard settings.targetClipLength > 0 else { + throw VideoProcessingError.invalidSettings("Die Ziellänge pro Clip muss größer als 0 sein.") + } + guard settings.transitionLength >= 0 else { + throw VideoProcessingError.invalidSettings("Die Übergangslänge darf nicht negativ sein.") + } + guard settings.fps > 0 else { + throw VideoProcessingError.invalidSettings("FPS muss größer als 0 sein.") + } + guard settings.crf >= 0, settings.crf <= 51 else { + throw VideoProcessingError.invalidSettings("CRF muss zwischen 0 und 51 liegen.") + } + guard settings.hardwareBitrateMbps > 0 else { + throw VideoProcessingError.invalidSettings("Die Hardware-Bitrate muss größer als 0 sein.") + } + guard settings.targetFaceHeightRatio > 0, settings.targetFaceHeightRatio < 1 else { + throw VideoProcessingError.invalidSettings("Die Ziel-Gesichtshöhe muss zwischen 0 und 100 Prozent liegen.") + } + if settings.transitionLength >= settings.targetClipLength { + throw VideoProcessingError.invalidSettings("Die Übergangslänge muss kleiner als die Ziellänge pro Clip sein.") + } + } + + private func discoverVideos(in folder: URL, sortMode: SortMode, ffprobe: URL, excluding outputFile: URL?) async throws -> [VideoFile] { + let supportedExtensions = Set(["mp4", "mov", "m4v", "avi", "mkv", "webm"]) + let excludedPath = outputFile?.standardizedFileURL.path + let contents = try FileManager.default.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + + var urls = contents.filter { url in + supportedExtensions.contains(url.pathExtension.lowercased()) + && url.standardizedFileURL.path != excludedPath + } + if let excludedPath, contents.contains(where: { $0.standardizedFileURL.path == excludedPath }) { + appendLog("Output-Datei aus Input-Liste ausgeschlossen: \(URL(fileURLWithPath: excludedPath).lastPathComponent)") + } + switch sortMode { + case .filenameAscending: + urls.sort { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + case .modificationDateAscending: + urls.sort { + let left = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let right = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return left < right + } + } + + guard !urls.isEmpty else { + throw VideoProcessingError.noVideosFound + } + + var result: [VideoFile] = [] + for url in urls { + try Task.checkCancellation() + let duration = try await probeDuration(url: url, ffprobe: ffprobe) + let dimensions = try await probeDimensions(url: url, ffprobe: ffprobe) + result.append(VideoFile(url: url, duration: duration, width: dimensions.width, height: dimensions.height)) + appendLog("Dauer \(url.lastPathComponent): \(formatSeconds(duration)), \(dimensions.width)x\(dimensions.height)") + } + return result + } + + private func probeDuration(url: URL, ffprobe: URL) async throws -> Double { + let result = try await service.run( + executable: ffprobe, + arguments: [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + url.path + ] + ) + + guard result.exitCode == 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + + let trimmed = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + guard let duration = Double(trimmed), duration.isFinite, duration > 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + return duration + } + + private func probeDimensions(url: URL, ffprobe: URL) async throws -> VideoDimensions { + let result = try await service.run( + executable: ffprobe, + arguments: [ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=s=x:p=0", + url.path + ] + ) + + guard result.exitCode == 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + + let firstLine = result.output + .split(separator: "\n") + .map(String.init) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parts = firstLine.split(separator: "x") + + guard parts.count == 2, + let width = Int(parts[0]), + let height = Int(parts[1]), + width > 0, + height > 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + + return VideoDimensions(width: evenDimension(width), height: evenDimension(height)) + } + + private func normalizeVideos(_ videos: [VideoFile], settings: RenderSettings, targetDimensions: VideoDimensions, ffmpeg: URL, tempDirectory: URL) async throws -> [URL] { + var outputURLs: [URL] = [] + let normalizationWeight = settings.transitionLength > 0 ? 0.75 : 0.9 + + for (index, video) in videos.enumerated() { + try Task.checkCancellation() + let clipNumber = index + 1 + let outputURL = tempDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", clipNumber)) + let actualSegmentLength = min(settings.segmentLength, video.duration) + let start: Double + + if settings.takeMiddleSegment, video.duration > settings.segmentLength { + start = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset) + } else { + start = max(0, settings.startOffset) + } + + let safeStart = min(start, max(0, video.duration - actualSegmentLength)) + let speedFactor = max(0.0001, actualSegmentLength / settings.targetClipLength) + let faceCrop = try await detectFaceCropIfNeeded( + source: video.url, + start: safeStart, + duration: actualSegmentLength, + settings: settings, + targetDimensions: targetDimensions, + clipNumber: clipNumber, + totalClips: videos.count + ) + + if settings.stabilizationEnabled { + let segmentURL = tempDirectory.appendingPathComponent(String(format: "segment_%04d.mp4", clipNumber)) + let stabilizedURL = tempDirectory.appendingPathComponent(String(format: "stabilized_%04d.mp4", clipNumber)) + try await extractSegment( + source: video.url, + output: segmentURL, + start: safeStart, + duration: actualSegmentLength, + settings: settings, + ffmpeg: ffmpeg, + clipNumber: clipNumber, + totalClips: videos.count + ) + let sourceForNormalization = try await stabilizeSegment( + segmentURL, + output: stabilizedURL, + video: video, + settings: settings, + ffmpeg: ffmpeg, + tempDirectory: tempDirectory, + clipNumber: clipNumber, + totalClips: videos.count + ) + try await normalizePreparedSegment( + source: sourceForNormalization, + output: outputURL, + actualSegmentLength: actualSegmentLength, + speedFactor: speedFactor, + faceCrop: faceCrop, + settings: settings, + targetDimensions: targetDimensions, + ffmpeg: ffmpeg, + clipNumber: clipNumber, + totalClips: videos.count + ) + } else { + try await normalizeSourceSegment( + source: video.url, + output: outputURL, + start: safeStart, + duration: actualSegmentLength, + speedFactor: speedFactor, + faceCrop: faceCrop, + settings: settings, + targetDimensions: targetDimensions, + ffmpeg: ffmpeg, + clipNumber: clipNumber, + totalClips: videos.count, + displayName: video.displayName + ) + } + + outputURLs.append(outputURL) + progress = Double(index + 1) / Double(videos.count) * normalizationWeight + } + + return outputURLs + } + + private func extractSegment( + source: URL, + output: URL, + start: Double, + duration: Double, + settings: RenderSettings, + ffmpeg: URL, + clipNumber: Int, + totalClips: Int + ) async throws { + progressText = "Segment \(clipNumber) von \(totalClips)" + appendLog("Schneide Segment \(source.lastPathComponent) @ \(formatSeconds(start)) für \(formatSeconds(duration)) -> \(output.lastPathComponent)") + var arguments = [ + "-y", + "-nostdin", + "-ss", decimal(start), + "-t", decimal(duration), + "-i", source.path, + "-map", "0:v:0", + "-an" + ] + arguments.append(contentsOf: videoEncoderArguments(settings)) + arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) + + let result = try await runFFmpeg(ffmpeg, arguments: arguments) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Segment schneiden \(source.lastPathComponent)", result.output) + } + } + + private func stabilizeSegment( + _ segment: URL, + output: URL, + video: VideoFile, + settings: RenderSettings, + ffmpeg: URL, + tempDirectory: URL, + clipNumber: Int, + totalClips: Int + ) async throws -> URL { + switch settings.stabilizationMethod { + case .vidstab: + do { + return try await stabilizeWithVidStab( + segment, + output: output, + video: video, + settings: settings, + ffmpeg: ffmpeg, + tempDirectory: tempDirectory, + clipNumber: clipNumber, + totalClips: totalClips + ) + } catch { + appendLog("Warnung: VidStab für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)") + appendLog("Fallback: Deshake für Clip \(clipNumber).") + do { + return try await stabilizeWithDeshake( + segment, + output: output, + settings: settings, + ffmpeg: ffmpeg, + clipNumber: clipNumber, + totalClips: totalClips + ) + } catch { + appendLog("Warnung: Deshake für Clip \(clipNumber) ebenfalls fehlgeschlagen: \(error.localizedDescription)") + appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.") + return segment + } + } + case .deshake: + do { + return try await stabilizeWithDeshake( + segment, + output: output, + settings: settings, + ffmpeg: ffmpeg, + clipNumber: clipNumber, + totalClips: totalClips + ) + } catch { + appendLog("Warnung: Deshake für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)") + appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.") + return segment + } + } + } + + private func stabilizeWithVidStab( + _ segment: URL, + output: URL, + video: VideoFile, + settings: RenderSettings, + ffmpeg: URL, + tempDirectory: URL, + clipNumber: Int, + totalClips: Int + ) async throws -> URL { + let transformFileName = String(format: "clip_%04d.trf", clipNumber) + let strength = settings.stabilizationStrength + let detectFilter = vidstabDetectFilter(settings: settings, video: video, transformFileName: transformFileName) + + progressText = "VidStab Analyse \(clipNumber) von \(totalClips)" + appendLog("VidStab Analyse nur auf Segment \(segment.lastPathComponent) -> \(transformFileName)") + if settings.stabilizationAnchorEnabled { + appendLog("VidStab Hintergrund-Anker: X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%") + } + let detectArguments = [ + "-y", + "-nostdin", + "-i", segment.path, + "-vf", detectFilter, + "-an", + "-f", "null", + "-" + ] + let detectResult = try await runFFmpeg(ffmpeg, arguments: detectArguments, currentDirectory: tempDirectory) + guard detectResult.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("VidStab Analyse Clip \(clipNumber)", detectResult.output) + } + + progressText = "VidStab Stabilisierung \(clipNumber) von \(totalClips)" + appendLog("VidStab Transform auf Segment \(segment.lastPathComponent) -> \(output.lastPathComponent)") + let transformFilter = [ + "vidstabtransform=input=\(transformFileName)", + "smoothing=\(strength.smoothing)", + "maxshift=\(strength.maxShift)", + "maxangle=\(decimal(strength.maxAngle))", + "optzoom=2", + "interpol=bicubic" + ].joined(separator: ":") + var transformArguments = [ + "-y", + "-nostdin", + "-i", segment.path, + "-vf", transformFilter, + "-an" + ] + transformArguments.append(contentsOf: videoEncoderArguments(settings)) + transformArguments.append(contentsOf: ["-movflags", "+faststart", output.path]) + + let transformResult = try await runFFmpeg(ffmpeg, arguments: transformArguments, currentDirectory: tempDirectory) + guard transformResult.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("VidStab Stabilisierung Clip \(clipNumber)", transformResult.output) + } + + return output + } + + private func stabilizeWithDeshake( + _ segment: URL, + output: URL, + settings: RenderSettings, + ffmpeg: URL, + clipNumber: Int, + totalClips: Int + ) async throws -> URL { + progressText = "Deshake \(clipNumber) von \(totalClips)" + appendLog("Deshake Stabilisierung auf Segment \(segment.lastPathComponent) -> \(output.lastPathComponent)") + let search = settings.stabilizationStrength.deshakeSearch + var arguments = [ + "-y", + "-nostdin", + "-i", segment.path, + "-vf", "deshake=x=\(search):y=\(search):w=32:h=32", + "-an" + ] + arguments.append(contentsOf: videoEncoderArguments(settings)) + arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) + + let result = try await runFFmpeg(ffmpeg, arguments: arguments) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Deshake Stabilisierung Clip \(clipNumber)", result.output) + } + + return output + } + + private func normalizePreparedSegment( + source: URL, + output: URL, + actualSegmentLength: Double, + speedFactor: Double, + faceCrop: FaceCrop?, + settings: RenderSettings, + targetDimensions: VideoDimensions, + ffmpeg: URL, + clipNumber: Int, + totalClips: Int + ) async throws { + progressText = "Normalisiere Clip \(clipNumber) von \(totalClips)" + appendLog("Normalisiere stabilisierten Clip -> \(output.lastPathComponent)") + var arguments = [ + "-y", + "-nostdin", + "-t", decimal(actualSegmentLength), + "-i", source.path, + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop), + "-t", decimal(settings.targetClipLength), + "-an" + ] + arguments.append(contentsOf: videoEncoderArguments(settings)) + arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) + + let result = try await runFFmpeg(ffmpeg, arguments: arguments) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Normalisieren Clip \(clipNumber)", result.output) + } + } + + private func normalizeSourceSegment( + source: URL, + output: URL, + start: Double, + duration: Double, + speedFactor: Double, + faceCrop: FaceCrop?, + settings: RenderSettings, + targetDimensions: VideoDimensions, + ffmpeg: URL, + clipNumber: Int, + totalClips: Int, + displayName: String + ) async throws { + progressText = "Clip \(clipNumber) von \(totalClips)" + appendLog("Normalisiere \(displayName) -> \(output.lastPathComponent)") + var arguments = [ + "-y", + "-nostdin", + "-ss", decimal(start), + "-t", decimal(duration), + "-i", source.path, + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop), + "-t", decimal(settings.targetClipLength), + ] + arguments.append(contentsOf: videoEncoderArguments(settings)) + arguments.append(contentsOf: ["-movflags", "+faststart"]) + if settings.removeAudio { + arguments.append("-an") + } else { + arguments.append(contentsOf: ["-af", "atempo=\(audioTempoChain(speedFactor))", "-c:a", "aac", "-b:a", "192k"]) + } + arguments.append(output.path) + + let result = try await runFFmpeg(ffmpeg, arguments: arguments) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Normalisieren von \(displayName)", result.output) + } + } + + private func concatenate(clips: [URL], outputFile: URL, ffmpeg: URL, crf: Int) async throws { + try Task.checkCancellation() + guard clips.count > 1 else { + try replaceOutput(with: clips[0], outputFile: outputFile) + return + } + + progressText = "Verbinde Clips" + let listFile = (temporaryDirectory ?? FileManager.default.temporaryDirectory).appendingPathComponent("concat.txt") + let list = clips.map { "file '\(escapeConcatPath($0.path))'" }.joined(separator: "\n") + try list.write(to: listFile, atomically: true, encoding: .utf8) + + let arguments = [ + "-y", + "-nostdin", + "-f", "concat", + "-safe", "0", + "-i", listFile.path, + "-c", "copy", + "-movflags", "+faststart", + outputFile.path + ] + + appendLog("Concat ohne Übergänge.") + let result = try await service.run(executable: ffmpeg, arguments: arguments) { [weak self] chunk in + Task { @MainActor in + self?.appendProcessOutput(chunk) + } + } + + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Concat", result.output) + } + progress = 1 + } + + private func crossfadeIteratively(clips: [URL], settings: RenderSettings, targetDimensions: VideoDimensions, outputFile: URL, ffmpeg: URL, tempDirectory: URL) async throws { + try Task.checkCancellation() + guard clips.count > 1 else { + try replaceOutput(with: clips[0], outputFile: outputFile) + return + } + + var current = clips[0] + var currentDuration = settings.targetClipLength + let totalSteps = clips.count - 1 + + for index in 1.. URL { + let base: URL + if keep { + base = outputFile.deletingPathExtension().deletingLastPathComponent() + } else { + base = FileManager.default.temporaryDirectory + } + + let directory = base.appendingPathComponent("GrowthLapse-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func replaceOutput(with source: URL, outputFile: URL) throws { + if FileManager.default.fileExists(atPath: outputFile.path) { + try FileManager.default.removeItem(at: outputFile) + } + try FileManager.default.copyItem(at: source, to: outputFile) + } + + private func escapeConcatPath(_ path: String) -> String { + path.replacingOccurrences(of: "'", with: "'\\''") + } + + private func vidstabDetectFilter(settings: RenderSettings, video: VideoFile, transformFileName: String) -> String { + let strength = settings.stabilizationStrength + let detect = "vidstabdetect=shakiness=\(strength.shakiness):accuracy=\(strength.accuracy):result=\(transformFileName)" + + guard settings.stabilizationAnchorEnabled else { + return detect + } + + let width = max(2, evenDimension(Int(Double(video.width) * settings.stabilizationAnchorSize))) + let height = max(2, evenDimension(Int(Double(video.height) * settings.stabilizationAnchorSize))) + let centerX = Double(video.width) * settings.stabilizationAnchorX + let centerY = Double(video.height) * settings.stabilizationAnchorY + let x = evenDimension(Int(clamp(centerX - Double(width) / 2, min: 0, max: Double(max(0, video.width - width))))) + let y = evenDimension(Int(clamp(centerY - Double(height) / 2, min: 0, max: Double(max(0, video.height - height))))) + + return "crop=\(width):\(height):\(x):\(y),\(detect)" + } + + private func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double { + Swift.max(minimum, Swift.min(maximum, value)) + } + + private func normalizationFilter(speedFactor: Double, fps: Int, targetDimensions: VideoDimensions, faceCrop: FaceCrop?) -> String { + var filters: [String] = [] + if let faceCrop { + filters.append("crop=\(faceCrop.width):\(faceCrop.height):\(faceCrop.x):\(faceCrop.y)") + } + filters.append("setpts=PTS/\(decimal(speedFactor))") + filters.append("fps=\(fps)") + filters.append("scale=\(targetDimensions.width):\(targetDimensions.height):force_original_aspect_ratio=decrease") + filters.append("pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2") + filters.append("setsar=1") + filters.append(contentsOf: sdrFilters()) + return filters.joined(separator: ",") + } + + private func detectFaceCropIfNeeded( + source: URL, + start: Double, + duration: Double, + settings: RenderSettings, + targetDimensions: VideoDimensions, + clipNumber: Int, + totalClips: Int + ) async throws -> FaceCrop? { + guard settings.faceNormalizationEnabled else { + return nil + } + + progressText = "Gesichtsanalyse \(clipNumber) von \(totalClips)" + appendLog("Vision Gesichtsanalyse: \(source.lastPathComponent)") + + let crop = try await FaceCropAnalyzer.detectCrop( + source: source, + start: start, + duration: duration, + targetDimensions: targetDimensions, + targetFaceHeightRatio: settings.targetFaceHeightRatio + ) + + if let crop { + appendLog("Face Crop Clip \(clipNumber): crop=\(crop.width)x\(crop.height)+\(crop.x)+\(crop.y), Gesicht \(Int(crop.faceHeightRatio * 100))%") + } else { + appendLog("Kein Gesicht in Clip \(clipNumber) erkannt. Render ohne Face-Normalisierung.") + } + + return crop + } + + private func runFFmpeg(_ ffmpeg: URL, arguments: [String], currentDirectory: URL? = nil) async throws -> ProcessResult { + try await service.run(executable: ffmpeg, arguments: arguments, currentDirectory: currentDirectory) { [weak self] chunk in + Task { @MainActor in + self?.appendProcessOutput(chunk) + } + } + } + + private func cleanupTransformFiles(in directory: URL) { + guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + return + } + + for file in files where file.pathExtension.lowercased() == "trf" { + try? FileManager.default.removeItem(at: file) + } + } + + private func writeRenderReport( + settings: RenderSettings, + outputFile: URL, + targetDimensions: VideoDimensions, + inputClipCount: Int + ) -> URL { + let reportURL = outputFile + .deletingPathExtension() + .appendingPathExtension("growthlapse-report.txt") + + let summary = [ + "GrowthLapse Render-Protokoll", + "Datum: \(Date())", + "Output: \(outputFile.path)", + "Input-Clips: \(inputClipCount)", + "Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)", + "Format: \(settings.outputFormat.rawValue)", + "Encoder: \(settings.videoEncoder.rawValue)", + "FPS: \(settings.fps)", + "Segmentlänge: \(settings.segmentLength)s", + "Ziellänge pro Clip: \(settings.targetClipLength)s", + "Übergangslänge: \(settings.transitionLength)s", + "Audio entfernen: \(settings.removeAudio ? "ja" : "nein")", + "Segment aus Mitte: \(settings.takeMiddleSegment ? "ja" : "nein")", + "Stabilisierung: \(settings.stabilizationEnabled ? settings.stabilizationMethod.rawValue : "aus")", + "Stabilisierungsstärke: \(settings.stabilizationStrength.rawValue)", + "Stabilisierung Hintergrund-Anker: \(settings.stabilizationAnchorEnabled ? "an, X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%" : "aus")", + "Face Size Normalization: \(settings.faceNormalizationEnabled ? "an, Ziel \(Int(settings.targetFaceHeightRatio * 100))%" : "aus")", + "", + "Log:", + logs.joined(separator: "\n") + ].joined(separator: "\n") + + do { + try summary.write(to: reportURL, atomically: true, encoding: .utf8) + } catch { + appendLog("Warnung: Render-Protokoll konnte nicht geschrieben werden: \(error.localizedDescription)") + } + + return reportURL + } + + private func decimal(_ value: Double) -> String { + String(format: "%.6f", locale: Locale(identifier: "en_US_POSIX"), value) + } + + private func formatSeconds(_ value: Double) -> String { + String(format: "%.2f s", locale: Locale(identifier: "de_DE"), value) + } + + private func outputDimensions(for settings: RenderSettings, firstVideo: VideoFile) -> VideoDimensions { + switch settings.outputFormat { + case .landscape1920x1080: + return VideoDimensions(width: 1920, height: 1080) + case .portrait1080x1920: + return VideoDimensions(width: 1080, height: 1920) + case .keepOriginal: + return VideoDimensions(width: evenDimension(firstVideo.width), height: evenDimension(firstVideo.height)) + } + } + + private func sdrFilters() -> [String] { + [ + "format=yuv420p", + "colorspace=all=bt709:iall=bt709:fast=1", + "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709" + ] + } + + private func evenDimension(_ value: Int) -> Int { + max(2, value - (value % 2)) + } + + private func videoEncoderArguments(_ settings: RenderSettings) -> [String] { + switch settings.videoEncoder { + case .x264: + return [ + "-c:v", "libx264", + "-preset", "medium", + "-crf", "\(settings.crf)", + "-pix_fmt", "yuv420p" + ] + case .videoToolbox: + return [ + "-c:v", "h264_videotoolbox", + "-b:v", "\(settings.hardwareBitrateMbps)M", + "-profile:v", "high", + "-allow_sw", "0", + "-realtime", "0", + "-power_efficient", "1", + "-pix_fmt", "yuv420p" + ] + } + } + + private func audioTempoChain(_ speedFactor: Double) -> String { + var remaining = speedFactor + var filters: [String] = [] + while remaining > 2.0 { + filters.append("2.0") + remaining /= 2.0 + } + while remaining < 0.5 { + filters.append("0.5") + remaining /= 0.5 + } + filters.append(decimal(remaining)) + return filters.joined(separator: ",atempo=") + } + + private func appendLog(_ message: String) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + logs.append("[\(formatter.string(from: Date()))] \(message)") + } + + private func appendProcessOutput(_ chunk: String) { + let cleaned = chunk + .replacingOccurrences(of: "\r", with: "\n") + .split(separator: "\n") + .map(String.init) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + guard !cleaned.isEmpty else { + return + } + + for line in cleaned.suffix(8) { + logs.append(line) + } + + if logs.count > 1_500 { + logs.removeFirst(logs.count - 1_500) + } + } + + private func handleCancellation() { + Task { + await service.cancel() + } + state = .cancelled + progressText = "Abgebrochen" + appendLog("Rendern abgebrochen.") + if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) { + cleanupTransformFiles(in: temporaryDirectory) + try? FileManager.default.removeItem(at: temporaryDirectory) + } + } +} + +private struct DetectedFaceSample { + let rect: CGRect + let imageWidth: Int + let imageHeight: Int +} + +private enum FaceCropAnalyzer { + static func detectCrop( + source: URL, + start: Double, + duration: Double, + targetDimensions: VideoDimensions, + targetFaceHeightRatio: Double + ) async throws -> FaceCrop? { + try await Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + + let asset = AVAsset(url: source) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 0.15, preferredTimescale: 600) + + let sampleTimes = sampleTimes(start: start, duration: duration) + var samples: [DetectedFaceSample] = [] + + for time in sampleTimes { + try Task.checkCancellation() + let cgImage = try generator.copyCGImage(at: time, actualTime: nil) + let request = VNDetectFaceRectanglesRequest() + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + try handler.perform([request]) + + guard let observations = request.results, !observations.isEmpty else { + continue + } + + let selected = selectFace(from: observations) + samples.append( + DetectedFaceSample( + rect: selected.boundingBox, + imageWidth: cgImage.width, + imageHeight: cgImage.height + ) + ) + } + + guard !samples.isEmpty else { + return nil + } + + return cropFromMedianFace( + samples, + targetDimensions: targetDimensions, + targetFaceHeightRatio: targetFaceHeightRatio + ) + }.value + } + + private static func sampleTimes(start: Double, duration: Double) -> [CMTime] { + let count = max(3, min(9, Int(duration.rounded(.down)) + 1)) + guard count > 1 else { + return [CMTime(seconds: start, preferredTimescale: 600)] + } + + return (0.. VNFaceObservation { + observations.max { left, right in + score(left.boundingBox) < score(right.boundingBox) + } ?? observations[0] + } + + private static func score(_ rect: CGRect) -> Double { + let area = rect.width * rect.height + let dx = rect.midX - 0.5 + let dy = rect.midY - 0.5 + let centralityPenalty = sqrt(dx * dx + dy * dy) * 0.18 + return area - centralityPenalty + } + + private static func cropFromMedianFace( + _ samples: [DetectedFaceSample], + targetDimensions: VideoDimensions, + targetFaceHeightRatio: Double + ) -> FaceCrop? { + let imageWidth = samples[samples.count / 2].imageWidth + let imageHeight = samples[samples.count / 2].imageHeight + + let medianX = median(samples.map { Double($0.rect.origin.x) }) + let medianY = median(samples.map { Double($0.rect.origin.y) }) + let medianWidth = median(samples.map { Double($0.rect.size.width) }) + let medianHeight = median(samples.map { Double($0.rect.size.height) }) + + guard medianWidth > 0, medianHeight > 0 else { + return nil + } + + let faceCenterX = (medianX + medianWidth / 2) * Double(imageWidth) + let faceCenterYFromTop = (1 - (medianY + medianHeight / 2)) * Double(imageHeight) + let faceHeightPixels = medianHeight * Double(imageHeight) + guard faceHeightPixels > 4 else { + return nil + } + + let outputAspect = Double(targetDimensions.width) / Double(targetDimensions.height) + var cropHeight = faceHeightPixels / targetFaceHeightRatio + var cropWidth = cropHeight * outputAspect + + if cropWidth > Double(imageWidth) { + cropWidth = Double(imageWidth) + cropHeight = cropWidth / outputAspect + } + if cropHeight > Double(imageHeight) { + cropHeight = Double(imageHeight) + cropWidth = cropHeight * outputAspect + } + + cropWidth = min(cropWidth, Double(imageWidth)) + cropHeight = min(cropHeight, Double(imageHeight)) + + let desiredFaceCenterY = 0.43 + var cropX = faceCenterX - cropWidth / 2 + var cropY = faceCenterYFromTop - cropHeight * desiredFaceCenterY + + cropX = clamp(cropX, min: 0, max: Double(imageWidth) - cropWidth) + cropY = clamp(cropY, min: 0, max: Double(imageHeight) - cropHeight) + + let evenX = even(Int(cropX.rounded(FloatingPointRoundingRule.down))) + let evenY = even(Int(cropY.rounded(FloatingPointRoundingRule.down))) + let evenWidth = max(2, even(Int(cropWidth.rounded(FloatingPointRoundingRule.down)))) + let evenHeight = max(2, even(Int(cropHeight.rounded(FloatingPointRoundingRule.down)))) + + return FaceCrop( + x: min(evenX, max(0, imageWidth - evenWidth)), + y: min(evenY, max(0, imageHeight - evenHeight)), + width: min(evenWidth, imageWidth), + height: min(evenHeight, imageHeight), + faceHeightRatio: faceHeightPixels / max(1, cropHeight) + ) + } + + private static func median(_ values: [Double]) -> Double { + guard !values.isEmpty else { + return 0 + } + let sorted = values.sorted() + let middle = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[middle - 1] + sorted[middle]) / 2 + } + return sorted[middle] + } + + private static func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double { + Swift.max(minimum, Swift.min(maximum, value)) + } + + private static func even(_ value: Int) -> Int { + max(0, value - (value % 2)) + } +}