diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 0ba5268..1213201 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -1,9 +1,11 @@ +import AVKit import SwiftUI import UniformTypeIdentifiers struct ContentView: View { @StateObject private var processor = VideoProcessor() @State private var settings = SettingsStore.load() + @State private var selectedClipID: UUID? var body: some View { HSplitView { @@ -133,6 +135,8 @@ struct ContentView: View { .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.") + Toggle("Render-Cache für Nachbearbeitung behalten", isOn: $settings.keepRenderCacheForReview) + .help("Behält die normalisierten clip_*.mp4 Dateien und eine Projektdatei neben dem Output. So können einzelne Segmentfenster später angepasst und schneller neu gerendert werden.") } .disabled(processor.state.isRunning) @@ -225,6 +229,10 @@ struct ContentView: View { ProgressView(value: processor.progress) .progressViewStyle(.linear) + if let project = processor.project { + projectReviewPanel(project) + } + logView } .padding() @@ -240,6 +248,11 @@ struct ContentView: View { .onChange(of: settings) { newSettings in SettingsStore.save(newSettings) } + .onChange(of: processor.project) { project in + if selectedClipID == nil { + selectedClipID = project?.clips.first?.id + } + } } private var statusHeader: some View { @@ -293,6 +306,119 @@ struct ContentView: View { } } + private func projectReviewPanel(_ project: GrowthLapseProject) -> some View { + let selectedClip = project.clips.first { $0.id == selectedClipID } ?? project.clips.first + + return VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Clip-Nachbearbeitung") + .font(.headline) + Spacer() + Button("Änderungen neu rendern") { + processor.rerenderProject(settings: settings) + } + .disabled(processor.state.isRunning || !project.clips.contains(where: \.needsRender)) + .help("Rendert nur geänderte normalisierte Clips neu und baut danach das finale Video erneut.") + + Button("Ergebnis bestätigen") { + processor.confirmProjectAndDeleteCache() + } + .disabled(processor.state.isRunning) + .help("Löscht den Render-Cache. Die Projektdatei und das finale Video bleiben erhalten.") + } + + HStack(alignment: .top, spacing: 12) { + List(project.clips, selection: $selectedClipID) { clip in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(String(format: "%03d", clip.index)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Text(clip.displayName) + .lineLimit(1) + if clip.needsRender { + Text("geändert") + .font(.caption) + .foregroundStyle(.orange) + } + } + Text("Start \(formattedNumber(clip.segmentStart)) s, Länge \(formattedNumber(clip.segmentLength)) s") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(minWidth: 240, idealWidth: 300, maxWidth: 340, minHeight: 220, maxHeight: 300) + + if let clip = selectedClip { + clipEditor(clip) + } else { + Text("Kein Clip ausgewählt") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 220) + } + } + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor)) + ) + } + + private func clipEditor(_ clip: GrowthLapseProjectClip) -> some View { + let maxStart = max(0, clip.duration - clip.segmentLength) + let startBinding = Binding( + get: { currentClip(id: clip.id)?.segmentStart ?? clip.segmentStart }, + set: { processor.updateClip(clip, segmentStart: $0) } + ) + + return VStack(alignment: .leading, spacing: 8) { + ClipPreviewView(clip: currentClip(id: clip.id) ?? clip) + .frame(height: 180) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Text(clip.displayName) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + + HStack { + Text("Segmentstart") + Spacer() + Text("\(formattedNumber(startBinding.wrappedValue)) s / max \(formattedNumber(maxStart)) s") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Slider(value: startBinding, in: 0...maxStart, step: 0.1) + + HStack { + Button("-1 s") { + processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 1) + } + Button("-0,5 s") { + processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 0.5) + } + Button("Zur Mitte") { + processor.updateClip(clip, segmentStart: max(0, (clip.duration - clip.segmentLength) / 2 + settings.startOffset)) + } + Button("+0,5 s") { + processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 0.5) + } + Button("+1 s") { + processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1) + } + } + .disabled(processor.state.isRunning) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + private func currentClip(id: UUID) -> GrowthLapseProjectClip? { + processor.project?.clips.first { $0.id == id } + } + private var errorBinding: Binding { Binding( get: { @@ -400,3 +526,31 @@ struct ContentView: View { } } } + +private struct ClipPreviewView: View { + let clip: GrowthLapseProjectClip + @State private var player = AVPlayer() + + var body: some View { + VideoPlayer(player: player) + .onAppear { + loadClip() + } + .onChange(of: clip.id) { _ in + loadClip() + } + .onChange(of: clip.segmentStart) { _ in + seekToSegmentStart() + } + } + + private func loadClip() { + player.replaceCurrentItem(with: AVPlayerItem(url: clip.sourceURL)) + seekToSegmentStart() + } + + private func seekToSegmentStart() { + let time = CMTime(seconds: clip.segmentStart, preferredTimescale: 600) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + } +} diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift index fca5240..8013018 100644 --- a/Sources/GrowthLapse/RenderSettings.swift +++ b/Sources/GrowthLapse/RenderSettings.swift @@ -126,6 +126,7 @@ struct RenderSettings: Equatable { var takeMiddleSegment: Bool = true var startOffset: Double = 0 var keepIntermediateClips: Bool = false + var keepRenderCacheForReview: Bool = true var videoEncoder: VideoEncoder = .videoToolbox var crf: Int = 20 var hardwareBitrateMbps: Int = 12 @@ -156,6 +157,7 @@ struct PersistedRenderSettings: Codable { var takeMiddleSegment: Bool var startOffset: Double var keepIntermediateClips: Bool + var keepRenderCacheForReview: Bool? var videoEncoder: VideoEncoder var crf: Int var hardwareBitrateMbps: Int @@ -185,6 +187,7 @@ struct PersistedRenderSettings: Codable { takeMiddleSegment = settings.takeMiddleSegment startOffset = settings.startOffset keepIntermediateClips = settings.keepIntermediateClips + keepRenderCacheForReview = settings.keepRenderCacheForReview videoEncoder = settings.videoEncoder crf = settings.crf hardwareBitrateMbps = settings.hardwareBitrateMbps @@ -216,6 +219,7 @@ struct PersistedRenderSettings: Codable { takeMiddleSegment: takeMiddleSegment, startOffset: startOffset, keepIntermediateClips: keepIntermediateClips, + keepRenderCacheForReview: keepRenderCacheForReview ?? true, videoEncoder: videoEncoder, crf: crf, hardwareBitrateMbps: hardwareBitrateMbps, @@ -286,6 +290,53 @@ struct VideoFile: Identifiable { } } +struct GrowthLapseProject: Codable, Equatable { + var outputFilePath: String + var cacheDirectoryPath: String + var targetWidth: Int + var targetHeight: Int + var clips: [GrowthLapseProjectClip] + + var outputFileURL: URL { + URL(fileURLWithPath: outputFilePath) + } + + var cacheDirectoryURL: URL { + URL(fileURLWithPath: cacheDirectoryPath, isDirectory: true) + } + + static func url(for outputFile: URL) -> URL { + outputFile.deletingPathExtension().appendingPathExtension("growthlapse-project.json") + } + + static func cacheURL(for outputFile: URL) -> URL { + outputFile.deletingPathExtension().appendingPathExtension("growthlapse-cache") + } +} + +struct GrowthLapseProjectClip: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var index: Int + var sourcePath: String + var displayName: String + var duration: Double + var width: Int + var height: Int + var ageWeeks: Int? + var segmentStart: Double + var segmentLength: Double + var normalizedClipPath: String + var needsRender: Bool = false + + var sourceURL: URL { + URL(fileURLWithPath: sourcePath) + } + + var normalizedClipURL: URL { + URL(fileURLWithPath: normalizedClipPath) + } +} + struct VideoDimensions: Equatable { let width: Int let height: Int diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index 6073344..d9b6eb9 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -3,6 +3,11 @@ import AVFoundation import Foundation import Vision +private struct RenderOutcome { + let outputURL: URL + let project: GrowthLapseProject +} + @MainActor final class VideoProcessor: ObservableObject { @Published var progress: Double = 0 @@ -10,6 +15,7 @@ final class VideoProcessor: ObservableObject { @Published var logs: [String] = [] @Published var state: RenderState = .idle @Published var warningText: String? + @Published var project: GrowthLapseProject? private let service = FFmpegService() private var renderTask: Task? @@ -29,14 +35,15 @@ final class VideoProcessor: ObservableObject { let settingsCopy = settings renderTask = Task { do { - let outputURL = try await render(settings: settingsCopy) + let outcome = try await render(settings: settingsCopy, existingProject: nil) guard !Task.isCancelled else { throw VideoProcessingError.cancelled } - state = .finished(outputURL) + project = outcome.project + state = .finished(outcome.outputURL) progress = 1 progressText = "Fertig" - appendLog("Fertig: \(outputURL.path)") + appendLog("Fertig: \(outcome.outputURL.path)") } catch is CancellationError { handleCancellation() } catch VideoProcessingError.cancelled { @@ -50,6 +57,63 @@ final class VideoProcessor: ObservableObject { } } + func rerenderProject(settings: RenderSettings) { + guard !state.isRunning, let project else { + return + } + + logs.removeAll() + warningText = nil + progress = 0 + progressText = "Render aus Projekt" + state = .running + + let settingsCopy = settings + let projectCopy = project + renderTask = Task { + do { + let outcome = try await render(settings: settingsCopy, existingProject: projectCopy) + guard !Task.isCancelled else { + throw VideoProcessingError.cancelled + } + self.project = outcome.project + state = .finished(outcome.outputURL) + progress = 1 + progressText = "Fertig" + appendLog("Fertig: \(outcome.outputURL.path)") + } catch is CancellationError { + handleCancellation() + } catch VideoProcessingError.cancelled { + handleCancellation() + } catch { + state = .failed(error.localizedDescription) + progressText = "Fehler" + appendLog("Fehler: \(error.localizedDescription)") + } + renderTask = nil + } + } + + func updateClip(_ clip: GrowthLapseProjectClip, segmentStart: Double) { + guard var project, + let index = project.clips.firstIndex(where: { $0.id == clip.id }) else { + return + } + let maxStart = max(0, project.clips[index].duration - project.clips[index].segmentLength) + project.clips[index].segmentStart = clamp(segmentStart, min: 0, max: maxStart) + project.clips[index].needsRender = true + self.project = project + saveProject(project) + } + + func confirmProjectAndDeleteCache() { + guard let project else { + return + } + try? FileManager.default.removeItem(at: project.cacheDirectoryURL) + appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)") + } + func cancel() { guard state.isRunning else { return @@ -65,7 +129,7 @@ final class VideoProcessor: ObservableObject { NSWorkspace.shared.activateFileViewerSelecting([url]) } - private func render(settings: RenderSettings) async throws -> URL { + private func render(settings: RenderSettings, existingProject: GrowthLapseProject?) async throws -> RenderOutcome { var effectiveSettings = settings try validate(effectiveSettings) let tools = try await service.validateTools( @@ -86,44 +150,83 @@ final class VideoProcessor: ObservableObject { let inputFolder = settings.inputFolder! let outputFile = settings.outputFile! - let tempDirectory = try createTemporaryDirectory(keep: settings.keepIntermediateClips, outputFile: outputFile) + let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil + let tempDirectory = try createWorkingDirectory( + keepDebugFiles: settings.keepIntermediateClips, + keepReviewCache: useReviewCache, + outputFile: outputFile, + resetCache: existingProject == nil + ) temporaryDirectory = tempDirectory appendLog("Temporärer Ordner: \(tempDirectory.path)") - let videos = try await discoverVideos( - in: inputFolder, - sortMode: settings.sortMode, - duplicateClipSelection: settings.duplicateClipSelection, - ffprobe: tools.ffprobe, - excluding: outputFile - ) + let videos: [VideoFile] + if let existingProject { + videos = existingProject.clips.map { + VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height) + } + appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).") + } else { + videos = try await discoverVideos( + in: inputFolder, + sortMode: settings.sortMode, + duplicateClipSelection: settings.duplicateClipSelection, + 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) + var renderProject = existingProject ?? makeProject( + outputFile: outputFile, + cacheDirectory: tempDirectory, + targetDimensions: targetDimensions, + videos: videos, + settings: effectiveSettings + ) + + let normalizedClips = try await normalizeVideos( + videos, + project: &renderProject, + 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) + try await crossfadeIteratively( + clips: normalizedClips, + settings: effectiveSettings, + targetDimensions: targetDimensions, + outputFile: outputFile, + ffmpeg: tools.ffmpeg, + tempDirectory: tempDirectory, + keepReviewCache: useReviewCache + ) } let reportURL = writeRenderReport( settings: effectiveSettings, outputFile: outputFile, targetDimensions: targetDimensions, - selectedVideos: videos + project: renderProject ) appendLog("Render-Protokoll: \(reportURL.path)") + saveProject(renderProject) + appendLog("Projektdatei: \(GrowthLapseProject.url(for: outputFile).path)") - if !settings.keepIntermediateClips { + if !settings.keepIntermediateClips && !useReviewCache { try? FileManager.default.removeItem(at: tempDirectory) } else { cleanupTransformFiles(in: tempDirectory) } - return outputFile + return RenderOutcome(outputURL: outputFile, project: renderProject) } private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod { @@ -407,25 +510,33 @@ final class VideoProcessor: ObservableObject { return VideoDimensions(width: evenDimension(width), height: evenDimension(height)) } - private func normalizeVideos(_ videos: [VideoFile], settings: RenderSettings, targetDimensions: VideoDimensions, ffmpeg: URL, tempDirectory: URL) async throws -> [URL] { + private func normalizeVideos( + _ videos: [VideoFile], + project: inout GrowthLapseProject, + 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 + var projectClip = project.clips[index] + let outputURL = projectClip.normalizedClipURL + let actualSegmentLength = projectClip.segmentLength + let safeStart = min(projectClip.segmentStart, max(0, video.duration - actualSegmentLength)) + let speedFactor = max(0.0001, actualSegmentLength / settings.targetClipLength) - if settings.takeMiddleSegment, video.duration > settings.segmentLength { - start = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset) - } else { - start = max(0, settings.startOffset) + if !projectClip.needsRender, FileManager.default.fileExists(atPath: outputURL.path) { + appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)") + outputURLs.append(outputURL) + progress = Double(index + 1) / Double(videos.count) * normalizationWeight + continue } - 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, @@ -493,6 +604,11 @@ final class VideoProcessor: ObservableObject { ) } + projectClip.segmentStart = safeStart + projectClip.segmentLength = actualSegmentLength + projectClip.needsRender = false + projectClip.normalizedClipPath = outputURL.path + project.clips[index] = projectClip outputURLs.append(outputURL) progress = Double(index + 1) / Double(videos.count) * normalizationWeight } @@ -787,7 +903,15 @@ final class VideoProcessor: ObservableObject { progress = 1 } - private func crossfadeIteratively(clips: [URL], settings: RenderSettings, targetDimensions: VideoDimensions, outputFile: URL, ffmpeg: URL, tempDirectory: URL) async throws { + private func crossfadeIteratively( + clips: [URL], + settings: RenderSettings, + targetDimensions: VideoDimensions, + outputFile: URL, + ffmpeg: URL, + tempDirectory: URL, + keepReviewCache: Bool + ) async throws { try Task.checkCancellation() guard clips.count > 1 else { try replaceOutput(with: clips[0], outputFile: outputFile) @@ -835,8 +959,12 @@ final class VideoProcessor: ObservableObject { } if !settings.keepIntermediateClips { - removeTemporaryFile(current) - removeTemporaryFile(next) + if current.lastPathComponent.hasPrefix("xfade_") || !keepReviewCache { + removeTemporaryFile(current) + } + if !keepReviewCache { + removeTemporaryFile(next) + } } current = out currentDuration += settings.targetClipLength - settings.transitionLength @@ -844,6 +972,19 @@ final class VideoProcessor: ObservableObject { } } + private func createWorkingDirectory(keepDebugFiles: Bool, keepReviewCache: Bool, outputFile: URL, resetCache: Bool) throws -> URL { + if keepReviewCache { + let directory = GrowthLapseProject.cacheURL(for: outputFile) + if resetCache, FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + return try createTemporaryDirectory(keep: keepDebugFiles, outputFile: outputFile) + } + private func createTemporaryDirectory(keep: Bool, outputFile: URL) throws -> URL { let base: URL if keep { @@ -857,6 +998,59 @@ final class VideoProcessor: ObservableObject { return directory } + private func makeProject( + outputFile: URL, + cacheDirectory: URL, + targetDimensions: VideoDimensions, + videos: [VideoFile], + settings: RenderSettings + ) -> GrowthLapseProject { + let clips = videos.enumerated().map { index, video in + let segmentLength = min(settings.segmentLength, video.duration) + let rawStart: Double + if settings.takeMiddleSegment, video.duration > settings.segmentLength { + rawStart = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset) + } else { + rawStart = max(0, settings.startOffset) + } + let safeStart = min(rawStart, max(0, video.duration - segmentLength)) + let normalizedClipURL = cacheDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", index + 1)) + return GrowthLapseProjectClip( + index: index + 1, + sourcePath: video.url.path, + displayName: video.displayName, + duration: video.duration, + width: video.width, + height: video.height, + ageWeeks: ageInWeeks(from: video.url), + segmentStart: safeStart, + segmentLength: segmentLength, + normalizedClipPath: normalizedClipURL.path, + needsRender: true + ) + } + + return GrowthLapseProject( + outputFilePath: outputFile.path, + cacheDirectoryPath: cacheDirectory.path, + targetWidth: targetDimensions.width, + targetHeight: targetDimensions.height, + clips: clips + ) + } + + private func saveProject(_ project: GrowthLapseProject) { + let url = GrowthLapseProject.url(for: project.outputFileURL) + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(project) + try data.write(to: url, options: [.atomic]) + } catch { + appendLog("Warnung: Projektdatei konnte nicht geschrieben werden: \(error.localizedDescription)") + } + } + private func replaceOutput(with source: URL, outputFile: URL) throws { if FileManager.default.fileExists(atPath: outputFile.path) { try FileManager.default.removeItem(at: outputFile) @@ -970,20 +1164,22 @@ final class VideoProcessor: ObservableObject { settings: RenderSettings, outputFile: URL, targetDimensions: VideoDimensions, - selectedVideos: [VideoFile] + project: GrowthLapseProject ) -> URL { let reportURL = outputFile .deletingPathExtension() .appendingPathExtension("growthlapse-report.txt") - let selectedClipLines = selectedVideos.enumerated().map { index, video in - let age = ageInWeeks(from: video.url).map { ", \($0) Wochen" } ?? "" + let selectedClipLines = project.clips.map { clip in + let age = clip.ageWeeks.map { ", \($0) Wochen" } ?? "" return String( - format: "%03d. %@ - %@, %dx%d%@", - index + 1, - video.displayName, - formatSeconds(video.duration), - video.width, - video.height, + format: "%03d. %@ - Dauer %@, Segment @ %@ für %@, %dx%d%@", + clip.index, + clip.displayName, + formatSeconds(clip.duration), + formatSeconds(clip.segmentStart), + formatSeconds(clip.segmentLength), + clip.width, + clip.height, age ) } @@ -993,7 +1189,7 @@ final class VideoProcessor: ObservableObject { "Datum: \(Date())", "Input-Ordner: \(settings.inputFolder?.path ?? "-")", "Output: \(outputFile.path)", - "Input-Clips: \(selectedVideos.count)", + "Input-Clips: \(project.clips.count)", "Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)", "Format: \(settings.outputFormat.rawValue)", "Encoder: \(settings.videoEncoder.rawValue)",