Make rendering failure-safe and project editing reproducible
This commit is contained in:
1 parent
c530fe025a
commit
73961f6afe
13 files changed
+1019
-212
No files matched your search
@@ -7,7 +7,8 @@ struct ContentView: View {
|
||||
@State private var settings = SettingsStore.load()
|
||||
@State private var selectedClipID: UUID?
|
||||
@State private var variantPickerClip: GrowthLapseProjectClip?
|
||||
@State private var reviewPanelHeight: CGFloat = 340
|
||||
@State private var reviewPanelHeight: CGFloat = 460
|
||||
@State private var showingResult = false
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
@@ -70,6 +71,11 @@ struct ContentView: View {
|
||||
.disabled(processor.state.isRunning)
|
||||
|
||||
Section("Parameter") {
|
||||
if processor.project != nil {
|
||||
Text("Ausschnittlänge und Start-Offset werden mit ‚Segmente neu planen‘ übernommen. Normales Neurendern erhält die gewählten Ausschnitte.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
doubleStepper("Ausschnittlänge", value: $settings.segmentLength, range: 1...120, step: 1, suffix: "s", help: "So viele Sekunden werden aus jedem Originalvideo entnommen, bevor Stabilisierung und Beschleunigung passieren.")
|
||||
doubleStepper("Ziellänge pro Clip", value: $settings.targetClipLength, range: 0.5...30, step: 0.5, suffix: "s", help: "Länge jedes fertigen Zwischenclips nach der Beschleunigung. Beispiel: 10 Sekunden Segment auf 2 Sekunden ergibt 5x Speed.")
|
||||
doubleStepper("Übergangslänge", value: $settings.transitionLength, range: 0...5, step: 0.1, suffix: "s", help: "Dauer des Crossfade zwischen zwei Clips. 0 verbindet hart per concat.")
|
||||
@@ -122,7 +128,7 @@ struct ContentView: View {
|
||||
Text(selection.rawValue).tag(selection)
|
||||
}
|
||||
}
|
||||
.help("Wenn mehrere Dateien denselben Namen mit Endung 01, 02, 03 haben, wählt GrowthLapse nur eine Variante. Beispiel: '... 118 Wochen alt 01.MOV' und '... 118 Wochen alt 02.MOV'.")
|
||||
.help("Nur beim neuen Import: Wenn mehrere Dateien denselben Namen mit Endung 01, 02, 03 haben, wählt GrowthLapse nur eine Variante. Beispiel: '... 118 Wochen alt 01.MOV' und '... 118 Wochen alt 02.MOV'.")
|
||||
|
||||
if settings.videoEncoder == .videoToolbox {
|
||||
Stepper(value: $settings.hardwareBitrateMbps, in: 2...80) {
|
||||
@@ -244,10 +250,11 @@ struct ContentView: View {
|
||||
if let project = processor.project {
|
||||
ResizableVerticalStack(
|
||||
topHeight: $reviewPanelHeight,
|
||||
minTopHeight: 260,
|
||||
minTopHeight: 340,
|
||||
minBottomHeight: 160
|
||||
) {
|
||||
projectReviewPanel(project)
|
||||
.disabled(processor.state.isRunning)
|
||||
} bottom: {
|
||||
logView
|
||||
}
|
||||
@@ -282,6 +289,11 @@ struct ContentView: View {
|
||||
.onChange(of: settings.sortMode) { sortMode in
|
||||
processor.reorderProject(sortMode: sortMode)
|
||||
}
|
||||
.sheet(isPresented: $showingResult) {
|
||||
if let project = processor.project {
|
||||
OutputPreviewView(url: project.outputFileURL)
|
||||
}
|
||||
}
|
||||
.sheet(item: $variantPickerClip) { clip in
|
||||
ClipVariantPickerView(clip: currentClip(id: clip.id) ?? clip) { variant in
|
||||
processor.switchClip(clip, to: variant)
|
||||
@@ -348,26 +360,34 @@ struct ContentView: View {
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Clip-Nachbearbeitung")
|
||||
Text("\(project.clips.count) Clips")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("Ergebnis ca. \(formattedNumber(Double(project.clips.count) * settings.targetClipLength - Double(max(0, project.clips.count - 1)) * settings.transitionLength)) s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Ergebnis ansehen") { showingResult = true }
|
||||
.disabled(!FileManager.default.fileExists(atPath: project.outputFilePath))
|
||||
}
|
||||
HStack {
|
||||
Button("Änderungen neu rendern") {
|
||||
processor.rerenderProject(settings: settings)
|
||||
}
|
||||
.disabled(processor.state.isRunning)
|
||||
.help("Übernimmt geänderte Einstellungen, erneuert bei Bedarf den Clip-Cache und baut danach das finale Video erneut.")
|
||||
.help("Übernimmt Ausgabe-Einstellungen und erhält manuelle Ausschnitte. Für neue Ausschnittlänge oder automatische Segmentwahl: Segmente neu planen.")
|
||||
|
||||
Button("Projekt speichern") {
|
||||
processor.saveCurrentProject()
|
||||
Button("Segmente neu planen") {
|
||||
processor.rerenderProject(settings: settings, replanSegments: true)
|
||||
}
|
||||
.disabled(processor.state.isRunning)
|
||||
.help("Speichert die aktuelle Nachbearbeitung in die .growthlapse-project.json neben der Output-Datei. Segmentänderungen werden zusätzlich automatisch gespeichert.")
|
||||
.help("Berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu. Ersetzt manuelle Segmentstarts nach erfolgreichem Rendern.")
|
||||
|
||||
Button("Ergebnis bestätigen") {
|
||||
processor.confirmProjectAndDeleteCache()
|
||||
Spacer(minLength: 0)
|
||||
Menu("Projekt") {
|
||||
Button("Projekt speichern") { processor.saveCurrentProject() }
|
||||
Button("Render-Cache löschen") { processor.confirmProjectAndDeleteCache() }
|
||||
}
|
||||
.disabled(processor.state.isRunning)
|
||||
.help("Löscht den Render-Cache. Die Projektdatei und das finale Video bleiben erhalten.")
|
||||
.fixedSize()
|
||||
.help("Projekt speichern oder den zugehörigen Cache löschen. Video und Quelldateien bleiben erhalten.")
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
@@ -410,7 +430,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 240, idealWidth: 300, maxWidth: 340, maxHeight: .infinity)
|
||||
.frame(minWidth: 180, idealWidth: 240, maxWidth: 300, maxHeight: .infinity)
|
||||
|
||||
if let clip = selectedClip {
|
||||
clipEditor(clip)
|
||||
@@ -443,55 +463,58 @@ struct ContentView: View {
|
||||
return GeometryReader { proxy in
|
||||
let previewHeight = min(max(150, proxy.size.height - 160), 420)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ClipPreviewView(clip: liveClip)
|
||||
.id(liveClip.sourcePath)
|
||||
.frame(height: previewHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ClipPreviewView(clip: liveClip)
|
||||
.id(liveClip.sourcePath)
|
||||
.frame(height: previewHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
Text(liveClip.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
Text(liveClip.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)
|
||||
HStack {
|
||||
Text("Segmentstart")
|
||||
Spacer()
|
||||
Text("\(formattedNumber(startBinding.wrappedValue)) s / max \(formattedNumber(maxStart)) s")
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
SegmentTimelineView(clip: liveClip)
|
||||
.frame(height: 42)
|
||||
.help("Blau ist das verwendete Segmentfenster. Der markierte rechte Bereich kann nicht als Startpunkt gewählt werden, weil das Segment sonst über das Videoende hinauslaufen würde.")
|
||||
|
||||
if maxStart > 0 {
|
||||
Slider(value: startBinding, in: 0...maxStart, step: 0.1)
|
||||
} else {
|
||||
Slider(value: .constant(0), in: 0...1)
|
||||
.disabled(true)
|
||||
}
|
||||
|
||||
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, (liveClip.duration - liveClip.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)
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(processor.state.isRunning || maxStart <= 0)
|
||||
}
|
||||
|
||||
SegmentTimelineView(clip: liveClip)
|
||||
.frame(height: 42)
|
||||
.help("Blau ist das verwendete Segmentfenster. Der markierte rechte Bereich kann nicht als Startpunkt gewählt werden, weil das Segment sonst über das Videoende hinauslaufen würde.")
|
||||
|
||||
if maxStart > 0 {
|
||||
Slider(value: startBinding, in: 0...maxStart, step: 0.1)
|
||||
} else {
|
||||
Slider(value: .constant(0), in: 0...1)
|
||||
.disabled(true)
|
||||
}
|
||||
|
||||
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, (liveClip.duration - liveClip.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 || maxStart <= 0)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
@@ -611,7 +634,7 @@ struct ContentView: View {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Output-Datei auswählen"
|
||||
panel.allowedContentTypes = [.mpeg4Movie]
|
||||
panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "Joshua_GrowthLapse.mp4"
|
||||
panel.nameFieldStringValue = settings.outputFile?.lastPathComponent ?? "GrowthLapse.mp4"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
settings.outputFile = url.pathExtension.isEmpty ? url.appendingPathExtension("mp4") : url
|
||||
}
|
||||
@@ -628,6 +651,13 @@ struct ContentView: View {
|
||||
if panel.runModal() == .OK,
|
||||
let url = panel.url,
|
||||
let project = processor.loadProject(from: url) {
|
||||
if let saved = project.settings {
|
||||
let ffmpeg = settings.ffmpegExecutable
|
||||
let ffprobe = settings.ffprobeExecutable
|
||||
settings = saved.renderSettings
|
||||
settings.ffmpegExecutable = ffmpeg
|
||||
settings.ffprobeExecutable = ffprobe
|
||||
}
|
||||
settings.outputFile = project.outputFileURL
|
||||
settings.inputFolder = project.clips.first?.sourceURL.deletingLastPathComponent()
|
||||
selectedClipID = project.clips.first?.id
|
||||
@@ -937,3 +967,29 @@ private struct VariantPreviewView: View {
|
||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OutputPreviewView: View {
|
||||
let url: URL
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var player: AVPlayer
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
_player = State(initialValue: AVPlayer(url: url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text(url.lastPathComponent).font(.headline)
|
||||
Spacer()
|
||||
Button("Schließen") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
VideoPlayer(player: player)
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 640, idealWidth: 800, minHeight: 440, idealHeight: 540)
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
final class LockedOutputBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = ""
|
||||
|
||||
func append(_ value: String) {
|
||||
lock.lock()
|
||||
storage += value
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func value() -> String {
|
||||
lock.lock()
|
||||
let result = storage
|
||||
lock.unlock()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
final class LockedCancellationState: @unchecked Sendable {
|
||||
/// Cancellation belongs to this invocation, including the interval before launch.
|
||||
private final class ProcessExecution: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let process = Process()
|
||||
private var cancelled = false
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
cancelled = true
|
||||
lock.unlock()
|
||||
if process.isRunning { process.terminate() }
|
||||
}
|
||||
|
||||
func isCancelled() -> Bool {
|
||||
func execute(executable: URL, arguments: [String], directory: URL?,
|
||||
onOutput: @escaping @Sendable (String) -> Void) throws -> ProcessResult {
|
||||
let pipe = Pipe()
|
||||
let input = try FileHandle(forReadingFrom: URL(fileURLWithPath: "/dev/null"))
|
||||
defer {
|
||||
try? input.close()
|
||||
try? pipe.fileHandleForReading.close()
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
}
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = directory
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = input
|
||||
lock.lock()
|
||||
let result = cancelled
|
||||
if cancelled {
|
||||
lock.unlock()
|
||||
throw CancellationError()
|
||||
}
|
||||
do { try process.run() } catch {
|
||||
lock.unlock()
|
||||
throw error
|
||||
}
|
||||
lock.unlock()
|
||||
return result
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
var output = Data()
|
||||
do {
|
||||
while let chunk = try pipe.fileHandleForReading.read(upToCount: 65536), !chunk.isEmpty {
|
||||
output.append(chunk)
|
||||
if output.count > 4 * 1024 * 1024 {
|
||||
output.removeFirst(output.count - 4 * 1024 * 1024)
|
||||
}
|
||||
onOutput(String(decoding: chunk, as: UTF8.self))
|
||||
}
|
||||
} catch {
|
||||
cancel()
|
||||
process.waitUntilExit()
|
||||
throw error
|
||||
}
|
||||
process.waitUntilExit()
|
||||
lock.lock()
|
||||
let wasCancelled = cancelled
|
||||
lock.unlock()
|
||||
if wasCancelled { throw CancellationError() }
|
||||
return ProcessResult(exitCode: process.terminationStatus,
|
||||
output: String(decoding: output, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +69,7 @@ struct ProcessResult {
|
||||
}
|
||||
|
||||
actor FFmpegService {
|
||||
private var runningProcess: Process?
|
||||
private var execution: ProcessExecution?
|
||||
|
||||
func findExecutable(named name: String) -> URL? {
|
||||
let candidates = [
|
||||
@@ -96,10 +123,7 @@ actor FFmpegService {
|
||||
return (ffmpeg, ffprobe)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
runningProcess?.terminate()
|
||||
runningProcess = nil
|
||||
}
|
||||
func cancel() { execution?.cancel() }
|
||||
|
||||
func run(
|
||||
executable: URL,
|
||||
@@ -108,61 +132,32 @@ actor FFmpegService {
|
||||
onOutput: @escaping @Sendable (String) -> Void = { _ in }
|
||||
) async throws -> ProcessResult {
|
||||
try Task.checkCancellation()
|
||||
let cancellationState = LockedCancellationState()
|
||||
|
||||
guard execution == nil else {
|
||||
throw VideoProcessingError.invalidSettings("Ein Werkzeugprozess läuft bereits.")
|
||||
}
|
||||
let current = ProcessExecution()
|
||||
execution = current
|
||||
defer { execution = nil }
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = currentDirectory
|
||||
|
||||
let pipe = Pipe()
|
||||
let nullInput = FileHandle(forReadingAtPath: "/dev/null")
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = nullInput
|
||||
|
||||
let outputBuffer = LockedOutputBuffer()
|
||||
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
outputBuffer.append(chunk)
|
||||
onOutput(chunk)
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
nullInput?.closeFile()
|
||||
let output = outputBuffer.value()
|
||||
Task {
|
||||
await self.clearRunningProcess(process)
|
||||
}
|
||||
if cancellationState.isCancelled() {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
} else {
|
||||
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
runningProcess = process
|
||||
try process.run()
|
||||
} catch {
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
nullInput?.closeFile()
|
||||
runningProcess = nil
|
||||
continuation.resume(throwing: error)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
continuation.resume(returning: try current.execute(executable: executable,
|
||||
arguments: arguments, directory: currentDirectory, onOutput: onOutput))
|
||||
} catch { continuation.resume(throwing: error) }
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
cancellationState.cancel()
|
||||
Task {
|
||||
await self.cancel()
|
||||
}
|
||||
current.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func validateEncoder(ffmpeg: URL, encoder: VideoEncoder, audio: Bool) async throws {
|
||||
let result = try await run(executable: ffmpeg, arguments: ["-hide_banner", "-encoders"])
|
||||
let name = encoder == .x264 ? "libx264" : "h264_videotoolbox"
|
||||
guard result.exitCode == 0, result.output.contains(name),
|
||||
!audio || result.output.contains(" aac ") else {
|
||||
throw VideoProcessingError.invalidSettings("FFmpeg enthält den gewählten Video-/Audioencoder nicht. Bitte einen anderen Encoder oder FFmpeg-Build wählen.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,11 +179,7 @@ actor FFmpegService {
|
||||
)
|
||||
}
|
||||
|
||||
private func clearRunningProcess(_ process: Process) {
|
||||
if runningProcess === process {
|
||||
runningProcess = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum VideoProcessingError: LocalizedError {
|
||||
|
||||
@@ -156,7 +156,7 @@ struct RenderSettings: Equatable {
|
||||
var targetFaceHeightRatio: Double = 0.26
|
||||
}
|
||||
|
||||
struct PersistedRenderSettings: Codable {
|
||||
struct PersistedRenderSettings: Codable, Equatable {
|
||||
var inputFolderPath: String?
|
||||
var outputFilePath: String?
|
||||
var ffmpegExecutablePath: String?
|
||||
@@ -292,6 +292,10 @@ enum SettingsStore {
|
||||
}
|
||||
|
||||
private static func settingsURL() -> URL {
|
||||
// Allows UI smoke tests to run without touching the user's preferences.
|
||||
if let path = ProcessInfo.processInfo.environment["GROWTHLAPSE_SETTINGS_PATH"], path.hasPrefix("/") {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
return base.appendingPathComponent("GrowthLapse", isDirectory: true).appendingPathComponent("settings.json")
|
||||
@@ -338,6 +342,8 @@ struct GrowthLapseProject: Codable, Equatable {
|
||||
var targetHeight: Int
|
||||
var clips: [GrowthLapseProjectClip]
|
||||
var renderFingerprint: String?
|
||||
var schemaVersion: Int? = 2
|
||||
var settings: PersistedRenderSettings? = nil
|
||||
|
||||
var outputFileURL: URL {
|
||||
URL(fileURLWithPath: outputFilePath)
|
||||
@@ -368,6 +374,7 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable {
|
||||
var segmentStart: Double
|
||||
var segmentLength: Double
|
||||
var normalizedClipPath: String
|
||||
var sourceSignature: String? = nil
|
||||
var needsRender: Bool = false
|
||||
var variants: [GrowthLapseClipVariant]?
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
/// Every render owns a new directory. Existing caches are only read, never edited.
|
||||
enum RenderWorkspace {
|
||||
private static let marker = ".growthlapse-owner.json"
|
||||
|
||||
static func create(for output: URL) throws -> URL {
|
||||
let directory = output.deletingLastPathComponent().resolvingSymlinksInPath()
|
||||
.appendingPathComponent("GrowthLapse-\(UUID().uuidString).growthlapse-cache", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
do {
|
||||
try JSONEncoder().encode(output.standardizedFileURL.path)
|
||||
.write(to: directory.appendingPathComponent(marker), options: .atomic)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
throw error
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
static func isOwned(_ directory: URL, by output: URL) -> Bool {
|
||||
let normalized = directory.standardizedFileURL
|
||||
guard normalized.path == normalized.resolvingSymlinksInPath().standardizedFileURL.path,
|
||||
normalized.deletingLastPathComponent().path == output.standardizedFileURL.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path,
|
||||
normalized.lastPathComponent.hasPrefix("GrowthLapse-"),
|
||||
normalized.pathExtension == "growthlapse-cache",
|
||||
let data = try? Data(contentsOf: normalized.appendingPathComponent(marker)),
|
||||
let owner = try? JSONDecoder().decode(String.self, from: data),
|
||||
owner == output.standardizedFileURL.path else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
static func remove(_ directory: URL, for output: URL) throws {
|
||||
guard isOwned(directory, by: output) else {
|
||||
throw VideoProcessingError.invalidSettings("Dieser Ordner ist kein bestätigter GrowthLapse-Cache und wird nicht gelöscht.")
|
||||
}
|
||||
try FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
/// Rename on the same volume replaces a file atomically. Backups permit rollback
|
||||
/// if publishing a later artifact fails (not a crash-proof multi-file transaction).
|
||||
static func publish(_ files: [(staged: URL, destination: URL)], in directory: URL) throws {
|
||||
let manager = FileManager.default
|
||||
var backups: [URL?] = []
|
||||
for (index, file) in files.enumerated() {
|
||||
guard (try file.staged.resourceValues(forKeys: [.isRegularFileKey])).isRegularFile == true else {
|
||||
throw VideoProcessingError.invalidSettings("Die Render-Ausgabe fehlt oder ist keine Datei.")
|
||||
}
|
||||
if manager.fileExists(atPath: file.destination.path) {
|
||||
let values = try file.destination.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
guard values.isRegularFile == true, values.isSymbolicLink != true else {
|
||||
throw VideoProcessingError.invalidSettings("Das Ausgabeziel muss eine reguläre Datei sein.")
|
||||
}
|
||||
let backup = directory.appendingPathComponent("backup-\(index)")
|
||||
try manager.copyItem(at: file.destination, to: backup)
|
||||
backups.append(backup)
|
||||
} else {
|
||||
backups.append(nil)
|
||||
}
|
||||
}
|
||||
var published = 0
|
||||
do {
|
||||
for file in files {
|
||||
try moveReplacing(file.staged, file.destination)
|
||||
published += 1
|
||||
}
|
||||
} catch {
|
||||
let originalError = error
|
||||
do {
|
||||
for index in (0..<published).reversed() {
|
||||
if let backup = backups[index] {
|
||||
try moveReplacing(backup, files[index].destination)
|
||||
} else {
|
||||
try manager.removeItem(at: files[index].destination)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw PublicationRecoveryError(directory: directory, details: error.localizedDescription)
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
for backup in backups.compactMap({ $0 }) { try? manager.removeItem(at: backup) }
|
||||
}
|
||||
|
||||
private static func moveReplacing(_ source: URL, _ destination: URL) throws {
|
||||
guard rename(source.path, destination.path) == 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detached analysis runs away from the UI, but follows its caller's cancellation.
|
||||
func cancellableAnalysis<T: Sendable>(
|
||||
_ operation: @escaping @Sendable () throws -> T
|
||||
) async throws -> T {
|
||||
let task = Task.detached(priority: .userInitiated, operation: operation)
|
||||
return try await withTaskCancellationHandler {
|
||||
let result = try await task.value
|
||||
try Task.checkCancellation()
|
||||
return result
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicationRecoveryError: LocalizedError {
|
||||
let directory: URL
|
||||
let details: String
|
||||
var errorDescription: String? {
|
||||
"Ausgabe konnte nicht vollständig wiederhergestellt werden. Sicherungen bleiben unter \(directory.path). \(details)"
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,6 @@ final class VideoProcessor: ObservableObject {
|
||||
renderTask = Task {
|
||||
do {
|
||||
let outcome = try await render(settings: settingsCopy, existingProject: nil)
|
||||
guard !Task.isCancelled else {
|
||||
throw CancellationError()
|
||||
}
|
||||
project = outcome.project
|
||||
state = .finished(outcome.outputURL)
|
||||
progress = 1
|
||||
@@ -64,7 +61,7 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func rerenderProject(settings: RenderSettings) {
|
||||
func rerenderProject(settings: RenderSettings, replanSegments: Bool = false) {
|
||||
guard !state.isRunning, let project else {
|
||||
return
|
||||
}
|
||||
@@ -80,10 +77,7 @@ final class VideoProcessor: ObservableObject {
|
||||
let projectCopy = reorderedProject(project, sortMode: settings.sortMode)
|
||||
renderTask = Task {
|
||||
do {
|
||||
let outcome = try await render(settings: settingsCopy, existingProject: projectCopy)
|
||||
guard !Task.isCancelled else {
|
||||
throw CancellationError()
|
||||
}
|
||||
let outcome = try await render(settings: settingsCopy, existingProject: projectCopy, replanSegments: replanSegments)
|
||||
self.project = outcome.project
|
||||
state = .finished(outcome.outputURL)
|
||||
progress = 1
|
||||
@@ -153,7 +147,7 @@ final class VideoProcessor: ObservableObject {
|
||||
appendLog("Kein Projekt zum Speichern geladen.")
|
||||
return
|
||||
}
|
||||
saveProject(project)
|
||||
guard saveProject(project) else { return }
|
||||
appendLog("Projekt gespeichert: \(GrowthLapseProject.url(for: project.outputFileURL).path)")
|
||||
}
|
||||
|
||||
@@ -161,6 +155,7 @@ final class VideoProcessor: ObservableObject {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
var loadedProject = try JSONDecoder().decode(GrowthLapseProject.self, from: data)
|
||||
try validateProject(loadedProject)
|
||||
loadedProject.clips = loadedProject.clips.map { clip in
|
||||
var updated = clip
|
||||
if !FileManager.default.fileExists(atPath: updated.normalizedClipPath) {
|
||||
@@ -168,6 +163,9 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
return updated
|
||||
}
|
||||
warningText = loadedProject.settings == nil
|
||||
? "Älteres Projekt ohne Render-Einstellungen: Es gelten die aktuellen Einstellungen."
|
||||
: nil
|
||||
project = loadedProject
|
||||
state = .finished(loadedProject.outputFileURL)
|
||||
progress = 1
|
||||
@@ -185,11 +183,16 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
|
||||
func confirmProjectAndDeleteCache() {
|
||||
guard let project else {
|
||||
guard !state.isRunning, let project else {
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: project.cacheDirectoryURL)
|
||||
appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)")
|
||||
do {
|
||||
try RenderWorkspace.remove(project.cacheDirectoryURL, for: project.outputFileURL)
|
||||
appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)")
|
||||
} catch {
|
||||
warningText = error.localizedDescription
|
||||
appendLog("Cache bleibt erhalten: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
@@ -200,16 +203,13 @@ final class VideoProcessor: ObservableObject {
|
||||
progressText = "Wird abgebrochen"
|
||||
appendLog("Abbruch angefordert.")
|
||||
renderTask?.cancel()
|
||||
Task {
|
||||
await service.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func showInFinder(url: URL) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
private func render(settings: RenderSettings, existingProject: GrowthLapseProject?) async throws -> RenderOutcome {
|
||||
private func render(settings: RenderSettings, existingProject: GrowthLapseProject?, replanSegments: Bool = false) async throws -> RenderOutcome {
|
||||
var effectiveSettings = settings
|
||||
try validate(effectiveSettings)
|
||||
let tools = try await service.validateTools(
|
||||
@@ -229,7 +229,7 @@ final class VideoProcessor: ObservableObject {
|
||||
throw VideoProcessingError.invalidSettings("Clipnummern können nicht eingeblendet werden, weil dieser ffmpeg-Build den Filter drawtext nicht enthält.")
|
||||
}
|
||||
if effectiveSettings.stabilizationEnabled {
|
||||
effectiveSettings.stabilizationMethod = resolveStabilizationMethod(
|
||||
effectiveSettings.stabilizationMethod = try resolveStabilizationMethod(
|
||||
requested: effectiveSettings.stabilizationMethod,
|
||||
availability: filters
|
||||
)
|
||||
@@ -240,23 +240,45 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
try await service.validateEncoder(ffmpeg: tools.ffmpeg, encoder: effectiveSettings.videoEncoder,
|
||||
audio: !effectiveSettings.removeAudio)
|
||||
let inputFolder = settings.inputFolder!
|
||||
let outputFile = settings.outputFile!
|
||||
let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil
|
||||
let tempDirectory = try createWorkingDirectory(
|
||||
keepDebugFiles: settings.keepIntermediateClips,
|
||||
keepReviewCache: useReviewCache,
|
||||
outputFile: outputFile,
|
||||
resetCache: existingProject == nil
|
||||
)
|
||||
let previousOnDisk = (try? Data(contentsOf: GrowthLapseProject.url(for: outputFile)))
|
||||
.flatMap { try? JSONDecoder().decode(GrowthLapseProject.self, from: $0) }
|
||||
if let existingProject { try validateProject(existingProject) }
|
||||
if existingProject == nil, FileManager.default.fileExists(atPath: outputFile.path),
|
||||
inputFolder.resolvingSymlinksInPath().standardizedFileURL.path == outputFile.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path,
|
||||
previousOnDisk?.outputFileURL.standardizedFileURL.path != outputFile.standardizedFileURL.path {
|
||||
throw VideoProcessingError.invalidSettings("Eine vorhandene Videodatei im Input-Ordner darf nicht als neues Ausgabeziel verwendet werden. Bitte einen neuen Dateinamen wählen.")
|
||||
}
|
||||
let tempDirectory = try RenderWorkspace.create(for: outputFile)
|
||||
temporaryDirectory = tempDirectory
|
||||
appendLog("Temporärer Ordner: \(tempDirectory.path)")
|
||||
var committed = false
|
||||
defer {
|
||||
temporaryDirectory = nil
|
||||
if !committed { try? RenderWorkspace.remove(tempDirectory, for: outputFile) }
|
||||
}
|
||||
let stagedOutput = tempDirectory.appendingPathComponent("result.mp4")
|
||||
appendLog("Arbeitsordner: \(tempDirectory.path)")
|
||||
|
||||
let videos: [VideoFile]
|
||||
if let existingProject {
|
||||
videos = existingProject.clips.map {
|
||||
VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height, variants: $0.availableVariants)
|
||||
var refreshed: [VideoFile] = []
|
||||
for clip in existingProject.clips {
|
||||
try Task.checkCancellation()
|
||||
if try sourceSignature(clip.sourceURL) != clip.sourceSignature {
|
||||
let duration = try await probeDuration(url: clip.sourceURL, ffprobe: tools.ffprobe)
|
||||
let dimensions = try await probeDimensions(url: clip.sourceURL, ffprobe: tools.ffprobe)
|
||||
refreshed.append(VideoFile(url: clip.sourceURL, duration: duration,
|
||||
width: dimensions.width, height: dimensions.height, variants: clip.availableVariants))
|
||||
} else {
|
||||
refreshed.append(VideoFile(url: clip.sourceURL, duration: clip.duration,
|
||||
width: clip.width, height: clip.height, variants: clip.availableVariants))
|
||||
}
|
||||
}
|
||||
videos = refreshed
|
||||
appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).")
|
||||
} else {
|
||||
videos = try await discoverVideos(
|
||||
@@ -267,12 +289,16 @@ final class VideoProcessor: ObservableObject {
|
||||
excluding: outputFile
|
||||
)
|
||||
}
|
||||
guard !videos.isEmpty else { throw VideoProcessingError.noVideosFound }
|
||||
guard !videos.contains(where: { $0.url.resolvingSymlinksInPath().standardizedFileURL == outputFile.resolvingSymlinksInPath().standardizedFileURL }) else {
|
||||
throw VideoProcessingError.invalidSettings("Die Ausgabe darf keine Quelldatei ersetzen.")
|
||||
}
|
||||
appendLog("\(videos.count) Video(s) gefunden.")
|
||||
let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
|
||||
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")
|
||||
|
||||
var renderProject: GrowthLapseProject
|
||||
if let existingProject {
|
||||
if let existingProject, !replanSegments {
|
||||
renderProject = existingProject
|
||||
} else {
|
||||
renderProject = try await makeProject(
|
||||
@@ -284,6 +310,32 @@ final class VideoProcessor: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
renderProject.outputFilePath = outputFile.path
|
||||
renderProject.cacheDirectoryPath = tempDirectory.path
|
||||
renderProject.targetWidth = targetDimensions.width
|
||||
renderProject.targetHeight = targetDimensions.height
|
||||
renderProject.schemaVersion = 2
|
||||
renderProject.settings = PersistedRenderSettings(settings: effectiveSettings)
|
||||
for index in renderProject.clips.indices {
|
||||
let old = renderProject.clips[index]
|
||||
let destination = tempDirectory.appendingPathComponent("clip-\(old.id.uuidString).mp4")
|
||||
let signature = try sourceSignature(old.sourceURL)
|
||||
let canReuse = !old.needsRender && old.sourceSignature == signature
|
||||
&& FileManager.default.fileExists(atPath: old.normalizedClipPath)
|
||||
if canReuse {
|
||||
try FileManager.default.copyItem(at: old.normalizedClipURL, to: destination)
|
||||
}
|
||||
renderProject.clips[index].duration = videos[index].duration
|
||||
renderProject.clips[index].width = videos[index].width
|
||||
renderProject.clips[index].height = videos[index].height
|
||||
renderProject.clips[index].segmentLength = min(old.segmentLength, videos[index].duration)
|
||||
renderProject.clips[index].segmentStart = min(old.segmentStart,
|
||||
max(0, videos[index].duration - renderProject.clips[index].segmentLength))
|
||||
renderProject.clips[index].normalizedClipPath = destination.path
|
||||
renderProject.clips[index].sourceSignature = signature
|
||||
renderProject.clips[index].needsRender = !canReuse
|
||||
}
|
||||
|
||||
let fingerprint = renderFingerprint(settings: effectiveSettings, targetDimensions: targetDimensions)
|
||||
if renderProject.renderFingerprint != fingerprint {
|
||||
if renderProject.renderFingerprint != nil {
|
||||
@@ -306,32 +358,47 @@ final class VideoProcessor: ObservableObject {
|
||||
)
|
||||
|
||||
if effectiveSettings.transitionLength <= 0 {
|
||||
try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf)
|
||||
try await concatenate(clips: normalizedClips, outputFile: stagedOutput, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf)
|
||||
} else {
|
||||
try await crossfade(
|
||||
clips: normalizedClips,
|
||||
settings: effectiveSettings,
|
||||
targetDimensions: targetDimensions,
|
||||
outputFile: outputFile,
|
||||
outputFile: stagedOutput,
|
||||
ffmpeg: tools.ffmpeg
|
||||
)
|
||||
}
|
||||
|
||||
let reportURL = writeRenderReport(
|
||||
settings: effectiveSettings,
|
||||
outputFile: outputFile,
|
||||
targetDimensions: targetDimensions,
|
||||
project: renderProject
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
let stagedProject = tempDirectory.appendingPathComponent("project.json")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(renderProject).write(to: stagedProject, options: .atomic)
|
||||
do {
|
||||
try RenderWorkspace.publish([
|
||||
(stagedOutput, outputFile),
|
||||
(stagedProject, GrowthLapseProject.url(for: outputFile))
|
||||
], in: tempDirectory)
|
||||
} catch let error as PublicationRecoveryError {
|
||||
committed = true // Retain backups for manual recovery if rollback itself failed.
|
||||
throw error
|
||||
}
|
||||
committed = true
|
||||
let reportURL = writeRenderReport(settings: effectiveSettings, outputFile: outputFile,
|
||||
targetDimensions: targetDimensions, project: renderProject)
|
||||
appendLog("Render-Protokoll: \(reportURL.path)")
|
||||
saveProject(renderProject)
|
||||
appendLog("Projektdatei: \(GrowthLapseProject.url(for: outputFile).path)")
|
||||
|
||||
if !settings.keepIntermediateClips && !useReviewCache {
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
try? RenderWorkspace.remove(tempDirectory, for: outputFile)
|
||||
} else {
|
||||
cleanupTransformFiles(in: tempDirectory)
|
||||
}
|
||||
for previous in [existingProject, previousOnDisk].compactMap({ $0 }) {
|
||||
if previous.outputFileURL.standardizedFileURL == outputFile.standardizedFileURL,
|
||||
RenderWorkspace.isOwned(previous.cacheDirectoryURL, by: outputFile) {
|
||||
try? RenderWorkspace.remove(previous.cacheDirectoryURL, for: outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
return RenderOutcome(outputURL: outputFile, project: renderProject)
|
||||
}
|
||||
@@ -565,7 +632,10 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod {
|
||||
private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) throws -> StabilizationMethod {
|
||||
guard (requested == .vidstab && availability.hasVidstab) || availability.hasDeshake else {
|
||||
throw VideoProcessingError.invalidSettings("Der FFmpeg-Build enthält keinen passenden Stabilisierungsfilter. Bitte Stabilisierung deaktivieren oder FFmpeg mit VidStab/Deshake auswählen.")
|
||||
}
|
||||
switch requested {
|
||||
case .vidstab:
|
||||
if availability.hasVidstab {
|
||||
@@ -585,7 +655,56 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceSignature(_ url: URL) throws -> String {
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey])
|
||||
guard values.isRegularFile == true else {
|
||||
throw VideoProcessingError.invalidSettings("Quelldatei fehlt: \(url.path)")
|
||||
}
|
||||
return "\(values.fileSize ?? -1)|\(values.contentModificationDate?.timeIntervalSince1970 ?? 0)"
|
||||
}
|
||||
|
||||
private func validateProject(_ project: GrowthLapseProject) throws {
|
||||
guard (project.schemaVersion ?? 1) <= 2, !project.clips.isEmpty,
|
||||
project.targetWidth > 0, project.targetHeight > 0,
|
||||
project.outputFilePath.hasPrefix("/"), project.cacheDirectoryPath.hasPrefix("/"),
|
||||
Set(project.clips.map(\.id)).count == project.clips.count else {
|
||||
throw VideoProcessingError.invalidSettings("Ungültiges oder nicht unterstütztes GrowthLapse-Projekt.")
|
||||
}
|
||||
if let saved = project.settings { try validate(saved.renderSettings) }
|
||||
let cache = project.cacheDirectoryURL.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
for clip in project.clips {
|
||||
guard clip.sourcePath.hasPrefix("/"), clip.normalizedClipPath.hasPrefix("/"),
|
||||
clip.duration.isFinite, clip.duration > 0,
|
||||
clip.segmentStart.isFinite, clip.segmentStart >= 0,
|
||||
clip.segmentLength.isFinite, clip.segmentLength > 0,
|
||||
clip.segmentStart + clip.segmentLength <= clip.duration + 0.001,
|
||||
clip.width > 0, clip.height > 0,
|
||||
clip.normalizedClipURL.resolvingSymlinksInPath().deletingLastPathComponent().standardizedFileURL.path == cache,
|
||||
clip.normalizedClipURL.pathExtension.lowercased() == "mp4",
|
||||
clip.normalizedClipURL.resolvingSymlinksInPath() != clip.sourceURL.resolvingSymlinksInPath() else {
|
||||
throw VideoProcessingError.invalidSettings("Ungültige Clipdaten: \(clip.displayName)")
|
||||
}
|
||||
for variant in clip.availableVariants {
|
||||
guard variant.sourcePath.hasPrefix("/"), variant.duration.isFinite, variant.duration > 0,
|
||||
variant.width > 0, variant.height > 0 else {
|
||||
throw VideoProcessingError.invalidSettings("Ungültige Clip-Variante: \(variant.displayName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func validate(_ settings: RenderSettings) throws {
|
||||
guard [settings.segmentLength, settings.targetClipLength, settings.transitionLength,
|
||||
settings.startOffset, settings.targetFaceHeightRatio, settings.stabilizationAnchorX,
|
||||
settings.stabilizationAnchorY, settings.stabilizationAnchorSize].allSatisfy({ $0.isFinite }) else {
|
||||
throw VideoProcessingError.invalidSettings("Render-Parameter müssen endliche Zahlen sein.")
|
||||
}
|
||||
if let output = settings.outputFile {
|
||||
guard output.pathExtension.lowercased() == "mp4" else {
|
||||
throw VideoProcessingError.invalidSettings("Bitte eine MP4-Ausgabedatei auswählen.")
|
||||
}
|
||||
}
|
||||
|
||||
guard settings.inputFolder != nil else {
|
||||
throw VideoProcessingError.invalidSettings("Bitte wähle einen Input-Ordner aus.")
|
||||
}
|
||||
@@ -634,7 +753,8 @@ final class VideoProcessor: ObservableObject {
|
||||
)
|
||||
|
||||
var urls = contents.filter { url in
|
||||
supportedExtensions.contains(url.pathExtension.lowercased())
|
||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
|
||||
&& supportedExtensions.contains(url.pathExtension.lowercased())
|
||||
&& url.standardizedFileURL.path != excludedPath
|
||||
}
|
||||
if let excludedPath, contents.contains(where: { $0.standardizedFileURL.path == excludedPath }) {
|
||||
@@ -1571,6 +1691,8 @@ final class VideoProcessor: ObservableObject {
|
||||
}
|
||||
appendLog("Bestes Segment Clip \(clipNumber): Start \(formatSeconds(result.start)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount), Score \(String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), result.score))")
|
||||
return result.start
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
appendLog("Warnung: Bestes Segment Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription). Nutze Fallback @ \(formatSeconds(safeFallback))")
|
||||
return safeFallback
|
||||
@@ -1604,6 +1726,7 @@ final class VideoProcessor: ObservableObject {
|
||||
var copy = project
|
||||
copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in
|
||||
var updated = clip
|
||||
if updated.index != offset + 1 { updated.needsRender = true }
|
||||
updated.index = offset + 1
|
||||
return updated
|
||||
}
|
||||
@@ -1635,15 +1758,19 @@ final class VideoProcessor: ObservableObject {
|
||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
}
|
||||
|
||||
private func saveProject(_ project: GrowthLapseProject) {
|
||||
@discardableResult
|
||||
private func saveProject(_ project: GrowthLapseProject) -> Bool {
|
||||
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])
|
||||
return true
|
||||
} catch {
|
||||
appendLog("Warnung: Projektdatei konnte nicht geschrieben werden: \(error.localizedDescription)")
|
||||
warningText = "Projektdatei konnte nicht gespeichert werden: \(error.localizedDescription)"
|
||||
appendLog(warningText!)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2053,16 +2180,10 @@ final class VideoProcessor: ObservableObject {
|
||||
|
||||
private func handleCancellation() {
|
||||
cancellationRequested = false
|
||||
Task {
|
||||
await service.cancel()
|
||||
}
|
||||
state = .cancelled
|
||||
progressText = "Abgebrochen"
|
||||
appendLog("Rendern abgebrochen.")
|
||||
if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) {
|
||||
cleanupTransformFiles(in: temporaryDirectory)
|
||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2095,7 +2216,7 @@ private enum PhotoMorphRenderer {
|
||||
targetFaceHeightRatio: Double,
|
||||
progress: @escaping (Int, Int, String, Bool) -> Void
|
||||
) async throws -> [PreparedMorphImage] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try await cancellableAnalysis {
|
||||
var result: [PreparedMorphImage] = []
|
||||
for (index, url) in urls.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
@@ -2111,7 +2232,7 @@ private enum PhotoMorphRenderer {
|
||||
progress(index + 1, urls.count, url.lastPathComponent, normalized.usedLandmarks)
|
||||
}
|
||||
return result
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func renderFrames(
|
||||
@@ -2121,7 +2242,7 @@ private enum PhotoMorphRenderer {
|
||||
secondsPerTransition: Double,
|
||||
progress: @escaping (Int, Int) -> Void
|
||||
) async throws -> Int {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try await cancellableAnalysis {
|
||||
guard images.count >= 2 else {
|
||||
return 0
|
||||
}
|
||||
@@ -2154,7 +2275,7 @@ private enum PhotoMorphRenderer {
|
||||
try writePNG(last, to: outputDirectory.appendingPathComponent(String(format: "frame_%06d.png", written)))
|
||||
progress(written, totalFrames)
|
||||
return written
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadImage(_ url: URL) throws -> CGImage {
|
||||
@@ -2461,7 +2582,7 @@ private enum BestSegmentAnalyzer {
|
||||
duration: Double,
|
||||
segmentLength: Double
|
||||
) async throws -> BestSegmentResult? {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try await cancellableAnalysis {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let sampleInterval = duration <= 60 ? 0.5 : 1.0
|
||||
@@ -2519,11 +2640,11 @@ private enum BestSegmentAnalyzer {
|
||||
return nil
|
||||
}
|
||||
return best
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
private static func analyzeSamples(source: URL, duration: Double, interval: Double) throws -> [EyeVisibilitySample] {
|
||||
let asset = AVAsset(url: source)
|
||||
let asset = AVURLAsset(url: source)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: 960, height: 960)
|
||||
@@ -2609,10 +2730,10 @@ private enum FaceAlignmentAnalyzer {
|
||||
targetDimensions: VideoDimensions,
|
||||
targetFaceHeightRatio: Double
|
||||
) async throws -> FaceAlignment? {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try await cancellableAnalysis {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let asset = AVAsset(url: source)
|
||||
let asset = AVURLAsset(url: source)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600)
|
||||
@@ -2663,7 +2784,7 @@ private enum FaceAlignmentAnalyzer {
|
||||
targetDimensions: targetDimensions,
|
||||
targetFaceHeightRatio: targetFaceHeightRatio
|
||||
)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
private static func sampleTimes(start: Double, duration: Double) -> [CMTime] {
|
||||
|
||||
Reference in new issue
Block a user