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