Add cached clip review workflow
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
Reference in New Issue
Block a user