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
+8 -21
View File
@@ -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()
+25 -1
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 {
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()
}
+28 -9
View File
@@ -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