Improve portrait alignment and rendering pipeline
This commit is contained in:
@@ -13,20 +13,11 @@ struct ContentView: View {
|
|||||||
HSplitView {
|
HSplitView {
|
||||||
Form {
|
Form {
|
||||||
Section("Dateien") {
|
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(
|
pickerRow(
|
||||||
title: "Input-Ordner",
|
title: "Input-Ordner",
|
||||||
value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt",
|
value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt",
|
||||||
buttonTitle: "Auswählen",
|
buttonTitle: "Auswählen",
|
||||||
help: settings.renderMode == .photoMorphing
|
help: "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen."
|
||||||
? "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."
|
|
||||||
) {
|
) {
|
||||||
selectInputFolder()
|
selectInputFolder()
|
||||||
}
|
}
|
||||||
@@ -79,10 +70,8 @@ struct ContentView: View {
|
|||||||
.disabled(processor.state.isRunning)
|
.disabled(processor.state.isRunning)
|
||||||
|
|
||||||
Section("Parameter") {
|
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("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("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("Ü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.")
|
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) {
|
Stepper(value: $settings.fps, in: 1...120) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -102,10 +91,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help("Qualitätswert für libx264. Niedriger ist bessere Qualität/größere Datei. Wird bei VideoToolbox nicht verwendet.")
|
.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)
|
.disabled(processor.state.isRunning)
|
||||||
|
|
||||||
Section("Ausgabe") {
|
Section("Ausgabe") {
|
||||||
@@ -179,7 +166,7 @@ struct ContentView: View {
|
|||||||
Text(strength.rawValue).tag(strength)
|
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)
|
.disabled(!settings.stabilizationEnabled)
|
||||||
|
|
||||||
Toggle("Auf Hintergrundpunkt fokussieren", isOn: $settings.stabilizationAnchorEnabled)
|
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.")
|
.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)
|
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 {
|
HStack {
|
||||||
Text("Ziel-Gesichtshöhe")
|
Text("Ziel-Gesichtshöhe")
|
||||||
@@ -367,8 +354,8 @@ struct ContentView: View {
|
|||||||
Button("Änderungen neu rendern") {
|
Button("Änderungen neu rendern") {
|
||||||
processor.rerenderProject(settings: settings)
|
processor.rerenderProject(settings: settings)
|
||||||
}
|
}
|
||||||
.disabled(processor.state.isRunning || !project.clips.contains(where: \.needsRender))
|
.disabled(processor.state.isRunning)
|
||||||
.help("Rendert nur geänderte normalisierte Clips neu und baut danach das finale Video erneut.")
|
.help("Übernimmt geänderte Einstellungen, erneuert bei Bedarf den Clip-Cache und baut danach das finale Video erneut.")
|
||||||
|
|
||||||
Button("Projekt speichern") {
|
Button("Projekt speichern") {
|
||||||
processor.saveCurrentProject()
|
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 {
|
struct ProcessResult {
|
||||||
let exitCode: Int32
|
let exitCode: Int32
|
||||||
let output: String
|
let output: String
|
||||||
@@ -90,6 +108,7 @@ actor FFmpegService {
|
|||||||
onOutput: @escaping @Sendable (String) -> Void = { _ in }
|
onOutput: @escaping @Sendable (String) -> Void = { _ in }
|
||||||
) async throws -> ProcessResult {
|
) async throws -> ProcessResult {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
let cancellationState = LockedCancellationState()
|
||||||
|
|
||||||
return try await withTaskCancellationHandler {
|
return try await withTaskCancellationHandler {
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
@@ -122,8 +141,12 @@ actor FFmpegService {
|
|||||||
Task {
|
Task {
|
||||||
await self.clearRunningProcess(process)
|
await self.clearRunningProcess(process)
|
||||||
}
|
}
|
||||||
|
if cancellationState.isCancelled() {
|
||||||
|
continuation.resume(throwing: CancellationError())
|
||||||
|
} else {
|
||||||
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
runningProcess = process
|
runningProcess = process
|
||||||
@@ -136,6 +159,7 @@ actor FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
|
cancellationState.cancel()
|
||||||
Task {
|
Task {
|
||||||
await self.cancel()
|
await self.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ enum RenderMode: String, CaseIterable, Identifiable, Codable {
|
|||||||
case videoGrowthLapse = "Video-Zeitraffer"
|
case videoGrowthLapse = "Video-Zeitraffer"
|
||||||
case photoMorphing = "Foto-Morphing"
|
case photoMorphing = "Foto-Morphing"
|
||||||
|
|
||||||
|
static var allCases: [RenderMode] {
|
||||||
|
[.videoGrowthLapse]
|
||||||
|
}
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +103,10 @@ enum StabilizationStrength: String, CaseIterable, Identifiable, Codable {
|
|||||||
|
|
||||||
var deshakeSearch: Int {
|
var deshakeSearch: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .light: return 48
|
case .light: return 16
|
||||||
case .medium: return 64
|
case .medium: return 32
|
||||||
case .strong: return 96
|
case .strong: return 48
|
||||||
case .veryStrong: return 128
|
case .veryStrong: return 64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ struct RenderSettings: Equatable {
|
|||||||
var stabilizationEnabled: Bool = true
|
var stabilizationEnabled: Bool = true
|
||||||
var stabilizationMethod: StabilizationMethod = .vidstab
|
var stabilizationMethod: StabilizationMethod = .vidstab
|
||||||
var stabilizationStrength: StabilizationStrength = .strong
|
var stabilizationStrength: StabilizationStrength = .strong
|
||||||
var stabilizationAnchorEnabled: Bool = true
|
var stabilizationAnchorEnabled: Bool = false
|
||||||
var stabilizationAnchorX: Double = 0.2
|
var stabilizationAnchorX: Double = 0.2
|
||||||
var stabilizationAnchorY: Double = 0.18
|
var stabilizationAnchorY: Double = 0.18
|
||||||
var stabilizationAnchorSize: Double = 0.22
|
var stabilizationAnchorSize: Double = 0.22
|
||||||
@@ -263,7 +267,11 @@ enum SettingsStore {
|
|||||||
let persisted = try? JSONDecoder().decode(PersistedRenderSettings.self, from: data) else {
|
let persisted = try? JSONDecoder().decode(PersistedRenderSettings.self, from: data) else {
|
||||||
return RenderSettings()
|
return RenderSettings()
|
||||||
}
|
}
|
||||||
return persisted.renderSettings
|
var settings = persisted.renderSettings
|
||||||
|
if settings.renderMode == .photoMorphing {
|
||||||
|
settings.renderMode = .videoGrowthLapse
|
||||||
|
}
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ settings: RenderSettings) {
|
static func save(_ settings: RenderSettings) {
|
||||||
@@ -329,6 +337,7 @@ struct GrowthLapseProject: Codable, Equatable {
|
|||||||
var targetWidth: Int
|
var targetWidth: Int
|
||||||
var targetHeight: Int
|
var targetHeight: Int
|
||||||
var clips: [GrowthLapseProjectClip]
|
var clips: [GrowthLapseProjectClip]
|
||||||
|
var renderFingerprint: String?
|
||||||
|
|
||||||
var outputFileURL: URL {
|
var outputFileURL: URL {
|
||||||
URL(fileURLWithPath: outputFilePath)
|
URL(fileURLWithPath: outputFilePath)
|
||||||
@@ -394,12 +403,22 @@ struct VideoDimensions: Equatable {
|
|||||||
let height: Int
|
let height: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FaceCrop: Equatable {
|
struct FaceAlignmentSample: Equatable {
|
||||||
let x: Int
|
let time: Double
|
||||||
let y: Int
|
let x: Double
|
||||||
|
let y: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FaceAlignment: Equatable {
|
||||||
|
let sourceWidth: Int
|
||||||
|
let sourceHeight: Int
|
||||||
let width: Int
|
let width: Int
|
||||||
let height: Int
|
let height: Int
|
||||||
|
let rotationRadians: Double
|
||||||
|
let contentScale: Double
|
||||||
|
let samples: [FaceAlignmentSample]
|
||||||
let faceHeightRatio: Double
|
let faceHeightRatio: Double
|
||||||
|
let usesEyeLandmarks: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RenderState: Equatable {
|
enum RenderState: Equatable {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user