diff --git a/README.md b/README.md
index b52ecf2..b5c2bb4 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,13 @@ Das Skript baut im Release-Modus für die Architektur des ausführenden Macs und
2. Sortierung und gegebenenfalls Variantenwahl einstellen. Die Wochen-Sortierung erkennt beispielsweise `12 Wochen alt V1.mov`. Dateien ohne erkennbares Wochenalter stehen dahinter. Varianten werden anhand eines abschließenden Leerzeichens mit Zahl oder `V` und Zahl gruppiert, etwa `12 Wochen alt V1.mov` und `12 Wochen alt V2.mov`.
3. Ausschnittlänge, Ziellänge und Übergang festlegen. Standard: 8 Sekunden Quelle, 1,5 Sekunden pro Ergebnisclip, 0,6 Sekunden Überblendung und 30 FPS. Die Übergangslänge muss kleiner als die Ziellänge sein; 0 bedeutet einen harten Schnitt.
4. Ausgabeformat, Encoder und optionale Stabilisierung/Gesichtsausrichtung wählen. Unterstützt sind 1920×1080, 1080×1920 oder die Abmessungen des ersten Videos. Für H.264 stehen CPU (`libx264`) und VideoToolbox zur Auswahl. Audio wird standardmäßig entfernt.
-5. „Render starten“ wählen. Fortschritt und Log erscheinen in der App; „STOP“ bricht die Verarbeitung ab.
-6. Mit „Ergebnis ansehen“ das fertige Video direkt in der App prüfen. In der Clip-Nachbearbeitung Segmentstarts oder Varianten ändern und „Änderungen neu rendern“ wählen. Diese Aktion erhält gewählte Ausschnitte. „Segmente neu planen“ berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu; manuelle Ausschnitte werden erst nach erfolgreichem Rendern ersetzt. Die globale Variantenwahl gilt für einen neuen Import; vorhandene Varianten werden pro Clip gewechselt. „Projekt laden“ stellt die gespeicherten Render-Einstellungen wieder her.
+5. Optional zuerst „Clips analysieren“ wählen. Die Clip-Liste zeigt Hinweise und gegebenenfalls alternative Segmentstarts. „Entwurf erstellen“ erzeugt einen separaten Vorschaufilm mit bis zu 640 Pixeln und höchstens 15 FPS. Über die Übergangs-Schaltflächen lassen sich Schnittstellen gezielt prüfen; „Clip prüfen“ öffnet den betreffenden Clip.
+6. „Projekt rendern“ wählen, um die geprüften Ausschnitte in voller Ausgabequalität zu rendern. Ohne vorherige Analyse heißt die Schaltfläche „Render starten“ und importiert den Ordner direkt. Fortschritt und Log erscheinen in der App; „STOP“ bricht die Verarbeitung ab.
+7. Mit „Ergebnis ansehen“ das fertige Video direkt in der App prüfen. In der Clip-Nachbearbeitung Segmentstarts oder Varianten ändern und „Änderungen neu rendern“ wählen. Diese Aktion erhält gewählte Ausschnitte. „Segmente neu planen“ berechnet alle Ausschnitte mit aktueller Länge, Mitte/Offset und automatischer Segmentwahl neu; manuelle Ausschnitte werden erst nach erfolgreichem Rendern ersetzt. Die globale Variantenwahl gilt für einen neuen Import; vorhandene Varianten werden pro Clip gewechselt. „Projekt laden“ stellt die gespeicherten Render-Einstellungen wieder her.
-Die automatische Segmentwahl sucht nach geeigneten Gesicht-/Augenaufnahmen. Gesichtsausrichtung und Stabilisierung sind heuristisch und sollten anhand des Ergebnisses geprüft werden.
+Apple Vision erkennt Gesicht, Augen und Kopfhaltung. Die gemeinsame Übergangsplanung sucht passende Ausschnitte über die gesamte Folge. Die Gesichtsausrichtung berücksichtigt unterschiedliche Aufnahmeabstände; der vorsichtige Farbabgleich misst nur innerhalb des Gesichts. Wechselnde Hintergründe dienen nicht als Farbreferenz. Gesichtsausrichtung und Stabilisierung sind heuristisch und sollten anhand des Ergebnisses geprüft werden. Details und Grenzen: [Visuelle Kontinuität in Version 0.4.0](docs/VISUELLE_KONTINUITAET.md).
+
+Für einen neuen Import nach einem bestehenden Projekt im Menü „Projekt“ die Aktion „Neuen Import vorbereiten“ wählen oder einen anderen Input-Ordner auswählen. Gespeicherte Projekte bleiben dabei erhalten.
## Dateien und Einstellungen
@@ -74,7 +77,7 @@ Für Integrationstests genügt die Command-Line-Tools-Umgebung mit FFmpeg und FF
bash scripts/integration-tests.sh
```
-Dieser Test baut einen eigenen Test-Einstieg mit den produktiven Render-Klassen, erzeugt synthetische Videos und prüft unter anderem Speichern/Laden, Cache, Tonspuren, Überblendungen, Endausgabe-Fehler, Wiederherstellung und Abbruch. Er verändert keine persönlichen Einstellungen. Testdateien werden automatisch entfernt; mit `GROWTHLAPSE_KEEP_TEST_OUTPUT=1` bleiben sie zur Kontrolle erhalten.
+Dieser Test baut einen eigenen Test-Einstieg mit den produktiven Render-Klassen, erzeugt synthetische Videos und prüft unter anderem Speichern/Laden, Cache, Tonspuren, Überblendungen, Endausgabe-Fehler, Wiederherstellung und Abbruch. Zusätzlich prüft er gemeinsame Übergangsplanung, wechselnde Kameraabstände, hintergrundunabhängige Farbmessung sowie Entwurfsrendering ohne Veränderung der finalen Ausgabe. Er verändert keine persönlichen Einstellungen. Testdateien werden automatisch entfernt; mit `GROWTHLAPSE_KEEP_TEST_OUTPUT=1` bleiben sie zur Kontrolle erhalten.
Für einen manuellen Funktionstest zwei kurze Videos in einen separaten Testordner legen und ein neues Ausgabeziel wählen. Zunächst ohne Stabilisierung und ohne Übergang rendern, danach mit Überblendung und den gewünschten Filtern. Ergebnis abspielen, einen Segmentstart ändern, erneut rendern, Projekt laden und Abbruch während eines weiteren Renders prüfen.
@@ -85,6 +88,7 @@ Für einen manuellen Funktionstest zwei kurze Videos in einen separaten Testordn
| `RenderSettings.swift` | Datenmodelle und persistierte Einstellungen |
| `FFmpegService.swift` | Werkzeugsuche, Encoder-Prüfung und abbrechbare Prozessausführung |
| `RenderWorkspace.swift` | Isolierte Arbeitsverzeichnisse, sichere Cache-Löschung und Übernahme der Ausgabe |
+| `VisualContinuity.swift` | Gesichtsmessung, gemeinsame Ausschnittplanung, Farbkorrektur und Prüfhinweise |
| `VideoProcessor.swift` | Videoanalyse, Render-Pipeline, Cache und Gesichtserkennung |
Alle genannten Swift-Dateien liegen unter `Sources/GrowthLapse/`.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index c77c77b..2f8b328 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.3.0
+ 0.4.0
CFBundleVersion
- 4
+ 5
LSMinimumSystemVersion
13.0
NSHighResolutionCapable
diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift
index 42ffb9c..879fdfc 100644
--- a/Sources/GrowthLapse/ContentView.swift
+++ b/Sources/GrowthLapse/ContentView.swift
@@ -9,6 +9,8 @@ struct ContentView: View {
@State private var variantPickerClip: GrowthLapseProjectClip?
@State private var reviewPanelHeight: CGFloat = 460
@State private var showingResult = false
+ @State private var showingDraft = false
+ @State private var onlyProblems = false
var body: some View {
HSplitView {
@@ -36,6 +38,14 @@ struct ContentView: View {
selectProjectFile()
}
.help("Öffnet ein gespeichertes Projekt mit Ausschnitten, Varianten und Render-Einstellungen. Deine lokalen FFmpeg-Pfade bleiben erhalten.")
+ HStack {
+ Button("Clips analysieren") { processor.analyze(settings: settings) }
+ .disabled(processor.state.isRunning)
+ .help("Prüft Gesichter, Bewegung und Übergänge vor dem Rendern. Erstellt Hinweise und Vorschläge, ohne das Ergebnisvideo zu überschreiben. Vorhandene manuelle Ausschnitte bleiben erhalten.")
+ Button("Entwurf erstellen") { processor.startDraft(settings: settings) }
+ .disabled(processor.state.isRunning || processor.project == nil)
+ .help("Erstellt nach der Analyse einen separaten Film mit bis zu 640 Pixeln und 15 FPS. Enthält Ausschnitte, Übergänge und Bildkorrekturen; das finale Video bleibt unverändert.")
+ }
}
.disabled(processor.state.isRunning)
@@ -188,10 +198,27 @@ struct ContentView: View {
}
.disabled(processor.state.isRunning)
+ Section("Übergänge und Bild") {
+ Toggle("Passende Übergänge planen", isOn: $settings.transitionPlanningEnabled)
+ .help("Bewertet Ausschnitte gemeinsam nach Kopfhaltung und erkannten Augen. Wird beim neuen Import und mit ‚Segmente neu planen‘ angewandt; manuelle Ausschnitte bleiben sonst erhalten.")
+ Toggle("Gesichtsfarben angleichen", isOn: $settings.colorMatchingEnabled)
+ .help("Gleicht Helligkeit vorsichtig im Gesicht an. Weißabgleich nur bei genügend neutralen Gesichtspixeln. Wechselnde Hintergründe werden nicht als Farbreferenz benutzt; bei mehreren Gesichtern oder HDR bleibt die Korrektur aus.")
+ percentSlider("Korrekturstärke", value: $settings.colorMatchingStrength, range: 0...1,
+ help: "Begrenzt die Farbangleichung. 50 % ist zurückhaltend. 0 % lässt Farben unverändert.")
+ .disabled(!settings.colorMatchingEnabled)
+ }
+ .disabled(processor.state.isRunning)
+
Section("Gesicht") {
Toggle("Bestes Segment automatisch finden", isOn: $settings.bestSegmentDetectionEnabled)
.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("Gemeinsame Gesichtsausrichtung", isOn: $settings.continuityAlignmentEnabled)
+ .help("Ermittelt für den gesamten Film eine gemeinsame Gesichtsgröße und Augenlinie. Unterschiedliche Kameraabstände werden durch den Ausschnitt ausgeglichen; fehlende Bildränder oder sehr kleine Gesichter werden markiert.")
+ .disabled(!settings.faceNormalizationEnabled)
+ percentSlider("Augenlinie von oben", value: $settings.targetEyeY, range: 0.25...0.6,
+ help: "Gemeinsame vertikale Position der Augen im Ergebnis. 40 % liegt etwas über der Bildmitte. Gilt nur, soweit genug Bildrand vorhanden ist.")
+ .disabled(!settings.faceNormalizationEnabled)
Toggle("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled)
.help("Verfolgt Gesicht und Augen mit Apple Vision und bewegt den Bildausschnitt geglättet mit. Das Bild bleibt immer vollflächig; fehlt Rand für die Zielgröße oder Drehung, hat ein randloses Bild Vorrang.")
@@ -211,10 +238,11 @@ struct ContentView: View {
Section {
HStack {
- Button("Render starten") {
- processor.start(settings: settings)
+ Button(processor.project == nil ? "Render starten" : "Projekt rendern") {
+ if processor.project == nil { processor.start(settings: settings) }
+ else { processor.rerenderProject(settings: settings) }
}
- .help("Startet die komplette Pipeline: Segment, Stabilisierung, Face-Crop, Normalisierung und Übergänge.")
+ .help("Rendert das analysierte Projekt mit seinen gewählten Ausschnitten. Ohne Projekt wird der Input-Ordner neu eingelesen.")
.keyboardShortcut(.return, modifiers: [.command])
.disabled(processor.state.isRunning)
@@ -232,6 +260,8 @@ struct ContentView: View {
.disabled(processor.state.isRunning)
}
+
+
if case .finished(let url) = processor.state {
Button("Im Finder anzeigen") {
processor.showInFinder(url: url)
@@ -293,7 +323,24 @@ struct ContentView: View {
}
.sheet(isPresented: $showingResult) {
if let project = processor.project {
- OutputPreviewView(url: project.outputFileURL)
+ OutputPreviewView(url: project.outputFileURL, project: project) { clipID in
+ selectedClipID = clipID
+ onlyProblems = false
+ showingResult = false
+ }
+ }
+ }
+ .onDisappear { processor.discardDraft() }
+ .onChange(of: processor.draftOutput) { url in
+ if url != nil { showingDraft = true }
+ }
+ .sheet(isPresented: $showingDraft) {
+ if let url = processor.draftOutput {
+ OutputPreviewView(url: url, project: processor.draftProject, draft: true) { clipID in
+ selectedClipID = clipID
+ onlyProblems = false
+ showingDraft = false
+ }
}
}
.sheet(item: $variantPickerClip) { clip in
@@ -357,7 +404,8 @@ struct ContentView: View {
}
private func projectReviewPanel(_ project: GrowthLapseProject) -> some View {
- let orderedClips = orderedProjectClips(project.clips)
+ let allClips = orderedProjectClips(project.clips)
+ let orderedClips = onlyProblems ? allClips.filter { !($0.review?.messages.isEmpty ?? true) } : allClips
let selectedClip = orderedClips.first { $0.id == selectedClipID } ?? orderedClips.first
return VStack(alignment: .leading, spacing: 10) {
@@ -386,6 +434,8 @@ struct ContentView: View {
Spacer(minLength: 0)
Menu("Projekt") {
+ Button("Neuen Import vorbereiten") { processor.resetProject() }
+ .help("Leert nur die aktuelle Nachbearbeitung. Gespeicherte Projekte, Videos und Quellen bleiben erhalten. Danach liest ‚Clips analysieren‘ den Input-Ordner neu ein.")
Button("Projekt speichern") { processor.saveCurrentProject() }
.help("Speichert Ausschnitte und Varianten. Render-Einstellungen werden beim erfolgreichen Rendern ins Projekt übernommen.")
Button("Render-Cache löschen") { processor.confirmProjectAndDeleteCache() }
@@ -395,6 +445,13 @@ struct ContentView: View {
.help("Projekt speichern oder den zugehörigen Cache löschen. Video und Quelldateien bleiben erhalten.")
}
+ HStack {
+ Text(allClips.contains(where: { $0.review == nil }) ? "Bildanalyse noch nicht vollständig" : "\(allClips.filter { !($0.review?.messages.isEmpty ?? true) }.count) Clips mit Hinweisen")
+ .font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ Toggle("Nur Hinweise", isOn: $onlyProblems).toggleStyle(.checkbox)
+ .help("Zeigt nur Clips mit unsicherer Erkennung, Bildrandproblemen oder auffälligen Übergängen.")
+ }
HStack(alignment: .top, spacing: 12) {
List(orderedClips, selection: $selectedClipID) { clip in
VStack(alignment: .leading, spacing: 2) {
@@ -414,6 +471,11 @@ struct ContentView: View {
}
Text(clip.displayName)
.lineLimit(1)
+ if let review = clip.review, !review.messages.isEmpty {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .help(review.messages.joined(separator: "\n"))
+ }
if clip.needsRender {
Text("geändert")
.font(.caption)
@@ -479,6 +541,19 @@ struct ContentView: View {
.font(.subheadline.weight(.semibold))
.lineLimit(2)
+ if let review = liveClip.review {
+ ForEach(Array(review.messages.enumerated()), id: \.offset) { _, message in
+ Label(message, systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(.orange)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ if let suggestion = review.suggestedStart {
+ Button("Vorschlag ab \(formattedNumber(suggestion)) s übernehmen") {
+ processor.updateClip(liveClip, segmentStart: suggestion)
+ }
+ .help("Übernimmt den gemeinsam mit den Nachbarclips ermittelten Ausschnitt. Danach Entwurf oder Ergebnis neu rendern.")
+ }
+ }
HStack {
Text("Segmentstart")
Spacer()
@@ -638,6 +713,7 @@ struct ContentView: View {
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK {
+ if settings.inputFolder != panel.url { processor.resetProject() }
settings.inputFolder = panel.url
}
}
@@ -984,24 +1060,53 @@ private struct VariantPreviewView: View {
private struct OutputPreviewView: View {
let url: URL
+ let project: GrowthLapseProject?
+ let draft: Bool
+ let onSelectClip: (UUID) -> Void
@Environment(\.dismiss) private var dismiss
@State private var player: AVPlayer
- init(url: URL) {
+ init(url: URL, project: GrowthLapseProject? = nil, draft: Bool = false,
+ onSelectClip: @escaping (UUID) -> Void = { _ in }) {
self.url = url
+ self.project = project
+ self.draft = draft
+ self.onSelectClip = onSelectClip
_player = State(initialValue: AVPlayer(url: url))
}
var body: some View {
VStack(spacing: 12) {
HStack {
- Text(url.lastPathComponent).font(.headline)
+ Text(draft ? "Entwurf · bis 640 Pixel / 15 FPS" : url.lastPathComponent).font(.headline)
Spacer()
Button("Schließen") { dismiss() }
.help("Schließt die Ergebnisvorschau und stoppt ihre Wiedergabe. Auch mit Escape möglich.")
.keyboardShortcut(.cancelAction)
}
VideoPlayer(player: player)
+ if let project, let saved = project.settings {
+ ScrollView(.horizontal) {
+ HStack {
+ ForEach(Array(project.clips.enumerated()), id: \.element.id) { index, clip in
+ VStack(spacing: 4) {
+ Button(index == 0 ? "Clip 1" : "Übergang \(index) → \(index + 1)") {
+ let start = max(0, Double(index) * (saved.targetClipLength - saved.transitionLength) - 0.25)
+ player.seek(to: CMTime(seconds: start, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero)
+ player.play()
+ }
+ .help("Springt kurz vor diesen Clip beziehungsweise seine Überblendung.")
+ if !(clip.review?.messages.isEmpty ?? true) {
+ Button("⚠ Clip prüfen") { onSelectClip(clip.id); dismiss() }
+ .help(clip.review!.messages.joined(separator: "\n"))
+ }
+ }
+ }
+ }
+ }
+ Text("Vorschau des letzten Renders; nach Änderungen bitte neu erstellen.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
}
.padding()
.frame(minWidth: 640, idealWidth: 800, minHeight: 440, idealHeight: 540)
diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift
index 56dc31d..f481cbe 100644
--- a/Sources/GrowthLapse/RenderSettings.swift
+++ b/Sources/GrowthLapse/RenderSettings.swift
@@ -154,6 +154,11 @@ struct RenderSettings: Equatable {
var bestSegmentDetectionEnabled: Bool = true
var faceNormalizationEnabled: Bool = true
var targetFaceHeightRatio: Double = 0.26
+ var continuityAlignmentEnabled: Bool = true
+ var transitionPlanningEnabled: Bool = true
+ var colorMatchingEnabled: Bool = true
+ var colorMatchingStrength: Double = 0.5
+ var targetEyeY: Double = 0.4
}
struct PersistedRenderSettings: Codable, Equatable {
@@ -188,6 +193,11 @@ struct PersistedRenderSettings: Codable, Equatable {
var bestSegmentDetectionEnabled: Bool?
var faceNormalizationEnabled: Bool
var targetFaceHeightRatio: Double
+ var continuityAlignmentEnabled: Bool?
+ var transitionPlanningEnabled: Bool?
+ var colorMatchingEnabled: Bool?
+ var colorMatchingStrength: Double?
+ var targetEyeY: Double?
init(settings: RenderSettings) {
inputFolderPath = settings.inputFolder?.path
@@ -221,6 +231,11 @@ struct PersistedRenderSettings: Codable, Equatable {
bestSegmentDetectionEnabled = settings.bestSegmentDetectionEnabled
faceNormalizationEnabled = settings.faceNormalizationEnabled
targetFaceHeightRatio = settings.targetFaceHeightRatio
+ continuityAlignmentEnabled = settings.continuityAlignmentEnabled
+ transitionPlanningEnabled = settings.transitionPlanningEnabled
+ colorMatchingEnabled = settings.colorMatchingEnabled
+ colorMatchingStrength = settings.colorMatchingStrength
+ targetEyeY = settings.targetEyeY
}
var renderSettings: RenderSettings {
@@ -255,7 +270,12 @@ struct PersistedRenderSettings: Codable, Equatable {
stabilizationAnchorSize: stabilizationAnchorSize,
bestSegmentDetectionEnabled: bestSegmentDetectionEnabled ?? false,
faceNormalizationEnabled: faceNormalizationEnabled,
- targetFaceHeightRatio: targetFaceHeightRatio
+ targetFaceHeightRatio: targetFaceHeightRatio,
+ continuityAlignmentEnabled: continuityAlignmentEnabled ?? false,
+ transitionPlanningEnabled: transitionPlanningEnabled ?? false,
+ colorMatchingEnabled: colorMatchingEnabled ?? false,
+ colorMatchingStrength: colorMatchingStrength ?? 0.5,
+ targetEyeY: targetEyeY ?? 0.4
)
}
}
@@ -268,6 +288,10 @@ enum SettingsStore {
return RenderSettings()
}
var settings = persisted.renderSettings
+ // New global options are offered by default; old project snapshots keep their historical appearance.
+ if persisted.continuityAlignmentEnabled == nil { settings.continuityAlignmentEnabled = true }
+ if persisted.transitionPlanningEnabled == nil { settings.transitionPlanningEnabled = true }
+ if persisted.colorMatchingEnabled == nil { settings.colorMatchingEnabled = true }
if settings.renderMode == .photoMorphing {
settings.renderMode = .videoGrowthLapse
}
@@ -342,8 +366,9 @@ struct GrowthLapseProject: Codable, Equatable {
var targetHeight: Int
var clips: [GrowthLapseProjectClip]
var renderFingerprint: String?
- var schemaVersion: Int? = 2
+ var schemaVersion: Int? = 3
var settings: PersistedRenderSettings? = nil
+ var continuityProfile: ContinuityProfile? = nil
var outputFileURL: URL {
URL(fileURLWithPath: outputFilePath)
@@ -375,6 +400,8 @@ struct GrowthLapseProjectClip: Identifiable, Codable, Equatable {
var segmentLength: Double
var normalizedClipPath: String
var sourceSignature: String? = nil
+ var visualAnalysis: ClipVisualAnalysis? = nil
+ var review: ClipReview? = nil
var needsRender: Bool = false
var variants: [GrowthLapseClipVariant]?
@@ -426,6 +453,7 @@ struct FaceAlignment: Equatable {
let samples: [FaceAlignmentSample]
let faceHeightRatio: Double
let usesEyeLandmarks: Bool
+ var boundaryLimited: Bool = false
}
enum RenderState: Equatable {
diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift
index 38b8c5f..36167d9 100644
--- a/Sources/GrowthLapse/VideoProcessor.swift
+++ b/Sources/GrowthLapse/VideoProcessor.swift
@@ -17,12 +17,134 @@ final class VideoProcessor: ObservableObject {
@Published var state: RenderState = .idle
@Published var warningText: String?
@Published var project: GrowthLapseProject?
+ @Published var draftOutput: URL?
+ @Published var draftProject: GrowthLapseProject?
+ @Published var isDraftRendering = false
+ private var draftDirectory: URL?
+
private let service = FFmpegService()
private var renderTask: Task?
private var temporaryDirectory: URL?
private var cancellationRequested = false
+ func resetProject() {
+ guard !state.isRunning else { return }
+ discardDraft()
+ project = nil
+ state = .idle
+ progress = 0
+ progressText = "Bereit für neuen Import"
+ warningText = nil
+ }
+
+ func discardDraft() {
+ guard !isDraftRendering else { return }
+ if let directory = draftDirectory { try? FileManager.default.removeItem(at: directory) }
+ draftDirectory = nil
+ draftOutput = nil
+ draftProject = nil
+ }
+
+ /// Analyze without producing or replacing a movie; expose suggestions before expensive rendering.
+ func analyze(settings: RenderSettings) {
+ guard !state.isRunning else { return }
+ state = .running
+ progress = 0
+ logs.removeAll()
+ warningText = nil
+ renderTask = Task {
+ do {
+ try validate(settings)
+ let tools = try await service.validateTools(ffmpegOverride: settings.ffmpegExecutable,
+ ffprobeOverride: settings.ffprobeExecutable)
+ var planned: GrowthLapseProject
+ if let existing = project {
+ try validateProject(existing)
+ planned = reorderedProject(existing, sortMode: settings.sortMode)
+ for index in planned.clips.indices {
+ let url = planned.clips[index].sourceURL
+ let duration = try await probeDuration(url: url, ffprobe: tools.ffprobe)
+ let dimensions = try await probeDimensions(url: url, ffprobe: tools.ffprobe)
+ planned.clips[index].duration = duration
+ planned.clips[index].width = dimensions.width
+ planned.clips[index].height = dimensions.height
+ planned.clips[index].segmentLength = min(planned.clips[index].segmentLength, duration)
+ planned.clips[index].segmentStart = min(planned.clips[index].segmentStart,
+ max(0, duration - planned.clips[index].segmentLength))
+ }
+ } else {
+ let videos = try await discoverVideos(in: settings.inputFolder!, sortMode: settings.sortMode,
+ duplicateClipSelection: settings.duplicateClipSelection, ffprobe: tools.ffprobe, excluding: settings.outputFile)
+ let dimensions = outputDimensions(for: settings, firstVideo: videos[0])
+ planned = try await makeProject(outputFile: settings.outputFile!,
+ cacheDirectory: GrowthLapseProject.cacheURL(for: settings.outputFile!), targetDimensions: dimensions,
+ videos: videos, settings: settings)
+ planned.settings = PersistedRenderSettings(settings: settings)
+ }
+ let dimensions = outputDimensions(for: settings,
+ firstVideo: VideoFile(url: planned.clips[0].sourceURL, duration: planned.clips[0].duration,
+ width: planned.clips[0].width, height: planned.clips[0].height))
+ try await prepareContinuity(project: &planned, settings: settings, dimensions: dimensions, applyPlan: project == nil, ffprobe: tools.ffprobe)
+ try Task.checkCancellation()
+ project = planned
+ state = .idle
+ progress = 1
+ progressText = "Analyse fertig – Hinweise und Ausschnitte prüfen"
+ } catch is CancellationError {
+ handleCancellation()
+ } catch {
+ state = .failed(error.localizedDescription)
+ progressText = "Analyse fehlgeschlagen"
+ }
+ renderTask = nil
+ }
+ }
+
+ /// Drafts have their own destination and never replace the user's project or final output.
+ func startDraft(settings: RenderSettings) {
+ guard !state.isRunning, let sourceProject = project else { return }
+ let previousState = state
+ state = .running
+ isDraftRendering = true
+ progress = 0
+ logs.removeAll()
+ warningText = nil
+ renderTask = Task {
+ var directory: URL?
+ do {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("GrowthLapse-draft-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
+ directory = root
+ var draftSettings = settings
+ draftSettings.outputFile = root.appendingPathComponent("Entwurf.mp4")
+ draftSettings.fps = min(15, settings.fps)
+ draftSettings.videoEncoder = .x264
+ draftSettings.crf = 28
+ draftSettings.keepIntermediateClips = false
+ draftSettings.keepRenderCacheForReview = false
+ let outcome = try await render(settings: draftSettings,
+ existingProject: reorderedProject(sourceProject, sortMode: settings.sortMode), draft: true)
+ if let old = draftDirectory { try? FileManager.default.removeItem(at: old) }
+ draftDirectory = root
+ draftProject = outcome.project
+ draftOutput = outcome.outputURL
+ state = previousState
+ progress = 1
+ progressText = "Entwurf fertig – bis 640 Pixel, höchstens 15 FPS"
+ directory = nil
+ } catch is CancellationError {
+ handleCancellation()
+ } catch {
+ state = .failed(error.localizedDescription)
+ progressText = "Entwurf fehlgeschlagen"
+ }
+ if let directory { try? FileManager.default.removeItem(at: directory) }
+ isDraftRendering = false
+ renderTask = nil
+ }
+ }
+
func start(settings: RenderSettings) {
guard !state.isRunning else {
return
@@ -108,6 +230,10 @@ final class VideoProcessor: ObservableObject {
let maxStart = max(0, project.clips[index].duration - project.clips[index].segmentLength)
project.clips[index].segmentStart = clamp(segmentStart, min: 0, max: maxStart)
project.clips[index].needsRender = true
+ if let analysis = project.clips[index].visualAnalysis {
+ project.clips[index].review = ContinuityPlanner.review(analysis,
+ start: project.clips[index].segmentStart, length: project.clips[index].segmentLength)
+ }
self.project = project
saveProject(project)
}
@@ -118,6 +244,8 @@ final class VideoProcessor: ObservableObject {
return
}
var updated = project.clips[index]
+ updated.visualAnalysis = nil
+ updated.review = nil
updated.sourcePath = variant.sourcePath
updated.displayName = variant.displayName
updated.duration = variant.duration
@@ -209,7 +337,7 @@ final class VideoProcessor: ObservableObject {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
- private func render(settings: RenderSettings, existingProject: GrowthLapseProject?, replanSegments: Bool = false) async throws -> RenderOutcome {
+ private func render(settings: RenderSettings, existingProject: GrowthLapseProject?, replanSegments: Bool = false, draft: Bool = false) async throws -> RenderOutcome {
var effectiveSettings = settings
try validate(effectiveSettings)
let tools = try await service.validateTools(
@@ -244,7 +372,7 @@ final class VideoProcessor: ObservableObject {
audio: !effectiveSettings.removeAudio)
let inputFolder = settings.inputFolder!
let outputFile = settings.outputFile!
- let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil
+ let useReviewCache = !draft && (settings.keepRenderCacheForReview || 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) }
@@ -294,7 +422,10 @@ final class VideoProcessor: ObservableObject {
throw VideoProcessingError.invalidSettings("Die Ausgabe darf keine Quelldatei ersetzen.")
}
appendLog("\(videos.count) Video(s) gefunden.")
- let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
+ let fullDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
+ let draftScale = min(1, 640.0 / Double(max(fullDimensions.width, fullDimensions.height)))
+ let targetDimensions = draft ? VideoDimensions(width: max(2, evenDimension(Int(Double(fullDimensions.width) * draftScale))),
+ height: max(2, evenDimension(Int(Double(fullDimensions.height) * draftScale)))) : fullDimensions
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")
var renderProject: GrowthLapseProject
@@ -314,7 +445,7 @@ final class VideoProcessor: ObservableObject {
renderProject.cacheDirectoryPath = tempDirectory.path
renderProject.targetWidth = targetDimensions.width
renderProject.targetHeight = targetDimensions.height
- renderProject.schemaVersion = 2
+ renderProject.schemaVersion = 3
renderProject.settings = PersistedRenderSettings(settings: effectiveSettings)
for index in renderProject.clips.indices {
let old = renderProject.clips[index]
@@ -336,6 +467,14 @@ final class VideoProcessor: ObservableObject {
renderProject.clips[index].needsRender = !canReuse
}
+ if effectiveSettings.continuityAlignmentEnabled || effectiveSettings.transitionPlanningEnabled || effectiveSettings.colorMatchingEnabled {
+ try await prepareContinuity(project: &renderProject, settings: effectiveSettings,
+ dimensions: targetDimensions, applyPlan: existingProject == nil || replanSegments, ffprobe: tools.ffprobe)
+ }
+ if effectiveSettings.faceNormalizationEnabled, effectiveSettings.continuityAlignmentEnabled,
+ let profile = renderProject.continuityProfile {
+ effectiveSettings.targetFaceHeightRatio = profile.faceHeightRatio
+ }
let fingerprint = renderFingerprint(settings: effectiveSettings, targetDimensions: targetDimensions)
if renderProject.renderFingerprint != fingerprint {
if renderProject.renderFingerprint != nil {
@@ -655,6 +794,100 @@ final class VideoProcessor: ObservableObject {
}
}
+ private func prepareContinuity(project: inout GrowthLapseProject, settings: RenderSettings,
+ dimensions: VideoDimensions, applyPlan: Bool, ffprobe: URL) async throws {
+ for index in project.clips.indices {
+ try Task.checkCancellation()
+ let clip = project.clips[index]
+ let signature = try sourceSignature(clip.sourceURL)
+ progressText = "Bildanalyse \(index + 1) von \(project.clips.count)"
+ if clip.visualAnalysis?.sourceSignature != signature {
+ project.clips[index].visualAnalysis = try await VisualAnalyzer.analyze(source: clip.sourceURL,
+ duration: clip.duration, signature: signature)
+ }
+ if project.clips[index].visualAnalysis?.isHDR == nil {
+ project.clips[index].visualAnalysis?.isHDR = try await isHDRVideo(url: clip.sourceURL, ffprobe: ffprobe)
+ }
+ progress = Double(index + 1) / Double(project.clips.count) * 0.15
+ }
+ let options = project.clips.map { clip -> [SegmentCandidate] in
+ let analysis = clip.visualAnalysis!
+ if !analysis.samples.contains(where: { $0.pose != nil }) {
+ return [SegmentCandidate(start: clip.segmentStart, quality: 0, entry: nil, exit: nil)]
+ }
+ return ContinuityPlanner.candidates(analysis: analysis, duration: clip.duration,
+ length: clip.segmentLength, currentStart: clip.segmentStart)
+ }
+ let optimum = ContinuityPlanner.choose(options)
+ let current = project.clips.enumerated().map { index, clip in
+ options[index].min { abs($0.start - clip.segmentStart) < abs($1.start - clip.segmentStart) }!
+ }
+ let improved = ContinuityPlanner.sequenceCost(current) - ContinuityPlanner.sequenceCost(optimum) > 0.05
+ let choices = improved ? optimum : current
+ if applyPlan, settings.transitionPlanningEnabled {
+ for index in project.clips.indices where abs(project.clips[index].segmentStart - choices[index].start) > 0.05 {
+ project.clips[index].segmentStart = choices[index].start
+ project.clips[index].needsRender = true
+ }
+ appendLog("Ausschnitte gemeinsam nach Gesichtsqualität und benachbarten Kopfhaltungen geplant.")
+ }
+ // Sparse global samples on long recordings are refined inside the actual chosen window.
+ for index in project.clips.indices {
+ let clip = project.clips[index]
+ if clip.visualAnalysis!.window(start: clip.segmentStart, length: clip.segmentLength).count < 3 {
+ let detail = try await VisualAnalyzer.analyze(source: clip.sourceURL, duration: clip.segmentLength,
+ signature: clip.visualAnalysis!.sourceSignature, rangeStart: clip.segmentStart)
+ project.clips[index].visualAnalysis!.samples.append(contentsOf: detail.samples)
+ project.clips[index].visualAnalysis!.samples.sort { $0.time < $1.time }
+ }
+ }
+ let windows = project.clips.map { $0.visualAnalysis!.window(start: $0.segmentStart, length: $0.segmentLength) }
+ let colorWindows = project.clips.indices.map { project.clips[$0].visualAnalysis?.isHDR == true ? [] : windows[$0] }
+ let reference = ContinuityPlanner.referenceColor(colorWindows)
+ project.continuityProfile = ContinuityPlanner.sharedProfile(clips: project.clips, dimensions: dimensions,
+ requested: settings.targetFaceHeightRatio, eyeY: settings.targetEyeY)
+ for index in project.clips.indices {
+ let clip = project.clips[index]
+ var review = ContinuityPlanner.review(clip.visualAnalysis!, start: clip.segmentStart, length: clip.segmentLength)
+ if settings.transitionPlanningEnabled, choices[index].quality > 0.1,
+ abs(choices[index].start - clip.segmentStart) > 0.25 {
+ review.suggestedStart = choices[index].start
+ }
+ if settings.colorMatchingEnabled {
+ review.color = ContinuityPlanner.colorAdjustment(samples: colorWindows[index], reference: reference,
+ strength: settings.colorMatchingStrength)
+ if clip.visualAnalysis?.isHDR == true {
+ review.messages.append("Farbangleichung bei HDR übersprungen; SDR-Konvertierung bleibt aktiv.")
+ } else if !windows[index].contains(where: { $0.color != nil && $0.faceCount == 1 }) {
+ review.messages.append("Keine sichere Farbmessung im Gesicht; Hintergrund wird nicht angeglichen.")
+ }
+ }
+ if index > 0 {
+ let cost = ContinuityPlanner.transitionCost(windows[index - 1].last?.pose, windows[index].first?.pose)
+ review.transitionScore = cost
+ if cost > 0.65 { review.messages.append("Übergang vom vorherigen Clip prüfen: Kopfhaltung oder Gesichtserkennung weicht ab.") }
+ }
+ let faceHeight = ContinuityPlanner.median(windows[index].compactMap { $0.pose?.height })
+ let sourceAspect = Double(clip.width) / Double(clip.height)
+ let outputAspect = Double(dimensions.width) / Double(dimensions.height)
+ let minimumRatio = faceHeight / max(0.01, min(1, sourceAspect / outputAspect) * 0.94)
+ if settings.faceNormalizationEnabled, faceHeight > 0,
+ Double(dimensions.height) * project.continuityProfile!.faceHeightRatio / max(1, faceHeight * Double(clip.height)) > 2.5 {
+ review.messages.append("Großer Aufnahmeabstand: Gesicht muss stark vergrößert werden; Schärfe prüfen.")
+ }
+ if settings.continuityAlignmentEnabled, minimumRatio > project.continuityProfile!.faceHeightRatio + 0.03 {
+ review.messages.append("Aufnahme sehr nah: gemeinsame Gesichtsgröße ist ohne fehlenden Bildrand nicht erreichbar.")
+ }
+ if clip.review?.color != review.color { project.clips[index].needsRender = true }
+ project.clips[index].review = review
+ }
+ if settings.continuityAlignmentEnabled, let profile = project.continuityProfile {
+ appendLog("Gemeinsames Ziel: Gesicht \(Int(profile.faceHeightRatio * 100)) %, Augenlinie \(Int(profile.eyeY * 100)) % von oben; Bildrand hat Vorrang.")
+ }
+ let problemCount = project.clips.filter { !($0.review?.messages.isEmpty ?? true) }.count
+ appendLog("Bildanalyse: \(problemCount) Clip(s) mit Hinweisen. Farbkorrektur misst ausschließlich das Gesicht.")
+ }
+
private func sourceSignature(_ url: URL) throws -> String {
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey])
guard values.isRegularFile == true else {
@@ -664,7 +897,7 @@ final class VideoProcessor: ObservableObject {
}
private func validateProject(_ project: GrowthLapseProject) throws {
- guard (project.schemaVersion ?? 1) <= 2, !project.clips.isEmpty,
+ guard (project.schemaVersion ?? 1) <= 3, !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 {
@@ -684,6 +917,23 @@ final class VideoProcessor: ObservableObject {
clip.normalizedClipURL.resolvingSymlinksInPath() != clip.sourceURL.resolvingSymlinksInPath() else {
throw VideoProcessingError.invalidSettings("Ungültige Clipdaten: \(clip.displayName)")
}
+ if let analysis = clip.visualAnalysis {
+ guard analysis.samples.count <= 4096, analysis.samples.allSatisfy({ sample in
+ guard sample.time.isFinite, sample.time >= 0, sample.time <= clip.duration + 0.1,
+ sample.faceCount >= 0, sample.quality.isFinite, (0...1).contains(sample.quality) else { return false }
+ if let pose = sample.pose {
+ guard [pose.x, pose.y, pose.height, pose.yaw, pose.roll].allSatisfy({ $0.isFinite }),
+ (0...1).contains(pose.x), (0...1).contains(pose.y), (0...1).contains(pose.height),
+ abs(pose.yaw) <= .pi, abs(pose.roll) <= .pi,
+ pose.pitch == nil || (pose.pitch!.isFinite && abs(pose.pitch!) <= .pi) else { return false }
+ }
+ if let color = sample.color {
+ guard color.luminance.isFinite, (0...1).contains(color.luminance),
+ [color.redToGreen, color.blueToGreen].compactMap({ $0 }).allSatisfy({ $0.isFinite && $0 > 0 && $0 < 10 }) else { return false }
+ }
+ return true
+ }) else { throw VideoProcessingError.invalidSettings("Ungültige gespeicherte Bildanalyse.") }
+ }
for variant in clip.availableVariants {
guard variant.sourcePath.hasPrefix("/"), variant.duration.isFinite, variant.duration > 0,
variant.width > 0, variant.height > 0 else {
@@ -696,9 +946,12 @@ final class VideoProcessor: ObservableObject {
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 {
+ settings.stabilizationAnchorY, settings.stabilizationAnchorSize, settings.colorMatchingStrength, settings.targetEyeY].allSatisfy({ $0.isFinite }) else {
throw VideoProcessingError.invalidSettings("Render-Parameter müssen endliche Zahlen sein.")
}
+ guard (0...1).contains(settings.colorMatchingStrength), (0.25...0.6).contains(settings.targetEyeY) else {
+ throw VideoProcessingError.invalidSettings("Farbkorrekturstärke oder Augenposition liegt außerhalb des gültigen Bereichs.")
+ }
if let output = settings.outputFile {
guard output.pathExtension.lowercased() == "mp4" else {
throw VideoProcessingError.invalidSettings("Bitte eine MP4-Ausgabedatei auswählen.")
@@ -1054,7 +1307,7 @@ final class VideoProcessor: ObservableObject {
if cachedAudioIsUsable {
appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)")
outputURLs.append(outputURL)
- progress = Double(index + 1) / Double(videos.count) * normalizationWeight
+ progress = 0.15 + Double(index + 1) / Double(videos.count) * (normalizationWeight - 0.15)
continue
}
appendLog("Cache wird neu erzeugt, weil die Audiospur fehlt: \(outputURL.lastPathComponent)")
@@ -1094,11 +1347,16 @@ final class VideoProcessor: ObservableObject {
clipNumber: clipNumber,
totalClips: videos.count
)
+ if let alignment = faceAlignment,
+ alignment.boundaryLimited || alignment.faceHeightRatio > settings.targetFaceHeightRatio + 0.03 {
+ projectClip.review?.messages.append("Gesichtsausrichtung durch Bildrand begrenzt; Position/Größe im Ergebnis prüfen.")
+ }
try await normalizePreparedSegment(
source: sourceForNormalization,
output: outputURL,
actualSegmentLength: actualSegmentLength,
speedFactor: speedFactor,
+ colorAdjustment: settings.colorMatchingEnabled && !sourceIsHDR ? projectClip.review?.color : nil,
faceAlignment: faceAlignment,
settings: settings,
targetDimensions: targetDimensions,
@@ -1124,12 +1382,17 @@ final class VideoProcessor: ObservableObject {
clipNumber: clipNumber,
totalClips: videos.count
)
+ if let alignment = faceAlignment,
+ alignment.boundaryLimited || alignment.faceHeightRatio > settings.targetFaceHeightRatio + 0.03 {
+ projectClip.review?.messages.append("Gesichtsausrichtung durch Bildrand begrenzt; Position/Größe im Ergebnis prüfen.")
+ }
try await normalizeSourceSegment(
source: video.url,
output: outputURL,
start: safeStart,
duration: actualSegmentLength,
speedFactor: speedFactor,
+ colorAdjustment: settings.colorMatchingEnabled && !sourceIsHDR ? projectClip.review?.color : nil,
faceAlignment: faceAlignment,
settings: settings,
targetDimensions: targetDimensions,
@@ -1149,7 +1412,7 @@ final class VideoProcessor: ObservableObject {
projectClip.normalizedClipPath = outputURL.path
project.clips[index] = projectClip
outputURLs.append(outputURL)
- progress = Double(index + 1) / Double(videos.count) * normalizationWeight
+ progress = 0.15 + Double(index + 1) / Double(videos.count) * (normalizationWeight - 0.15)
}
return outputURLs
@@ -1369,6 +1632,7 @@ final class VideoProcessor: ObservableObject {
output: URL,
actualSegmentLength: Double,
speedFactor: Double,
+ colorAdjustment: ColorAdjustment?,
faceAlignment: FaceAlignment?,
settings: RenderSettings,
targetDimensions: VideoDimensions,
@@ -1395,7 +1659,7 @@ final class VideoProcessor: ObservableObject {
])
}
arguments.append(contentsOf: [
- "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR),
+ "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR, colorAdjustment: colorAdjustment),
"-t", decimal(settings.targetClipLength)
])
arguments.append(contentsOf: videoEncoderArguments(settings))
@@ -1420,6 +1684,7 @@ final class VideoProcessor: ObservableObject {
start: Double,
duration: Double,
speedFactor: Double,
+ colorAdjustment: ColorAdjustment?,
faceAlignment: FaceAlignment?,
settings: RenderSettings,
targetDimensions: VideoDimensions,
@@ -1448,7 +1713,7 @@ final class VideoProcessor: ObservableObject {
])
}
arguments.append(contentsOf: [
- "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR),
+ "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR, colorAdjustment: colorAdjustment),
"-t", decimal(settings.targetClipLength)
])
arguments.append(contentsOf: videoEncoderArguments(settings))
@@ -1673,7 +1938,7 @@ final class VideoProcessor: ObservableObject {
}
let safeFallback = min(fallbackStart, max(0, video.duration - segmentLength))
- guard settings.bestSegmentDetectionEnabled, video.duration > segmentLength else {
+ guard settings.bestSegmentDetectionEnabled, !settings.transitionPlanningEnabled, video.duration > segmentLength else {
return safeFallback
}
@@ -1701,7 +1966,11 @@ final class VideoProcessor: ObservableObject {
private func renderFingerprint(settings: RenderSettings, targetDimensions: VideoDimensions) -> String {
[
- "portrait-alignment-v4-full-frame",
+ "continuity-v1",
+ "\(settings.continuityAlignmentEnabled)",
+ "\(settings.colorMatchingEnabled)",
+ decimal(settings.colorMatchingStrength),
+ decimal(settings.targetEyeY),
"\(targetDimensions.width)x\(targetDimensions.height)",
decimal(settings.targetClipLength),
"\(settings.fps)",
@@ -1825,7 +2094,8 @@ final class VideoProcessor: ObservableObject {
faceAlignment: FaceAlignment?,
clipIndex: Int,
burnInClipNumber: Bool,
- sourceIsHDR: Bool
+ sourceIsHDR: Bool,
+ colorAdjustment: ColorAdjustment? = nil
) -> String {
var filters: [String] = []
if let alignment = faceAlignment {
@@ -1848,6 +2118,7 @@ final class VideoProcessor: ObservableObject {
filters.append("pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2")
filters.append("setsar=1")
filters.append(contentsOf: sdrFilters(sourceIsHDR: sourceIsHDR))
+ if let colorAdjustment { filters.append(colorAdjustment.filter) }
if burnInClipNumber {
filters.append(clipNumberOverlayFilter(clipIndex))
}
@@ -1903,14 +2174,23 @@ final class VideoProcessor: ObservableObject {
progressText = "Gesichtsanalyse \(clipNumber) von \(totalClips)"
appendLog("Vision Gesichtsanalyse: \(source.lastPathComponent)")
- let alignment = try await FaceAlignmentAnalyzer.detect(
+ let alignment: FaceAlignment?
+ do {
+ alignment = try await FaceAlignmentAnalyzer.detect(
source: source,
start: start,
duration: duration,
targetDimensions: targetDimensions,
- targetFaceHeightRatio: settings.targetFaceHeightRatio
+ targetFaceHeightRatio: settings.targetFaceHeightRatio,
+ targetEyeY: settings.targetEyeY
)
+ } catch is CancellationError { throw CancellationError() }
+ catch {
+ appendLog("Gesichtsanalyse nicht möglich: \(error.localizedDescription). Render ohne Gesichtsausrichtung.")
+ return nil
+ }
+
if let alignment {
let eyeMode = alignment.usesEyeLandmarks ? "Augenlinie" : "Gesichtsmitte"
let limitedByFrame = alignment.faceHeightRatio > settings.targetFaceHeightRatio + 0.01
@@ -1993,6 +2273,13 @@ final class VideoProcessor: ObservableObject {
"Ausgewählte Clip-Reihenfolge:",
selectedClipLines.joined(separator: "\n"),
"",
+ "Bildanalyse und Korrekturen:",
+ project.clips.map { clip in
+ let notes = clip.review?.messages.joined(separator: "; ") ?? "Nicht analysiert"
+ let correction = clip.review?.color.filter ?? "keine"
+ return "\(clip.index). \(clip.displayName): \(notes) | \(correction)"
+ }.joined(separator: "\n"),
+ "",
"Log:",
logs.joined(separator: "\n")
].joined(separator: "\n")
@@ -2728,7 +3015,8 @@ private enum FaceAlignmentAnalyzer {
start: Double,
duration: Double,
targetDimensions: VideoDimensions,
- targetFaceHeightRatio: Double
+ targetFaceHeightRatio: Double,
+ targetEyeY: Double = 0.4
) async throws -> FaceAlignment? {
try await cancellableAnalysis {
try Task.checkCancellation()
@@ -2782,7 +3070,8 @@ private enum FaceAlignmentAnalyzer {
return alignmentFromSamples(
samples,
targetDimensions: targetDimensions,
- targetFaceHeightRatio: targetFaceHeightRatio
+ targetFaceHeightRatio: targetFaceHeightRatio,
+ targetEyeY: targetEyeY
)
}
}
@@ -2866,7 +3155,8 @@ private enum FaceAlignmentAnalyzer {
private static func alignmentFromSamples(
_ samples: [DetectedFaceSample],
targetDimensions: VideoDimensions,
- targetFaceHeightRatio: Double
+ targetFaceHeightRatio: Double,
+ targetEyeY: Double
) -> FaceAlignment? {
let imageWidth = samples[samples.count / 2].imageWidth
let imageHeight = samples[samples.count / 2].imageHeight
@@ -2915,6 +3205,7 @@ private enum FaceAlignmentAnalyzer {
let maxX = allowedFrame.maxX - Double(evenCropWidth)
let maxY = allowedFrame.maxY - Double(evenCropHeight)
+ var boundaryLimited = !canRotateWithoutBlack && abs(proposedRotation) > 0.001
let rawPositions = samples.map { sample -> FaceAlignmentSample in
let fallback = CGPoint(
x: sample.rect.midX * Double(imageWidth),
@@ -2922,7 +3213,11 @@ private enum FaceAlignmentAnalyzer {
)
var anchor = sample.eyeCenter ?? fallback
anchor = rotated(anchor, around: center, by: rotation)
- let desiredY = sample.eyeCenter == nil ? 0.43 : 0.40
+ let desiredY = sample.eyeCenter == nil ? targetEyeY + 0.03 : targetEyeY
+ let desiredX = anchor.x - Double(evenCropWidth) / 2
+ let desiredTop = anchor.y - Double(evenCropHeight) * desiredY
+ if abs(clamp(desiredX, min: minX, max: maxX) - desiredX) > 2
+ || abs(clamp(desiredTop, min: minY, max: maxY) - desiredTop) > 2 { boundaryLimited = true }
return FaceAlignmentSample(
time: sample.time,
x: clamp(anchor.x - Double(evenCropWidth) / 2, min: minX, max: maxX),
@@ -2946,7 +3241,8 @@ private enum FaceAlignmentAnalyzer {
contentScale: 1,
samples: positions,
faceHeightRatio: faceHeightPixels / Double(evenCropHeight),
- usesEyeLandmarks: usesEyes
+ usesEyeLandmarks: usesEyes,
+ boundaryLimited: boundaryLimited
)
}
diff --git a/Sources/GrowthLapse/VisualContinuity.swift b/Sources/GrowthLapse/VisualContinuity.swift
new file mode 100644
index 0000000..5a96d36
--- /dev/null
+++ b/Sources/GrowthLapse/VisualContinuity.swift
@@ -0,0 +1,283 @@
+import Foundation
+import AVFoundation
+import Vision
+import CoreGraphics
+
+struct FacePose: Codable, Equatable, Sendable {
+ var x: Double
+ var y: Double
+ var height: Double
+ var yaw: Double
+ var roll: Double
+ var eyesVisible: Bool
+ var pitch: Double? = nil
+}
+
+struct FaceColor: Codable, Equatable, Sendable {
+ var luminance: Double
+ var redToGreen: Double?
+ var blueToGreen: Double?
+}
+
+struct VisualSample: Codable, Equatable, Sendable {
+ var time: Double
+ var faceCount: Int
+ var pose: FacePose?
+ var color: FaceColor?
+ var quality: Double
+}
+
+struct ClipVisualAnalysis: Codable, Equatable, Sendable {
+ var sourceSignature: String
+ var samples: [VisualSample]
+ var failure: String? = nil
+ var isHDR: Bool? = nil
+
+ func window(start: Double, length: Double) -> [VisualSample] {
+ let selected = samples.filter { $0.time >= start && $0.time <= start + length }
+ if !selected.isEmpty { return selected }
+ return []
+ }
+}
+
+struct ColorAdjustment: Codable, Equatable, Sendable {
+ var brightness: Double = 0
+ var redGamma: Double = 1
+ var blueGamma: Double = 1
+
+ var filter: String {
+ String(format: "eq=brightness=%.6f:gamma_r=%.6f:gamma_b=%.6f",
+ locale: Locale(identifier: "en_US_POSIX"), brightness, redGamma, blueGamma)
+ }
+}
+
+struct ClipReview: Codable, Equatable, Sendable {
+ var messages: [String] = []
+ var suggestedStart: Double? = nil
+ var transitionScore: Double? = nil
+ var color: ColorAdjustment = ColorAdjustment()
+}
+
+struct ContinuityProfile: Codable, Equatable, Sendable {
+ var faceHeightRatio: Double
+ var eyeY: Double
+}
+
+struct SegmentCandidate: Equatable, Sendable {
+ var start: Double
+ var quality: Double
+ var entry: FacePose?
+ var exit: FacePose?
+}
+
+/// Pure planning and conservative, face-only measurements. No background matching.
+enum ContinuityPlanner {
+ static func median(_ values: [Double]) -> Double {
+ let sorted = values.filter(\.isFinite).sorted()
+ guard !sorted.isEmpty else { return 0 }
+ return sorted[sorted.count / 2]
+ }
+
+ static func transitionCost(_ left: FacePose?, _ right: FacePose?) -> Double {
+ guard let left, let right else { return 0.8 }
+ // Position and distance are corrected by the common crop; prioritize expression/pose.
+ return min(3, abs(left.yaw - right.yaw) * 2.5 + abs(left.roll - right.roll) + abs((left.pitch ?? 0) - (right.pitch ?? 0)) * 1.5
+ + (left.eyesVisible && right.eyesVisible ? 0 : 0.5))
+ }
+
+ static func candidates(analysis: ClipVisualAnalysis, duration: Double, length: Double,
+ currentStart: Double) -> [SegmentCandidate] {
+ let maxStart = max(0, duration - length)
+ let steps = min(24, max(1, Int(ceil(min(12, maxStart) / 0.5))))
+ var starts = (0...steps).map { maxStart * Double($0) / Double(steps) }
+ starts.append(min(maxStart, max(0, currentStart)))
+ return Array(Set(starts)).sorted().map { start in
+ let samples = analysis.window(start: start, length: length)
+ let quality = samples.isEmpty ? 0 : samples.map(\.quality).reduce(0, +) / Double(samples.count)
+ return SegmentCandidate(start: start, quality: quality,
+ entry: samples.first?.pose, exit: samples.last?.pose)
+ }
+ }
+
+ static func sequenceCost(_ sequence: [SegmentCandidate]) -> Double {
+ let qualityCost = sequence.map { (1 - $0.quality) * 2 }.reduce(0, +)
+ return qualityCost + zip(sequence, sequence.dropFirst()).map { transitionCost($0.exit, $1.entry) }.reduce(0, +)
+ }
+
+ /// Dynamic programming chooses windows jointly across the complete sequence.
+ static func choose(_ candidates: [[SegmentCandidate]]) -> [SegmentCandidate] {
+ guard !candidates.isEmpty, candidates.allSatisfy({ !$0.isEmpty }) else { return [] }
+ var costs = candidates[0].map { (1 - $0.quality) * 2 }
+ var parents: [[Int]] = [Array(repeating: 0, count: costs.count)]
+ for index in candidates.indices.dropFirst() {
+ var next: [Double] = []
+ var row: [Int] = []
+ for candidate in candidates[index] {
+ let options = candidates[index - 1].enumerated().map { offset, previous in
+ costs[offset] + transitionCost(previous.exit, candidate.entry) + (1 - candidate.quality) * 2
+ }
+ let best = options.indices.min { options[$0] < options[$1] }!
+ next.append(options[best])
+ row.append(best)
+ }
+ costs = next
+ parents.append(row)
+ }
+ var position = costs.indices.min { costs[$0] < costs[$1] }!
+ var result: [SegmentCandidate] = []
+ for index in candidates.indices.reversed() {
+ result.append(candidates[index][position])
+ position = parents[index][position]
+ }
+ return result.reversed()
+ }
+
+ static func review(_ analysis: ClipVisualAnalysis, start: Double, length: Double) -> ClipReview {
+ var review = ClipReview()
+ if let failure = analysis.failure { review.messages.append("Analyse unvollständig: \(failure)") }
+ let samples = analysis.window(start: start, length: length)
+ guard !samples.isEmpty else {
+ review.messages.append("Keine auswertbaren Bilder; Ausschnitt manuell prüfen.")
+ return review
+ }
+ let poses = samples.compactMap(\.pose)
+ if poses.count < max(1, samples.count / 2) { review.messages.append("Gesicht häufig nicht erkannt.") }
+ if samples.contains(where: { $0.faceCount > 1 }) { review.messages.append("Mehrere Gesichter: Personenauswahl prüfen.") }
+ if poses.contains(where: { !$0.eyesVisible }) { review.messages.append("Augen nicht zuverlässig erkannt.") }
+ if poses.contains(where: { abs($0.yaw) > 0.4 || abs($0.roll) > 0.25 || abs($0.pitch ?? 0) > 0.4 }) {
+ review.messages.append("Starke Kopfwendung oder Neigung.")
+ }
+ if zip(poses, poses.dropFirst()).contains(where: { hypot($0.x - $1.x, $0.y - $1.y) > 0.10 }) {
+ review.messages.append("Starke Bewegung oder wechselnde Gesichtserkennung.")
+ }
+ if poses.contains(where: { $0.height < 0.08 }) { review.messages.append("Gesicht sehr klein: Vergrößerung kann unscharf werden.") }
+ return review
+ }
+
+ static func colorAdjustment(samples: [VisualSample], reference: FaceColor?, strength: Double) -> ColorAdjustment {
+ let colors = samples.filter { $0.faceCount == 1 }.compactMap(\.color)
+ guard let reference, !colors.isEmpty else { return ColorAdjustment() }
+ let strength = max(0, min(1, strength))
+ let current = median(colors.map(\.luminance))
+ // Only face exposure is matched. White balance needs neutral pixels inside the face.
+ var result = ColorAdjustment(brightness: max(-0.06, min(0.06, reference.luminance - current)) * strength)
+ if let target = reference.redToGreen, !colors.compactMap(\.redToGreen).isEmpty {
+ result.redGamma = 1 + max(-0.06, min(0.06, target / max(0.01, median(colors.compactMap(\.redToGreen))) - 1)) * strength
+ }
+ if let target = reference.blueToGreen, !colors.compactMap(\.blueToGreen).isEmpty {
+ result.blueGamma = 1 + max(-0.06, min(0.06, target / max(0.01, median(colors.compactMap(\.blueToGreen))) - 1)) * strength
+ }
+ return result
+ }
+
+ static func referenceColor(_ windows: [[VisualSample]]) -> FaceColor? {
+ // Equal weight per clip, irrespective of source duration or sample count.
+ let colors = windows.compactMap { samples -> FaceColor? in
+ let colors = samples.filter { $0.faceCount == 1 }.compactMap(\.color)
+ guard !colors.isEmpty else { return nil }
+ let red = colors.compactMap(\.redToGreen)
+ let blue = colors.compactMap(\.blueToGreen)
+ return FaceColor(luminance: median(colors.map(\.luminance)),
+ redToGreen: red.isEmpty ? nil : median(red), blueToGreen: blue.isEmpty ? nil : median(blue))
+ }
+ guard colors.count > 1 else { return nil }
+ let red = colors.compactMap(\.redToGreen)
+ let blue = colors.compactMap(\.blueToGreen)
+ return FaceColor(luminance: median(colors.map(\.luminance)),
+ redToGreen: red.count < 2 ? nil : median(red), blueToGreen: blue.count < 2 ? nil : median(blue))
+ }
+
+ static func sharedProfile(clips: [GrowthLapseProjectClip], dimensions: VideoDimensions,
+ requested: Double, eyeY: Double) -> ContinuityProfile {
+ let aspect = Double(dimensions.width) / Double(dimensions.height)
+ let required = clips.compactMap { clip -> Double? in
+ let poses = clip.visualAnalysis?.window(start: clip.segmentStart, length: clip.segmentLength).compactMap(\.pose) ?? []
+ guard !poses.isEmpty else { return nil }
+ let sourceAspect = Double(clip.width) / Double(clip.height)
+ // Reserve a small border for leveling the eye line. Outliers are flagged, not imposed on all clips.
+ return median(poses.map(\.height)) / (min(1, sourceAspect / aspect) * 0.94)
+ }.sorted()
+ let feasible = required.isEmpty ? requested : required[min(required.count - 1, Int(ceil(Double(required.count - 1) * 0.8)))]
+ return ContinuityProfile(faceHeightRatio: max(requested, min(requested + 0.12, feasible)), eyeY: eyeY)
+ }
+}
+
+enum VisualAnalyzer {
+ static func analyze(source: URL, duration: Double, signature: String, rangeStart: Double = 0) async throws -> ClipVisualAnalysis {
+ try await cancellableAnalysis {
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: source))
+ generator.appliesPreferredTrackTransform = true
+ generator.maximumSize = CGSize(width: 480, height: 480)
+ generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600)
+ generator.requestedTimeToleranceAfter = CMTime(seconds: 0.15, preferredTimescale: 600)
+ let count = min(120, max(3, Int(ceil(min(60, duration) / 0.5))))
+ var samples: [VisualSample] = []
+ var failures = 0
+ for index in 0.. 1 ? 0.65 : 0
+ quality = max(0, 1 - abs(pose.yaw) - abs(pose.roll) * 0.5 - abs(pose.pitch ?? 0) * 0.5 - eyePenalty - ambiguityPenalty)
+ } else { quality = 0 }
+ let color = faces.count == 1 ? face.flatMap { measureFaceColor(image, box: $0.boundingBox) } : nil
+ samples.append(VisualSample(time: time, faceCount: faces.count, pose: pose, color: color, quality: quality))
+ } catch { failures += 1 }
+ }
+ return ClipVisualAnalysis(sourceSignature: signature, samples: samples,
+ failure: failures == 0 ? nil : "\(failures) von \(count) Stichproben konnten nicht gelesen werden.")
+ }
+ }
+
+ private static func eyeOpen(_ region: VNFaceLandmarkRegion2D?) -> Bool {
+ guard let points = region?.normalizedPoints, points.count >= 4 else { return false }
+ let width = (points.map(\.x).max() ?? 0) - (points.map(\.x).min() ?? 0)
+ let height = (points.map(\.y).max() ?? 0) - (points.map(\.y).min() ?? 0)
+ return width > 0.001 && height / width > 0.12
+ }
+
+ static func measureFaceColor(_ image: CGImage, box: CGRect) -> FaceColor? {
+ // Measure only the inset face rectangle, never the changing background.
+ let rect = CGRect(x: box.minX * Double(image.width), y: (1 - box.maxY) * Double(image.height),
+ width: box.width * Double(image.width), height: box.height * Double(image.height)).insetBy(dx: box.width * Double(image.width) * 0.12, dy: box.height * Double(image.height) * 0.12)
+ guard rect.width >= 20, rect.height >= 20, let crop = image.cropping(to: rect) else { return nil }
+ var bytes = [UInt8](repeating: 0, count: 32 * 32 * 4)
+ let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in
+ guard let context = CGContext(data: buffer.baseAddress, width: 32, height: 32, bitsPerComponent: 8,
+ bytesPerRow: 128, space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else { return false }
+ context.draw(crop, in: CGRect(x: 0, y: 0, width: 32, height: 32))
+ return true
+ }
+ guard drawn else { return nil }
+ var luminance: [Double] = [], red: [Double] = [], blue: [Double] = []
+ for offset in stride(from: 0, to: bytes.count, by: 4) {
+ let r = Double(bytes[offset]) / 255, g = Double(bytes[offset + 1]) / 255, b = Double(bytes[offset + 2]) / 255
+ let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ if y > 0.04 && y < 0.95 { luminance.append(y) }
+ if y > 0.18 && y < 0.9 && max(r, g, b) - min(r, g, b) < 0.08 {
+ red.append(r / max(0.01, g)); blue.append(b / max(0.01, g))
+ }
+ }
+ guard luminance.count >= 100 else { return nil }
+ return FaceColor(luminance: ContinuityPlanner.median(luminance),
+ redToGreen: red.count >= 24 ? ContinuityPlanner.median(red) : nil,
+ blueToGreen: blue.count >= 24 ? ContinuityPlanner.median(blue) : nil)
+ }
+}
diff --git a/Tests/Integration/ContinuityChecks.swift b/Tests/Integration/ContinuityChecks.swift
new file mode 100644
index 0000000..fc711ba
--- /dev/null
+++ b/Tests/Integration/ContinuityChecks.swift
@@ -0,0 +1,71 @@
+import Foundation
+import CoreGraphics
+
+enum ContinuityChecks {
+ static func check(_ value: @autoclosure () -> Bool, _ message: String) throws {
+ if !value() { throw NSError(domain: "ContinuityChecks", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) }
+ }
+
+ static func run() throws {
+ let frontal = FacePose(x: 0.5, y: 0.4, height: 0.2, yaw: 0, roll: 0, eyesVisible: true)
+ var turned = frontal
+ turned.yaw = 0.6
+ let choices = ContinuityPlanner.choose([
+ [SegmentCandidate(start: 0, quality: 1, entry: turned, exit: turned),
+ SegmentCandidate(start: 2, quality: 0.9, entry: frontal, exit: frontal)],
+ [SegmentCandidate(start: 1, quality: 1, entry: frontal, exit: frontal)]
+ ])
+ try check(choices.map(\.start) == [2, 1], "Joint planning did not prefer matching neighboring poses")
+ let missing = VisualSample(time: 0, faceCount: 0, pose: nil, color: nil, quality: 0)
+ let analysis = ClipVisualAnalysis(sourceSignature: "test", samples: [missing])
+ try check(!ContinuityPlanner.review(analysis, start: 0, length: 1).messages.isEmpty, "Missing face was not flagged")
+ let bounds = ContinuityPlanner.candidates(analysis: analysis, duration: 1, length: 2, currentStart: 10)
+ try check(bounds.allSatisfy { $0.start == 0 }, "Candidate escaped short-source bounds")
+ try check(analysis.window(start: 100, length: 1).isEmpty, "Sparse analysis pretends to know an unsampled window")
+
+ let color = FaceColor(luminance: 0.3, redToGreen: 1.02, blueToGreen: 0.98)
+ let sample = VisualSample(time: 0, faceCount: 1, pose: frontal, color: color, quality: 1)
+ let reference = FaceColor(luminance: 0.9, redToGreen: 2, blueToGreen: 0.1)
+ let correction = ContinuityPlanner.colorAdjustment(samples: [sample], reference: reference, strength: 1)
+ try check(abs(correction.brightness) <= 0.06 && correction.redGamma <= 1.06 && correction.blueGamma >= 0.94, "Unbounded color change")
+ try check(ContinuityPlanner.colorAdjustment(samples: [sample], reference: reference, strength: 0) == ColorAdjustment(), "Zero strength changes colors")
+ var ambiguous = sample
+ ambiguous.faceCount = 2
+ try check(ContinuityPlanner.colorAdjustment(samples: [ambiguous], reference: reference, strength: 1) == ColorAdjustment(), "Ambiguous face affected colors")
+ try check(ContinuityPlanner.colorAdjustment(samples: [missing], reference: reference, strength: 1) == ColorAdjustment(), "Background used when no face is known")
+
+ let box = CGRect(x: 0.3, y: 0.3, width: 0.4, height: 0.4)
+ let redBackground = try image(background: (1, 0, 0))
+ let blueBackground = try image(background: (0, 0, 1))
+ let redMeasurement = VisualAnalyzer.measureFaceColor(redBackground, box: box)
+ let blueMeasurement = VisualAnalyzer.measureFaceColor(blueBackground, box: box)
+ try check(redMeasurement != nil && redMeasurement == blueMeasurement, "Background color contaminated face measurements")
+
+ func clip(height: Double) -> GrowthLapseProjectClip {
+ var pose = frontal
+ pose.height = height
+ let analysis = ClipVisualAnalysis(sourceSignature: "test", samples: [VisualSample(time: 0,
+ faceCount: 1, pose: pose, color: color, quality: 1)])
+ return GrowthLapseProjectClip(index: 1, sourcePath: "/tmp/source.mp4", displayName: "Test",
+ duration: 4, width: 1920, height: 1080, ageWeeks: nil, segmentStart: 0, segmentLength: 2,
+ normalizedClipPath: "/tmp/cache/clip.mp4", visualAnalysis: analysis, variants: nil)
+ }
+ let profile = ContinuityPlanner.sharedProfile(clips: [clip(height: 0.08), clip(height: 0.35)],
+ dimensions: VideoDimensions(width: 1920, height: 1080), requested: 0.26, eyeY: 0.4)
+ try check(profile.faceHeightRatio > 0.35 && profile.faceHeightRatio <= 0.38, "Different camera distances were not negotiated")
+ let extreme = ContinuityPlanner.sharedProfile(clips: [clip(height: 0.08), clip(height: 0.9)],
+ dimensions: VideoDimensions(width: 1920, height: 1080), requested: 0.26, eyeY: 0.4)
+ try check(extreme.faceHeightRatio <= 0.38, "One extreme close-up forces excessive zoom on all clips")
+ print("PASS joint transition planning, sparse-window handling, face-only bounded colors and camera-distance profile")
+ }
+
+ private static func image(background: (CGFloat, CGFloat, CGFloat)) throws -> CGImage {
+ let context = CGContext(data: nil, width: 100, height: 100, bitsPerComponent: 8, bytesPerRow: 400,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
+ context.setFillColor(CGColor(red: background.0, green: background.1, blue: background.2, alpha: 1))
+ context.fill(CGRect(x: 0, y: 0, width: 100, height: 100))
+ context.setFillColor(CGColor(gray: 0.5, alpha: 1))
+ context.fill(CGRect(x: 30, y: 30, width: 40, height: 40))
+ return context.makeImage()!
+ }
+}
diff --git a/Tests/Integration/RenderIntegration.swift b/Tests/Integration/RenderIntegration.swift
index 376489a..f525ded 100644
--- a/Tests/Integration/RenderIntegration.swift
+++ b/Tests/Integration/RenderIntegration.swift
@@ -29,6 +29,7 @@ struct RenderIntegration {
@MainActor
static func main() async throws {
+ try ContinuityChecks.run()
let defaults = RenderSettings()
try require(defaults.inputFolder == nil && defaults.outputFile == nil
&& defaults.ffmpegExecutable == nil && defaults.ffprobeExecutable == nil, "Machine-specific defaults")
@@ -124,6 +125,40 @@ struct RenderIntegration {
try require(streams.count == 1 && streams[0]["width"] as? Int == 160, "Silent native-size render")
print("PASS two-clip render, ownership, dimensions and audio removal")
+ let draftSourceState = processor.project!
+ let finalBytesBeforeDraft = try Data(contentsOf: first.outputFileURL)
+ let finalJSONBeforeDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
+ var draftSettings = settings
+ draftSettings.outputFormat = .portrait1080x1920
+ processor.startDraft(settings: draftSettings)
+ let draftDeadline = Date().addingTimeInterval(60)
+ while processor.state.isRunning && Date() < draftDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
+ try require(processor.draftOutput != nil && !processor.state.isRunning, "Draft failed: \(processor.state)")
+ try require(processor.project == draftSourceState, "Draft modified the working project")
+ let finalBytesAfterDraft = try Data(contentsOf: first.outputFileURL)
+ let finalJSONAfterDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
+ try require(finalBytesBeforeDraft == finalBytesAfterDraft && finalJSONBeforeDraft == finalJSONAfterDraft, "Draft replaced final artifacts")
+ let draftMetadata = try await probe(processor.draftOutput!, tools: service, ffprobe: tools.ffprobe)
+ let draftStreams = draftMetadata["streams"] as! [[String: Any]]
+ let draftVideo = draftStreams.first { $0["codec_type"] as? String == "video" }!
+ try require(draftVideo["width"] as? Int == 360 && draftVideo["height"] as? Int == 640, "Draft dimensions")
+ let draftURL = processor.draftOutput!
+ processor.discardDraft()
+ try require(!manager.fileExists(atPath: draftURL.path), "Draft cleanup failed")
+ print("PASS portrait draft 360x640 preserves final output, project and cache; cleanup removes only draft")
+
+ let analysisOnly = VideoProcessor()
+ var analysisSettings = settings
+ analysisSettings.outputFile = root.appendingPathComponent("Analysis-only.mp4")
+ analysisOnly.analyze(settings: analysisSettings)
+ let analysisDeadline = Date().addingTimeInterval(60)
+ while analysisOnly.state.isRunning && Date() < analysisDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
+ try require(analysisOnly.state == .idle && analysisOnly.project?.clips.count == 2, "Analysis-only failed")
+ try require(!manager.fileExists(atPath: analysisSettings.outputFile!.path), "Analysis created a final video")
+ try require(analysisOnly.project!.clips.allSatisfy { !($0.review?.messages.isEmpty ?? true) }, "Missing-face warnings absent")
+ print("PASS pre-render analysis and missing-face diagnostics without producing final video")
+
+
let alias = root.appendingPathComponent("GrowthLapse-alias.growthlapse-cache")
try manager.createSymbolicLink(at: alias, withDestinationURL: first.cacheDirectoryURL)
try require(!RenderWorkspace.isOwned(alias, by: first.outputFileURL), "Symlink cache accepted")
diff --git a/docs/VISUELLE_KONTINUITAET.md b/docs/VISUELLE_KONTINUITAET.md
new file mode 100644
index 0000000..120befa
--- /dev/null
+++ b/docs/VISUELLE_KONTINUITAET.md
@@ -0,0 +1,41 @@
+# Visuelle Kontinuität – Version 0.4.0
+
+Ziel ist ein ruhiger Wachstumsfilm trotz wechselndem Aufnahmeabstand, Kopfhaltung und Hintergrund. Apple Vision bleibt die Grundlage für Gesichtserkennung und Augenmerkmale; FFmpeg setzt die Bildtransformationen um. Alter und Datum werden noch nicht eingeblendet.
+
+## Arbeitsablauf
+
+1. Ordner, Ausgabe und gewünschte Parameter auswählen.
+2. „Clips analysieren“ erstellt ein bearbeitbares Projekt ohne Videoexport. Bei einem neuen Import werden passende Ausschnitte gemeinsam geplant. Bei einem vorhandenen Projekt bleiben manuelle Ausschnitte erhalten.
+3. Hinweise in der Clip-Liste prüfen, bei Bedarf „Nur Hinweise“ aktivieren und vorgeschlagene Segmentstarts übernehmen. Ein Hinweis ist kein automatischer Ausschluss.
+4. „Entwurf erstellen“ rendert einen separaten Film mit maximal 640 Pixeln an der längsten Seite und höchstens 15 FPS. Die Übergangs-Schaltflächen springen kurz vor die jeweilige Überblendung. „Clip prüfen“ führt zum zugehörigen Ausschnitt zurück.
+5. „Projekt rendern“ erzeugt die finale Ausgabe mit den gewählten Ausschnitten. „Segmente neu planen“ erlaubt ausdrücklich eine neue automatische Auswahl.
+
+Die erste Bildanalyse kann bei langen Aufnahmen dauern. Gespeicherte Messungen werden bei unveränderten Quellen wiederverwendet. Der Entwurf nutzt eine eigene temporäre Ausgabe und verändert weder das finale Video noch dessen Projekt oder Cache. Er ist für Rhythmus, Ausschnitte und Übergänge gedacht; Schärfe und Bewegung müssen zusätzlich in voller Auflösung geprüft werden.
+
+## Unterschiedliche Aufnahmeabstände
+
+„Gesichtsgröße normalisieren“ aktiviert die vorhandene Vision-basierte Nachführung. „Gemeinsame Gesichtsausrichtung“ ergänzt ein Ziel für die gesamte Folge: Gesichtsgröße und Augenlinie. Die Auswertung berücksichtigt Quellformat und verfügbaren Bildrand. Eine einzelne extreme Nahaufnahme darf die Zielgröße nicht beliebig für alle anderen Clips vergrößern.
+
+Bei fehlendem Bildrand begrenzt die App Zuschnitt und Drehung und zeigt einen Hinweis. Ein sehr kleines Gesicht wird beim Vergrößern nicht detailreicher; starke Vergrößerung wird ebenfalls markiert. Die Augenlinie ist ein Ziel innerhalb dieser Grenzen. Starke Kopfwendungen lassen sich nicht durch zweidimensionale Drehung frontal machen.
+
+## Übergänge und problematische Clips
+
+Die Auswahl bewertet Gesichtsqualität und Kopfhaltung an den Grenzen benachbarter Ausschnitte gemeinsam. Sie arbeitet mit Stichproben, nicht mit jedem Einzelbild. Gewählte Ausschnitte mit zu wenigen Stichproben werden genauer analysiert. Ohne verwertbare Gesichtserkennung bleibt der bisherige Ausschnitt erhalten.
+
+Hinweise umfassen fehlende oder mehrere Gesichter, unsichere Augenmerkmale, starke Kopfwendung, Bewegung, auffällige Übergänge und Grenzen der Vergrößerung. Bei mehreren Personen wird derzeit das größte Gesicht für die Lage ausgewertet; eine sichere Identitätszuordnung ist nicht implementiert. Die Personenauswahl muss dann manuell geprüft werden.
+
+## Farbangleichung bei wechselnden Hintergründen
+
+Die Helligkeitsmessung erfolgt in einem verkleinerten Bereich innerhalb des erkannten Gesichts. Der Hintergrund dient nicht als Referenz. Weißabgleich wird nur aus ausreichend vorhandenen, wenig gesättigten Pixeln innerhalb dieses Bereichs abgeleitet. Es gibt keine feste Soll-Hautfarbe.
+
+Die resultierende Korrektur gilt für das ganze Clipbild. Bei voller Stärke ist die Helligkeitskorrektur auf ±0,06 und die Rot-/Blau-Gammakorrektur auf 0,94–1,06 begrenzt; Standardstärke ist 50 %. Ohne sichere Messung oder bei mehreren Gesichtern entfällt die Korrektur für diese Stichprobe. HDR-Clips werden weder korrigiert noch als Farbreferenz genutzt; die vorhandene SDR-Konvertierung bleibt aktiv.
+
+Damit werden kleinere Unterschiede gedämpft. Starke Mischbeleuchtung, farbige Schatten, echte Veränderungen der Hautfarbe und wechselnde Hintergründe lassen sich damit nicht vollständig vereinheitlichen. Die Korrektur kann abgeschaltet werden.
+
+## Speicherung und Prüfung
+
+Projektschema 3 speichert die neuen Einstellungen, Messungen und Hinweise. Ältere Projekte bleiben lesbar und erhalten die neuen Optionen nicht ungefragt beim Laden; diese lassen sich anschließend einschalten. Die globalen Standardwerte aktivieren die neuen Kontinuitätsoptionen.
+
+Automatisierte Integrationstests prüfen unter anderem die gemeinsame Auswahl, Grenzen bei unterschiedlichen Abständen, identische Gesichtsmessung vor verschiedenfarbigen Hintergründen, begrenzte Farbkorrekturen, fehlende Gesichter und Entwürfe ohne Veränderung bestehender Ergebnisse. Bestehende Tests für Cache, Audio, Überblendung, Abbruch und Fehlerwiederherstellung laufen weiterhin. Analyse, Entwurfsvorschau und Rücksprung zum Problemclip wurden zusätzlich in der App mit synthetischen Clips geprüft.
+
+Die ästhetische Qualität muss noch mit realen Aufnahmen aus der vorgesehenen Serie beurteilt werden. Synthetische Tests belegen die technischen Abläufe, nicht die Erkennungsqualität bei jedem Gesicht oder jeder Beleuchtung.
diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh
index f4cdcc2..3cd676f 100644
--- a/scripts/integration-tests.sh
+++ b/scripts/integration-tests.sh
@@ -7,6 +7,8 @@ swiftc -parse-as-library \
Sources/GrowthLapse/RenderWorkspace.swift \
Sources/GrowthLapse/FFmpegService.swift \
Sources/GrowthLapse/VideoProcessor.swift \
+ Sources/GrowthLapse/VisualContinuity.swift \
+ Tests/Integration/ContinuityChecks.swift \
Tests/Integration/RenderIntegration.swift \
-o .build/integration/render-tests
.build/integration/render-tests