Add cached clip review workflow

This commit is contained in:
Mikei386
2026-06-03 15:01:09 +02:00
parent 2eec8d7769
commit d8bca3d94f
3 changed files with 441 additions and 40 deletions
+154
View File
@@ -1,9 +1,11 @@
import AVKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
@StateObject private var processor = VideoProcessor() @StateObject private var processor = VideoProcessor()
@State private var settings = SettingsStore.load() @State private var settings = SettingsStore.load()
@State private var selectedClipID: UUID?
var body: some View { var body: some View {
HSplitView { HSplitView {
@@ -133,6 +135,8 @@ struct ContentView: View {
.help("Wählt den Ausschnitt aus der Videomitte. Start-Offset verschiebt diesen Mittelpunkt.") .help("Wählt den Ausschnitt aus der Videomitte. Start-Offset verschiebt diesen Mittelpunkt.")
Toggle("Zwischenclips behalten", isOn: $settings.keepIntermediateClips) 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.") .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) .disabled(processor.state.isRunning)
@@ -225,6 +229,10 @@ struct ContentView: View {
ProgressView(value: processor.progress) ProgressView(value: processor.progress)
.progressViewStyle(.linear) .progressViewStyle(.linear)
if let project = processor.project {
projectReviewPanel(project)
}
logView logView
} }
.padding() .padding()
@@ -240,6 +248,11 @@ struct ContentView: View {
.onChange(of: settings) { newSettings in .onChange(of: settings) { newSettings in
SettingsStore.save(newSettings) SettingsStore.save(newSettings)
} }
.onChange(of: processor.project) { project in
if selectedClipID == nil {
selectedClipID = project?.clips.first?.id
}
}
} }
private var statusHeader: some View { 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<Double>(
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<Bool> { private var errorBinding: Binding<Bool> {
Binding( Binding(
get: { 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)
}
}
+51
View File
@@ -126,6 +126,7 @@ struct RenderSettings: Equatable {
var takeMiddleSegment: Bool = true var takeMiddleSegment: Bool = true
var startOffset: Double = 0 var startOffset: Double = 0
var keepIntermediateClips: Bool = false var keepIntermediateClips: Bool = false
var keepRenderCacheForReview: Bool = true
var videoEncoder: VideoEncoder = .videoToolbox var videoEncoder: VideoEncoder = .videoToolbox
var crf: Int = 20 var crf: Int = 20
var hardwareBitrateMbps: Int = 12 var hardwareBitrateMbps: Int = 12
@@ -156,6 +157,7 @@ struct PersistedRenderSettings: Codable {
var takeMiddleSegment: Bool var takeMiddleSegment: Bool
var startOffset: Double var startOffset: Double
var keepIntermediateClips: Bool var keepIntermediateClips: Bool
var keepRenderCacheForReview: Bool?
var videoEncoder: VideoEncoder var videoEncoder: VideoEncoder
var crf: Int var crf: Int
var hardwareBitrateMbps: Int var hardwareBitrateMbps: Int
@@ -185,6 +187,7 @@ struct PersistedRenderSettings: Codable {
takeMiddleSegment = settings.takeMiddleSegment takeMiddleSegment = settings.takeMiddleSegment
startOffset = settings.startOffset startOffset = settings.startOffset
keepIntermediateClips = settings.keepIntermediateClips keepIntermediateClips = settings.keepIntermediateClips
keepRenderCacheForReview = settings.keepRenderCacheForReview
videoEncoder = settings.videoEncoder videoEncoder = settings.videoEncoder
crf = settings.crf crf = settings.crf
hardwareBitrateMbps = settings.hardwareBitrateMbps hardwareBitrateMbps = settings.hardwareBitrateMbps
@@ -216,6 +219,7 @@ struct PersistedRenderSettings: Codable {
takeMiddleSegment: takeMiddleSegment, takeMiddleSegment: takeMiddleSegment,
startOffset: startOffset, startOffset: startOffset,
keepIntermediateClips: keepIntermediateClips, keepIntermediateClips: keepIntermediateClips,
keepRenderCacheForReview: keepRenderCacheForReview ?? true,
videoEncoder: videoEncoder, videoEncoder: videoEncoder,
crf: crf, crf: crf,
hardwareBitrateMbps: hardwareBitrateMbps, 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 { struct VideoDimensions: Equatable {
let width: Int let width: Int
let height: Int let height: Int
+228 -32
View File
@@ -3,6 +3,11 @@ import AVFoundation
import Foundation import Foundation
import Vision import Vision
private struct RenderOutcome {
let outputURL: URL
let project: GrowthLapseProject
}
@MainActor @MainActor
final class VideoProcessor: ObservableObject { final class VideoProcessor: ObservableObject {
@Published var progress: Double = 0 @Published var progress: Double = 0
@@ -10,6 +15,7 @@ final class VideoProcessor: ObservableObject {
@Published var logs: [String] = [] @Published var logs: [String] = []
@Published var state: RenderState = .idle @Published var state: RenderState = .idle
@Published var warningText: String? @Published var warningText: String?
@Published var project: GrowthLapseProject?
private let service = FFmpegService() private let service = FFmpegService()
private var renderTask: Task<Void, Never>? private var renderTask: Task<Void, Never>?
@@ -29,14 +35,15 @@ final class VideoProcessor: ObservableObject {
let settingsCopy = settings let settingsCopy = settings
renderTask = Task { renderTask = Task {
do { do {
let outputURL = try await render(settings: settingsCopy) let outcome = try await render(settings: settingsCopy, existingProject: nil)
guard !Task.isCancelled else { guard !Task.isCancelled else {
throw VideoProcessingError.cancelled throw VideoProcessingError.cancelled
} }
state = .finished(outputURL) project = outcome.project
state = .finished(outcome.outputURL)
progress = 1 progress = 1
progressText = "Fertig" progressText = "Fertig"
appendLog("Fertig: \(outputURL.path)") appendLog("Fertig: \(outcome.outputURL.path)")
} catch is CancellationError { } catch is CancellationError {
handleCancellation() handleCancellation()
} catch VideoProcessingError.cancelled { } 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() { func cancel() {
guard state.isRunning else { guard state.isRunning else {
return return
@@ -65,7 +129,7 @@ final class VideoProcessor: ObservableObject {
NSWorkspace.shared.activateFileViewerSelecting([url]) 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 var effectiveSettings = settings
try validate(effectiveSettings) try validate(effectiveSettings)
let tools = try await service.validateTools( let tools = try await service.validateTools(
@@ -86,44 +150,83 @@ final class VideoProcessor: ObservableObject {
let inputFolder = settings.inputFolder! let inputFolder = settings.inputFolder!
let outputFile = settings.outputFile! 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 temporaryDirectory = tempDirectory
appendLog("Temporärer Ordner: \(tempDirectory.path)") appendLog("Temporärer Ordner: \(tempDirectory.path)")
let videos = try await discoverVideos( 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, in: inputFolder,
sortMode: settings.sortMode, sortMode: settings.sortMode,
duplicateClipSelection: settings.duplicateClipSelection, duplicateClipSelection: settings.duplicateClipSelection,
ffprobe: tools.ffprobe, ffprobe: tools.ffprobe,
excluding: outputFile excluding: outputFile
) )
}
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)")
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 { if effectiveSettings.transitionLength <= 0 {
try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf) try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf)
} else { } 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( let reportURL = writeRenderReport(
settings: effectiveSettings, settings: effectiveSettings,
outputFile: outputFile, outputFile: outputFile,
targetDimensions: targetDimensions, targetDimensions: targetDimensions,
selectedVideos: videos project: renderProject
) )
appendLog("Render-Protokoll: \(reportURL.path)") 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) try? FileManager.default.removeItem(at: tempDirectory)
} else { } else {
cleanupTransformFiles(in: tempDirectory) cleanupTransformFiles(in: tempDirectory)
} }
return outputFile return RenderOutcome(outputURL: outputFile, project: renderProject)
} }
private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod { private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod {
@@ -407,25 +510,33 @@ final class VideoProcessor: ObservableObject {
return VideoDimensions(width: evenDimension(width), height: evenDimension(height)) 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] = [] var outputURLs: [URL] = []
let normalizationWeight = settings.transitionLength > 0 ? 0.75 : 0.9 let normalizationWeight = settings.transitionLength > 0 ? 0.75 : 0.9
for (index, video) in videos.enumerated() { for (index, video) in videos.enumerated() {
try Task.checkCancellation() try Task.checkCancellation()
let clipNumber = index + 1 let clipNumber = index + 1
let outputURL = tempDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", clipNumber)) var projectClip = project.clips[index]
let actualSegmentLength = min(settings.segmentLength, video.duration) let outputURL = projectClip.normalizedClipURL
let start: Double 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 { if !projectClip.needsRender, FileManager.default.fileExists(atPath: outputURL.path) {
start = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset) appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)")
} else { outputURLs.append(outputURL)
start = max(0, settings.startOffset) 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( let faceCrop = try await detectFaceCropIfNeeded(
source: video.url, source: video.url,
start: safeStart, 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) outputURLs.append(outputURL)
progress = Double(index + 1) / Double(videos.count) * normalizationWeight progress = Double(index + 1) / Double(videos.count) * normalizationWeight
} }
@@ -787,7 +903,15 @@ final class VideoProcessor: ObservableObject {
progress = 1 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() try Task.checkCancellation()
guard clips.count > 1 else { guard clips.count > 1 else {
try replaceOutput(with: clips[0], outputFile: outputFile) try replaceOutput(with: clips[0], outputFile: outputFile)
@@ -835,15 +959,32 @@ final class VideoProcessor: ObservableObject {
} }
if !settings.keepIntermediateClips { if !settings.keepIntermediateClips {
if current.lastPathComponent.hasPrefix("xfade_") || !keepReviewCache {
removeTemporaryFile(current) removeTemporaryFile(current)
}
if !keepReviewCache {
removeTemporaryFile(next) removeTemporaryFile(next)
} }
}
current = out current = out
currentDuration += settings.targetClipLength - settings.transitionLength currentDuration += settings.targetClipLength - settings.transitionLength
progress = 0.75 + (Double(index) / Double(totalSteps)) * 0.25 progress = 0.75 + (Double(index) / Double(totalSteps)) * 0.25
} }
} }
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 { private func createTemporaryDirectory(keep: Bool, outputFile: URL) throws -> URL {
let base: URL let base: URL
if keep { if keep {
@@ -857,6 +998,59 @@ final class VideoProcessor: ObservableObject {
return directory 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 { private func replaceOutput(with source: URL, outputFile: URL) throws {
if FileManager.default.fileExists(atPath: outputFile.path) { if FileManager.default.fileExists(atPath: outputFile.path) {
try FileManager.default.removeItem(at: outputFile) try FileManager.default.removeItem(at: outputFile)
@@ -970,20 +1164,22 @@ final class VideoProcessor: ObservableObject {
settings: RenderSettings, settings: RenderSettings,
outputFile: URL, outputFile: URL,
targetDimensions: VideoDimensions, targetDimensions: VideoDimensions,
selectedVideos: [VideoFile] project: GrowthLapseProject
) -> URL { ) -> URL {
let reportURL = outputFile let reportURL = outputFile
.deletingPathExtension() .deletingPathExtension()
.appendingPathExtension("growthlapse-report.txt") .appendingPathExtension("growthlapse-report.txt")
let selectedClipLines = selectedVideos.enumerated().map { index, video in let selectedClipLines = project.clips.map { clip in
let age = ageInWeeks(from: video.url).map { ", \($0) Wochen" } ?? "" let age = clip.ageWeeks.map { ", \($0) Wochen" } ?? ""
return String( return String(
format: "%03d. %@ - %@, %dx%d%@", format: "%03d. %@ - Dauer %@, Segment @ %@ für %@, %dx%d%@",
index + 1, clip.index,
video.displayName, clip.displayName,
formatSeconds(video.duration), formatSeconds(clip.duration),
video.width, formatSeconds(clip.segmentStart),
video.height, formatSeconds(clip.segmentLength),
clip.width,
clip.height,
age age
) )
} }
@@ -993,7 +1189,7 @@ final class VideoProcessor: ObservableObject {
"Datum: \(Date())", "Datum: \(Date())",
"Input-Ordner: \(settings.inputFolder?.path ?? "-")", "Input-Ordner: \(settings.inputFolder?.path ?? "-")",
"Output: \(outputFile.path)", "Output: \(outputFile.path)",
"Input-Clips: \(selectedVideos.count)", "Input-Clips: \(project.clips.count)",
"Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)", "Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)",
"Format: \(settings.outputFormat.rawValue)", "Format: \(settings.outputFormat.rawValue)",
"Encoder: \(settings.videoEncoder.rawValue)", "Encoder: \(settings.videoEncoder.rawValue)",