Improve portrait alignment and rendering pipeline

This commit is contained in:
Mikei386
2026-07-24 06:27:04 +02:00
parent a59bd46cdf
commit b829ead985
4 changed files with 596 additions and 207 deletions
+6 -19
View File
@@ -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()
+24
View File
@@ -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()
} }
+28 -9
View File
@@ -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