Improve portrait alignment and rendering pipeline
This commit is contained in:
@@ -13,20 +13,11 @@ struct ContentView: View {
|
||||
HSplitView {
|
||||
Form {
|
||||
Section("Dateien") {
|
||||
Picker("Modus", selection: $settings.renderMode) {
|
||||
ForEach(RenderMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.help("Video-Zeitraffer verarbeitet MOV/MP4-Clips. Foto-Morphing nutzt Bilder aus dem Input-Ordner oder extrahiert passende Standbilder aus Videos und erzeugt Landmark-basierte Morph-Übergänge.")
|
||||
|
||||
pickerRow(
|
||||
title: "Input-Ordner",
|
||||
value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt",
|
||||
buttonTitle: "Auswählen",
|
||||
help: settings.renderMode == .photoMorphing
|
||||
? "Ordner mit chronologisch sortierten Bildern oder Videos. Wenn keine Bilder gefunden werden, extrahiert GrowthLapse Standbilder aus den Videos."
|
||||
: "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen."
|
||||
help: "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen."
|
||||
) {
|
||||
selectInputFolder()
|
||||
}
|
||||
@@ -79,10 +70,8 @@ struct ContentView: View {
|
||||
.disabled(processor.state.isRunning)
|
||||
|
||||
Section("Parameter") {
|
||||
if settings.renderMode == .videoGrowthLapse {
|
||||
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: settings.renderMode == .photoMorphing ? "Dauer des Morph-Übergangs von einem Bild zum nächsten." : "Länge jedes fertigen Zwischenclips nach der Beschleunigung. Beispiel: 10 Sekunden Segment auf 2 Sekunden ergibt 5x Speed.")
|
||||
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.")
|
||||
Stepper(value: $settings.fps, in: 1...120) {
|
||||
HStack {
|
||||
@@ -102,9 +91,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.help("Qualitätswert für libx264. Niedriger ist bessere Qualität/größere Datei. Wird bei VideoToolbox nicht verwendet.")
|
||||
if settings.renderMode == .videoGrowthLapse {
|
||||
doubleStepper("Start-Offset", value: $settings.startOffset, range: -60...60, step: 0.5, suffix: "s", help: "Verschiebt den gewählten Ausschnitt relativ zur Mitte. Positive Werte nehmen späteres Material, negative früheres.")
|
||||
}
|
||||
doubleStepper("Start-Offset", value: $settings.startOffset, range: -60...60, step: 0.5, suffix: "s", help: "Verschiebt den gewählten Ausschnitt relativ zur Mitte. Positive Werte nehmen späteres Material, negative früheres.")
|
||||
}
|
||||
.disabled(processor.state.isRunning)
|
||||
|
||||
@@ -179,7 +166,7 @@ struct ContentView: View {
|
||||
Text(strength.rawValue).tag(strength)
|
||||
}
|
||||
}
|
||||
.help("Stärkere Werte glätten mehr Bewegung, können aber stärker zoomen/croppen oder schwimmender wirken.")
|
||||
.help("Stärkere Werte glätten mehr Bewegung. GrowthLapse hält den Sicherheits-Zoom dabei konstant, damit das Bild nicht pumpt.")
|
||||
.disabled(!settings.stabilizationEnabled)
|
||||
|
||||
Toggle("Auf Hintergrundpunkt fokussieren", isOn: $settings.stabilizationAnchorEnabled)
|
||||
@@ -199,7 +186,7 @@ struct ContentView: View {
|
||||
.help("Analysiert das Originalvideo vor dem Schneiden und sucht das Fenster, in dem Gesicht und beide Augen am besten sichtbar sind. Wenn nichts Brauchbares gefunden wird, nutzt GrowthLapse die normale Mitte/Start-Offset-Logik.")
|
||||
|
||||
Toggle("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled)
|
||||
.help("Analysiert mehrere Frames mit Apple Vision und berechnet einen statischen Crop pro Clip. Kein Frame-by-frame Tracking.")
|
||||
.help("Verfolgt Gesicht und Augen mit Apple Vision, richtet die Augenlinie aus und bewegt den Bildausschnitt geglättet mit. Ist das Gesicht bereits zu groß, wird zur Not herausgezoomt.")
|
||||
|
||||
HStack {
|
||||
Text("Ziel-Gesichtshöhe")
|
||||
@@ -367,8 +354,8 @@ struct ContentView: View {
|
||||
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.")
|
||||
.disabled(processor.state.isRunning)
|
||||
.help("Übernimmt geänderte Einstellungen, erneuert bei Bedarf den Clip-Cache und baut danach das finale Video erneut.")
|
||||
|
||||
Button("Projekt speichern") {
|
||||
processor.saveCurrentProject()
|
||||
|
||||
@@ -18,6 +18,24 @@ final class LockedOutputBuffer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
final class LockedCancellationState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var cancelled = false
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func isCancelled() -> Bool {
|
||||
lock.lock()
|
||||
let result = cancelled
|
||||
lock.unlock()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessResult {
|
||||
let exitCode: Int32
|
||||
let output: String
|
||||
@@ -90,6 +108,7 @@ actor FFmpegService {
|
||||
onOutput: @escaping @Sendable (String) -> Void = { _ in }
|
||||
) async throws -> ProcessResult {
|
||||
try Task.checkCancellation()
|
||||
let cancellationState = LockedCancellationState()
|
||||
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
@@ -122,7 +141,11 @@ actor FFmpegService {
|
||||
Task {
|
||||
await self.clearRunningProcess(process)
|
||||
}
|
||||
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
||||
if cancellationState.isCancelled() {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
} else {
|
||||
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -136,6 +159,7 @@ actor FFmpegService {
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
cancellationState.cancel()
|
||||
Task {
|
||||
await self.cancel()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ enum RenderMode: String, CaseIterable, Identifiable, Codable {
|
||||
case videoGrowthLapse = "Video-Zeitraffer"
|
||||
case photoMorphing = "Foto-Morphing"
|
||||
|
||||
static var allCases: [RenderMode] {
|
||||
[.videoGrowthLapse]
|
||||
}
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -99,10 +103,10 @@ enum StabilizationStrength: String, CaseIterable, Identifiable, Codable {
|
||||
|
||||
var deshakeSearch: Int {
|
||||
switch self {
|
||||
case .light: return 48
|
||||
case .medium: return 64
|
||||
case .strong: return 96
|
||||
case .veryStrong: return 128
|
||||
case .light: return 16
|
||||
case .medium: return 32
|
||||
case .strong: return 48
|
||||
case .veryStrong: return 64
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +147,7 @@ struct RenderSettings: Equatable {
|
||||
var stabilizationEnabled: Bool = true
|
||||
var stabilizationMethod: StabilizationMethod = .vidstab
|
||||
var stabilizationStrength: StabilizationStrength = .strong
|
||||
var stabilizationAnchorEnabled: Bool = true
|
||||
var stabilizationAnchorEnabled: Bool = false
|
||||
var stabilizationAnchorX: Double = 0.2
|
||||
var stabilizationAnchorY: Double = 0.18
|
||||
var stabilizationAnchorSize: Double = 0.22
|
||||
@@ -263,7 +267,11 @@ enum SettingsStore {
|
||||
let persisted = try? JSONDecoder().decode(PersistedRenderSettings.self, from: data) else {
|
||||
return RenderSettings()
|
||||
}
|
||||
return persisted.renderSettings
|
||||
var settings = persisted.renderSettings
|
||||
if settings.renderMode == .photoMorphing {
|
||||
settings.renderMode = .videoGrowthLapse
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
static func save(_ settings: RenderSettings) {
|
||||
@@ -329,6 +337,7 @@ struct GrowthLapseProject: Codable, Equatable {
|
||||
var targetWidth: Int
|
||||
var targetHeight: Int
|
||||
var clips: [GrowthLapseProjectClip]
|
||||
var renderFingerprint: String?
|
||||
|
||||
var outputFileURL: URL {
|
||||
URL(fileURLWithPath: outputFilePath)
|
||||
@@ -394,12 +403,22 @@ struct VideoDimensions: Equatable {
|
||||
let height: Int
|
||||
}
|
||||
|
||||
struct FaceCrop: Equatable {
|
||||
let x: Int
|
||||
let y: Int
|
||||
struct FaceAlignmentSample: Equatable {
|
||||
let time: Double
|
||||
let x: Double
|
||||
let y: Double
|
||||
}
|
||||
|
||||
struct FaceAlignment: Equatable {
|
||||
let sourceWidth: Int
|
||||
let sourceHeight: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
let rotationRadians: Double
|
||||
let contentScale: Double
|
||||
let samples: [FaceAlignmentSample]
|
||||
let faceHeightRatio: Double
|
||||
let usesEyeLandmarks: Bool
|
||||
}
|
||||
|
||||
enum RenderState: Equatable {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user