diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 9aea0ee..373ec65 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -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() diff --git a/Sources/GrowthLapse/FFmpegService.swift b/Sources/GrowthLapse/FFmpegService.swift index f610c63..5f46bf3 100644 --- a/Sources/GrowthLapse/FFmpegService.swift +++ b/Sources/GrowthLapse/FFmpegService.swift @@ -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() } diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift index 570391e..3adc8d9 100644 --- a/Sources/GrowthLapse/RenderSettings.swift +++ b/Sources/GrowthLapse/RenderSettings.swift @@ -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 { diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index efb8911..a3b9c7c 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -21,6 +21,7 @@ final class VideoProcessor: ObservableObject { private let service = FFmpegService() private var renderTask: Task? private var temporaryDirectory: URL? + private var cancellationRequested = false func start(settings: RenderSettings) { guard !state.isRunning else { @@ -32,15 +33,16 @@ final class VideoProcessor: ObservableObject { progress = 0 progressText = "Vorbereitung" state = .running + cancellationRequested = false let settingsCopy = settings renderTask = Task { do { let outcome = try await render(settings: settingsCopy, existingProject: nil) guard !Task.isCancelled else { - throw VideoProcessingError.cancelled + throw CancellationError() } - project = settingsCopy.renderMode == .photoMorphing ? nil : outcome.project + project = outcome.project state = .finished(outcome.outputURL) progress = 1 progressText = "Fertig" @@ -50,9 +52,13 @@ final class VideoProcessor: ObservableObject { } catch VideoProcessingError.cancelled { handleCancellation() } catch { - state = .failed(error.localizedDescription) - progressText = "Fehler" - appendLog("Fehler: \(error.localizedDescription)") + if Task.isCancelled { + handleCancellation() + } else { + state = .failed(error.localizedDescription) + progressText = "Fehler" + appendLog("Fehler: \(error.localizedDescription)") + } } renderTask = nil } @@ -68,6 +74,7 @@ final class VideoProcessor: ObservableObject { progress = 0 progressText = "Render aus Projekt" state = .running + cancellationRequested = false let settingsCopy = settings let projectCopy = reorderedProject(project, sortMode: settings.sortMode) @@ -75,7 +82,7 @@ final class VideoProcessor: ObservableObject { do { let outcome = try await render(settings: settingsCopy, existingProject: projectCopy) guard !Task.isCancelled else { - throw VideoProcessingError.cancelled + throw CancellationError() } self.project = outcome.project state = .finished(outcome.outputURL) @@ -87,9 +94,13 @@ final class VideoProcessor: ObservableObject { } catch VideoProcessingError.cancelled { handleCancellation() } catch { - state = .failed(error.localizedDescription) - progressText = "Fehler" - appendLog("Fehler: \(error.localizedDescription)") + if Task.isCancelled { + handleCancellation() + } else { + state = .failed(error.localizedDescription) + progressText = "Fehler" + appendLog("Fehler: \(error.localizedDescription)") + } } renderTask = nil } @@ -182,14 +193,16 @@ final class VideoProcessor: ObservableObject { } func cancel() { - guard state.isRunning else { + guard state.isRunning, !cancellationRequested else { return } + cancellationRequested = true + progressText = "Wird abgebrochen" + appendLog("Abbruch angefordert.") renderTask?.cancel() Task { await service.cancel() } - handleCancellation() } func showInFinder(url: URL) { @@ -206,8 +219,8 @@ final class VideoProcessor: ObservableObject { appendLog("ffmpeg: \(tools.ffmpeg.path)") appendLog("ffprobe: \(tools.ffprobe.path)") - if effectiveSettings.renderMode == .photoMorphing { - return try await renderPhotoMorph(settings: effectiveSettings, ffmpeg: tools.ffmpeg, ffprobe: tools.ffprobe) + guard effectiveSettings.renderMode == .videoGrowthLapse else { + throw VideoProcessingError.invalidSettings("Foto-Morphing ist derzeit deaktiviert.") } if effectiveSettings.stabilizationEnabled || effectiveSettings.burnInClipNumbers { @@ -271,26 +284,36 @@ final class VideoProcessor: ObservableObject { ) } + let fingerprint = renderFingerprint(settings: effectiveSettings, targetDimensions: targetDimensions) + if renderProject.renderFingerprint != fingerprint { + if renderProject.renderFingerprint != nil { + appendLog("Render-Einstellungen haben sich geändert. Der Clip-Cache wird vollständig erneuert.") + } + for index in renderProject.clips.indices { + renderProject.clips[index].needsRender = true + } + renderProject.renderFingerprint = fingerprint + } + let normalizedClips = try await normalizeVideos( videos, project: &renderProject, settings: effectiveSettings, targetDimensions: targetDimensions, ffmpeg: tools.ffmpeg, + ffprobe: tools.ffprobe, tempDirectory: tempDirectory ) if effectiveSettings.transitionLength <= 0 { try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf) } else { - try await crossfadeIteratively( + try await crossfade( clips: normalizedClips, settings: effectiveSettings, targetDimensions: targetDimensions, outputFile: outputFile, - ffmpeg: tools.ffmpeg, - tempDirectory: tempDirectory, - keepReviewCache: useReviewCache + ffmpeg: tools.ffmpeg ) } @@ -398,7 +421,8 @@ final class VideoProcessor: ObservableObject { cacheDirectoryPath: tempDirectory.path, targetWidth: targetDimensions.width, targetHeight: targetDimensions.height, - clips: [] + clips: [], + renderFingerprint: nil )) } @@ -833,12 +857,52 @@ final class VideoProcessor: ObservableObject { return VideoDimensions(width: evenDimension(width), height: evenDimension(height)) } + private func hasAudioStream(url: URL, ffprobe: URL) async throws -> Bool { + let result = try await service.run( + executable: ffprobe, + arguments: [ + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=index", + "-of", "csv=p=0", + url.path + ] + ) + + guard result.exitCode == 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + + return !result.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func isHDRVideo(url: URL, ffprobe: URL) async throws -> Bool { + let result = try await service.run( + executable: ffprobe, + arguments: [ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=color_transfer,color_primaries", + "-of", "default=noprint_wrappers=1:nokey=1", + url.path + ] + ) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffprobeFailed(url, result.output) + } + let metadata = result.output.lowercased() + return metadata.contains("arib-std-b67") + || metadata.contains("smpte2084") + || metadata.contains("bt2020") + } + private func normalizeVideos( _ videos: [VideoFile], project: inout GrowthLapseProject, settings: RenderSettings, targetDimensions: VideoDimensions, ffmpeg: URL, + ffprobe: URL, tempDirectory: URL ) async throws -> [URL] { var outputURLs: [URL] = [] @@ -852,23 +916,29 @@ final class VideoProcessor: ObservableObject { let actualSegmentLength = projectClip.segmentLength let safeStart = min(projectClip.segmentStart, max(0, video.duration - actualSegmentLength)) let speedFactor = max(0.0001, actualSegmentLength / settings.targetClipLength) - - if !projectClip.needsRender, FileManager.default.fileExists(atPath: outputURL.path) { - appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)") - outputURLs.append(outputURL) - progress = Double(index + 1) / Double(videos.count) * normalizationWeight - continue + let sourceIsHDR = try await isHDRVideo(url: video.url, ffprobe: ffprobe) + let sourceHasAudio: Bool + if settings.removeAudio { + sourceHasAudio = false + } else { + sourceHasAudio = try await hasAudioStream(url: video.url, ffprobe: ffprobe) } - let faceCrop = try await detectFaceCropIfNeeded( - source: video.url, - start: safeStart, - duration: actualSegmentLength, - settings: settings, - targetDimensions: targetDimensions, - clipNumber: clipNumber, - totalClips: videos.count - ) + if !projectClip.needsRender, FileManager.default.fileExists(atPath: outputURL.path) { + let cachedAudioIsUsable: Bool + if settings.removeAudio { + cachedAudioIsUsable = true + } else { + cachedAudioIsUsable = try await hasAudioStream(url: outputURL, ffprobe: ffprobe) + } + if cachedAudioIsUsable { + appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)") + outputURLs.append(outputURL) + progress = Double(index + 1) / Double(videos.count) * normalizationWeight + continue + } + appendLog("Cache wird neu erzeugt, weil die Audiospur fehlt: \(outputURL.lastPathComponent)") + } if settings.stabilizationEnabled { let segmentURL = tempDirectory.appendingPathComponent(String(format: "segment_%04d.mp4", clipNumber)) @@ -880,6 +950,7 @@ final class VideoProcessor: ObservableObject { duration: actualSegmentLength, settings: settings, ffmpeg: ffmpeg, + preserveAudio: sourceHasAudio, clipNumber: clipNumber, totalClips: videos.count ) @@ -889,20 +960,32 @@ final class VideoProcessor: ObservableObject { video: video, settings: settings, ffmpeg: ffmpeg, + preserveAudio: sourceHasAudio, tempDirectory: tempDirectory, clipNumber: clipNumber, totalClips: videos.count ) + let faceAlignment = try await detectFaceAlignmentIfNeeded( + source: sourceForNormalization, + start: 0, + duration: actualSegmentLength, + settings: settings, + targetDimensions: targetDimensions, + clipNumber: clipNumber, + totalClips: videos.count + ) try await normalizePreparedSegment( source: sourceForNormalization, output: outputURL, actualSegmentLength: actualSegmentLength, speedFactor: speedFactor, - faceCrop: faceCrop, + faceAlignment: faceAlignment, settings: settings, targetDimensions: targetDimensions, clipIndex: projectClip.index, ffmpeg: ffmpeg, + sourceHasAudio: sourceHasAudio, + sourceIsHDR: sourceIsHDR, clipNumber: clipNumber, totalClips: videos.count ) @@ -912,17 +995,28 @@ final class VideoProcessor: ObservableObject { removeTemporaryFile(tempDirectory.appendingPathComponent(String(format: "clip_%04d.trf", clipNumber))) } } else { + let faceAlignment = try await detectFaceAlignmentIfNeeded( + source: video.url, + start: safeStart, + duration: actualSegmentLength, + settings: settings, + targetDimensions: targetDimensions, + clipNumber: clipNumber, + totalClips: videos.count + ) try await normalizeSourceSegment( source: video.url, output: outputURL, start: safeStart, duration: actualSegmentLength, speedFactor: speedFactor, - faceCrop: faceCrop, + faceAlignment: faceAlignment, settings: settings, targetDimensions: targetDimensions, clipIndex: projectClip.index, ffmpeg: ffmpeg, + sourceHasAudio: sourceHasAudio, + sourceIsHDR: sourceIsHDR, clipNumber: clipNumber, totalClips: videos.count, displayName: video.displayName @@ -948,6 +1042,7 @@ final class VideoProcessor: ObservableObject { duration: Double, settings: RenderSettings, ffmpeg: URL, + preserveAudio: Bool, clipNumber: Int, totalClips: Int ) async throws { @@ -959,10 +1054,14 @@ final class VideoProcessor: ObservableObject { "-ss", decimal(start), "-t", decimal(duration), "-i", source.path, - "-map", "0:v:0", - "-an" + "-map", "0:v:0" ] arguments.append(contentsOf: videoEncoderArguments(settings)) + if preserveAudio { + arguments.append(contentsOf: ["-map", "0:a:0", "-c:a", "aac", "-b:a", "192k"]) + } else { + arguments.append("-an") + } arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) let result = try await runFFmpeg(ffmpeg, arguments: arguments) @@ -977,6 +1076,7 @@ final class VideoProcessor: ObservableObject { video: VideoFile, settings: RenderSettings, ffmpeg: URL, + preserveAudio: Bool, tempDirectory: URL, clipNumber: Int, totalClips: Int @@ -990,11 +1090,15 @@ final class VideoProcessor: ObservableObject { video: video, settings: settings, ffmpeg: ffmpeg, + preserveAudio: preserveAudio, tempDirectory: tempDirectory, clipNumber: clipNumber, totalClips: totalClips ) + } catch is CancellationError { + throw CancellationError() } catch { + try Task.checkCancellation() appendLog("Warnung: VidStab für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)") appendLog("Fallback: Deshake für Clip \(clipNumber).") do { @@ -1003,10 +1107,14 @@ final class VideoProcessor: ObservableObject { output: output, settings: settings, ffmpeg: ffmpeg, + preserveAudio: preserveAudio, clipNumber: clipNumber, totalClips: totalClips ) + } catch is CancellationError { + throw CancellationError() } catch { + try Task.checkCancellation() appendLog("Warnung: Deshake für Clip \(clipNumber) ebenfalls fehlgeschlagen: \(error.localizedDescription)") appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.") return segment @@ -1019,10 +1127,14 @@ final class VideoProcessor: ObservableObject { output: output, settings: settings, ffmpeg: ffmpeg, + preserveAudio: preserveAudio, clipNumber: clipNumber, totalClips: totalClips ) + } catch is CancellationError { + throw CancellationError() } catch { + try Task.checkCancellation() appendLog("Warnung: Deshake für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)") appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.") return segment @@ -1036,6 +1148,7 @@ final class VideoProcessor: ObservableObject { video: VideoFile, settings: RenderSettings, ffmpeg: URL, + preserveAudio: Bool, tempDirectory: URL, clipNumber: Int, totalClips: Int @@ -1070,7 +1183,7 @@ final class VideoProcessor: ObservableObject { "smoothing=\(strength.smoothing)", "maxshift=\(strength.maxShift)", "maxangle=\(decimal(strength.maxAngle))", - "optzoom=2", + "optzoom=1", "interpol=bicubic" ].joined(separator: ":") var transformArguments = [ @@ -1078,9 +1191,14 @@ final class VideoProcessor: ObservableObject { "-nostdin", "-i", segment.path, "-vf", transformFilter, - "-an" + "-map", "0:v:0" ] transformArguments.append(contentsOf: videoEncoderArguments(settings)) + if preserveAudio { + transformArguments.append(contentsOf: ["-map", "0:a:0", "-c:a", "copy"]) + } else { + transformArguments.append("-an") + } transformArguments.append(contentsOf: ["-movflags", "+faststart", output.path]) let transformResult = try await runFFmpeg(ffmpeg, arguments: transformArguments, currentDirectory: tempDirectory) @@ -1096,6 +1214,7 @@ final class VideoProcessor: ObservableObject { output: URL, settings: RenderSettings, ffmpeg: URL, + preserveAudio: Bool, clipNumber: Int, totalClips: Int ) async throws -> URL { @@ -1106,10 +1225,15 @@ final class VideoProcessor: ObservableObject { "-y", "-nostdin", "-i", segment.path, - "-vf", "deshake=x=\(search):y=\(search):w=32:h=32", - "-an" + "-vf", "deshake=rx=\(search):ry=\(search):edge=mirror:blocksize=16:search=less", + "-map", "0:v:0" ] arguments.append(contentsOf: videoEncoderArguments(settings)) + if preserveAudio { + arguments.append(contentsOf: ["-map", "0:a:0", "-c:a", "copy"]) + } else { + arguments.append("-an") + } arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) let result = try await runFFmpeg(ffmpeg, arguments: arguments) @@ -1125,11 +1249,13 @@ final class VideoProcessor: ObservableObject { output: URL, actualSegmentLength: Double, speedFactor: Double, - faceCrop: FaceCrop?, + faceAlignment: FaceAlignment?, settings: RenderSettings, targetDimensions: VideoDimensions, clipIndex: Int, ffmpeg: URL, + sourceHasAudio: Bool, + sourceIsHDR: Bool, clipNumber: Int, totalClips: Int ) async throws { @@ -1139,12 +1265,27 @@ final class VideoProcessor: ObservableObject { "-y", "-nostdin", "-t", decimal(actualSegmentLength), - "-i", source.path, - "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers), - "-t", decimal(settings.targetClipLength), - "-an" + "-i", source.path ] + if !settings.removeAudio, !sourceHasAudio { + arguments.append(contentsOf: [ + "-f", "lavfi", + "-t", decimal(settings.targetClipLength), + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000" + ]) + } + arguments.append(contentsOf: [ + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR), + "-t", decimal(settings.targetClipLength) + ]) arguments.append(contentsOf: videoEncoderArguments(settings)) + appendNormalizedAudioArguments( + to: &arguments, + removeAudio: settings.removeAudio, + sourceHasAudio: sourceHasAudio, + speedFactor: speedFactor, + targetDuration: settings.targetClipLength + ) arguments.append(contentsOf: ["-movflags", "+faststart", output.path]) let result = try await runFFmpeg(ffmpeg, arguments: arguments) @@ -1159,11 +1300,13 @@ final class VideoProcessor: ObservableObject { start: Double, duration: Double, speedFactor: Double, - faceCrop: FaceCrop?, + faceAlignment: FaceAlignment?, settings: RenderSettings, targetDimensions: VideoDimensions, clipIndex: Int, ffmpeg: URL, + sourceHasAudio: Bool, + sourceIsHDR: Bool, clipNumber: Int, totalClips: Int, displayName: String @@ -1175,17 +1318,28 @@ final class VideoProcessor: ObservableObject { "-nostdin", "-ss", decimal(start), "-t", decimal(duration), - "-i", source.path, - "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers), - "-t", decimal(settings.targetClipLength), + "-i", source.path ] + if !settings.removeAudio, !sourceHasAudio { + arguments.append(contentsOf: [ + "-f", "lavfi", + "-t", decimal(settings.targetClipLength), + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000" + ]) + } + arguments.append(contentsOf: [ + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceAlignment: faceAlignment, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers, sourceIsHDR: sourceIsHDR), + "-t", decimal(settings.targetClipLength) + ]) arguments.append(contentsOf: videoEncoderArguments(settings)) arguments.append(contentsOf: ["-movflags", "+faststart"]) - if settings.removeAudio { - arguments.append("-an") - } else { - arguments.append(contentsOf: ["-af", "atempo=\(audioTempoChain(speedFactor))", "-c:a", "aac", "-b:a", "192k"]) - } + appendNormalizedAudioArguments( + to: &arguments, + removeAudio: settings.removeAudio, + sourceHasAudio: sourceHasAudio, + speedFactor: speedFactor, + targetDuration: settings.targetClipLength + ) arguments.append(output.path) let result = try await runFFmpeg(ffmpeg, arguments: arguments) @@ -1230,14 +1384,12 @@ final class VideoProcessor: ObservableObject { progress = 1 } - private func crossfadeIteratively( + private func crossfade( clips: [URL], settings: RenderSettings, targetDimensions: VideoDimensions, outputFile: URL, - ffmpeg: URL, - tempDirectory: URL, - keepReviewCache: Bool + ffmpeg: URL ) async throws { try Task.checkCancellation() guard clips.count > 1 else { @@ -1245,58 +1397,73 @@ final class VideoProcessor: ObservableObject { return } - var current = clips[0] - var currentDuration = settings.targetClipLength - let totalSteps = clips.count - 1 + progressText = "Erzeuge Übergänge" + appendLog("Xfade: \(clips.count) Clips in einem FFmpeg-Durchgang.") - for index in 1.. URL { @@ -1366,7 +1533,8 @@ final class VideoProcessor: ObservableObject { cacheDirectoryPath: cacheDirectory.path, targetWidth: targetDimensions.width, targetHeight: targetDimensions.height, - clips: clips + clips: clips, + renderFingerprint: nil ) } @@ -1409,6 +1577,29 @@ final class VideoProcessor: ObservableObject { } } + private func renderFingerprint(settings: RenderSettings, targetDimensions: VideoDimensions) -> String { + [ + "portrait-alignment-v3", + "\(targetDimensions.width)x\(targetDimensions.height)", + decimal(settings.targetClipLength), + "\(settings.fps)", + "\(settings.removeAudio)", + "\(settings.burnInClipNumbers)", + settings.videoEncoder.rawValue, + "\(settings.crf)", + "\(settings.hardwareBitrateMbps)", + "\(settings.stabilizationEnabled)", + settings.stabilizationMethod.rawValue, + settings.stabilizationStrength.rawValue, + "\(settings.stabilizationAnchorEnabled)", + decimal(settings.stabilizationAnchorX), + decimal(settings.stabilizationAnchorY), + decimal(settings.stabilizationAnchorSize), + "\(settings.faceNormalizationEnabled)", + decimal(settings.targetFaceHeightRatio) + ].joined(separator: "|") + } + private func reorderedProject(_ project: GrowthLapseProject, sortMode: SortMode) -> GrowthLapseProject { var copy = project copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in @@ -1504,20 +1695,32 @@ final class VideoProcessor: ObservableObject { speedFactor: Double, fps: Int, targetDimensions: VideoDimensions, - faceCrop: FaceCrop?, + faceAlignment: FaceAlignment?, clipIndex: Int, - burnInClipNumber: Bool + burnInClipNumber: Bool, + sourceIsHDR: Bool ) -> String { var filters: [String] = [] - if let faceCrop { - filters.append("crop=\(faceCrop.width):\(faceCrop.height):\(faceCrop.x):\(faceCrop.y)") + if let alignment = faceAlignment { + if abs(alignment.rotationRadians) > 0.001 { + filters.append("rotate=\(decimal(alignment.rotationRadians)):ow=iw:oh=ih:fillcolor=black") + } + if alignment.contentScale < 0.999 { + let scaledWidth = evenDimension(Int(Double(alignment.sourceWidth) * alignment.contentScale)) + let scaledHeight = evenDimension(Int(Double(alignment.sourceHeight) * alignment.contentScale)) + filters.append("scale=\(scaledWidth):\(scaledHeight)") + filters.append("pad=\(alignment.sourceWidth):\(alignment.sourceHeight):(ow-iw)/2:(oh-ih)/2:black") + } + let x = animatedCropExpression(alignment.samples.map { ($0.time, $0.x) }) + let y = animatedCropExpression(alignment.samples.map { ($0.time, $0.y) }) + filters.append("crop=\(alignment.width):\(alignment.height):x='\(x)':y='\(y)'") } filters.append("setpts=PTS/\(decimal(speedFactor))") filters.append("fps=\(fps)") filters.append("scale=\(targetDimensions.width):\(targetDimensions.height):force_original_aspect_ratio=decrease") filters.append("pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2") filters.append("setsar=1") - filters.append(contentsOf: sdrFilters()) + filters.append(contentsOf: sdrFilters(sourceIsHDR: sourceIsHDR)) if burnInClipNumber { filters.append(clipNumberOverlayFilter(clipIndex)) } @@ -1538,7 +1741,26 @@ final class VideoProcessor: ObservableObject { ].joined(separator: ":") } - private func detectFaceCropIfNeeded( + private func animatedCropExpression(_ points: [(Double, Double)]) -> String { + guard let first = points.first else { + return "0" + } + guard points.count > 1 else { + return decimal(first.1) + } + + var expression = decimal(points.last!.1) + for index in stride(from: points.count - 2, through: 0, by: -1) { + let left = points[index] + let right = points[index + 1] + let span = max(0.001, right.0 - left.0) + let interpolation = "\(decimal(left.1))+\(decimal(right.1 - left.1))*(t-\(decimal(left.0)))/\(decimal(span))" + expression = "if(lt(t\\,\(decimal(right.0)))\\,\(interpolation)\\,\(expression))" + } + return expression + } + + private func detectFaceAlignmentIfNeeded( source: URL, start: Double, duration: Double, @@ -1546,7 +1768,7 @@ final class VideoProcessor: ObservableObject { targetDimensions: VideoDimensions, clipNumber: Int, totalClips: Int - ) async throws -> FaceCrop? { + ) async throws -> FaceAlignment? { guard settings.faceNormalizationEnabled else { return nil } @@ -1554,7 +1776,7 @@ final class VideoProcessor: ObservableObject { progressText = "Gesichtsanalyse \(clipNumber) von \(totalClips)" appendLog("Vision Gesichtsanalyse: \(source.lastPathComponent)") - let crop = try await FaceCropAnalyzer.detectCrop( + let alignment = try await FaceAlignmentAnalyzer.detect( source: source, start: start, duration: duration, @@ -1562,13 +1784,15 @@ final class VideoProcessor: ObservableObject { targetFaceHeightRatio: settings.targetFaceHeightRatio ) - if let crop { - appendLog("Face Crop Clip \(clipNumber): crop=\(crop.width)x\(crop.height)+\(crop.x)+\(crop.y), Gesicht \(Int(crop.faceHeightRatio * 100))%") + if let alignment { + let eyeMode = alignment.usesEyeLandmarks ? "Augenlinie" : "Gesichtsmitte" + let zoomOut = alignment.contentScale < 0.999 ? ", Bild auf \(Int(alignment.contentScale * 100))% verkleinert" : "" + appendLog("Gesichtsausrichtung Clip \(clipNumber): \(alignment.width)x\(alignment.height), \(eyeMode), Gesicht \(Int(alignment.faceHeightRatio * 100))%\(zoomOut)") } else { appendLog("Kein Gesicht in Clip \(clipNumber) erkannt. Render ohne Face-Normalisierung.") } - return crop + return alignment } private func runFFmpeg(_ ffmpeg: URL, arguments: [String], currentDirectory: URL? = nil) async throws -> ProcessResult { @@ -1715,8 +1939,19 @@ final class VideoProcessor: ObservableObject { } } - private func sdrFilters() -> [String] { - [ + private func sdrFilters(sourceIsHDR: Bool) -> [String] { + if sourceIsHDR { + return [ + "zscale=transfer=linear:npl=100", + "format=gbrpf32le", + "zscale=primaries=bt709", + "tonemap=tonemap=hable:desat=0", + "zscale=transfer=bt709:matrix=bt709:range=tv", + "format=yuv420p", + "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709" + ] + } + return [ "format=yuv420p", "colorspace=all=bt709:iall=bt709:fast=1", "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709" @@ -1749,6 +1984,30 @@ final class VideoProcessor: ObservableObject { } } + private func appendNormalizedAudioArguments( + to arguments: inout [String], + removeAudio: Bool, + sourceHasAudio: Bool, + speedFactor: Double, + targetDuration: Double + ) { + guard !removeAudio else { + arguments.append("-an") + return + } + + arguments.append(contentsOf: ["-map", "0:v:0"]) + if sourceHasAudio { + arguments.append(contentsOf: [ + "-map", "0:a:0", + "-af", "atempo=\(audioTempoChain(speedFactor)),aresample=48000,aformat=sample_rates=48000:channel_layouts=stereo,apad=whole_dur=\(decimal(targetDuration)),atrim=duration=\(decimal(targetDuration))" + ]) + } else { + arguments.append(contentsOf: ["-map", "1:a:0"]) + } + arguments.append(contentsOf: ["-c:a", "aac", "-b:a", "192k"]) + } + private func audioTempoChain(_ speedFactor: Double) -> String { var remaining = speedFactor var filters: [String] = [] @@ -1791,6 +2050,7 @@ final class VideoProcessor: ObservableObject { } private func handleCancellation() { + cancellationRequested = false Task { await service.cancel() } @@ -1805,7 +2065,10 @@ final class VideoProcessor: ObservableObject { } private struct DetectedFaceSample { + let time: Double let rect: CGRect + let eyeCenter: CGPoint? + let eyeAngle: Double? let imageWidth: Int let imageHeight: Int } @@ -2336,14 +2599,14 @@ private enum BestSegmentAnalyzer { } } -private enum FaceCropAnalyzer { - static func detectCrop( +private enum FaceAlignmentAnalyzer { + static func detect( source: URL, start: Double, duration: Double, targetDimensions: VideoDimensions, targetFaceHeightRatio: Double - ) async throws -> FaceCrop? { + ) async throws -> FaceAlignment? { try await Task.detached(priority: .userInitiated) { try Task.checkCancellation() @@ -2355,11 +2618,12 @@ private enum FaceCropAnalyzer { let sampleTimes = sampleTimes(start: start, duration: duration) var samples: [DetectedFaceSample] = [] + var previousCenter: CGPoint? - for time in sampleTimes { + for (index, time) in sampleTimes.enumerated() { try Task.checkCancellation() let cgImage = try generator.copyCGImage(at: time, actualTime: nil) - let request = VNDetectFaceRectanglesRequest() + let request = VNDetectFaceLandmarksRequest() let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) try handler.perform([request]) @@ -2367,10 +2631,21 @@ private enum FaceCropAnalyzer { continue } - let selected = selectFace(from: observations) + let selected = selectFace(from: observations, previousCenter: previousCenter) + previousCenter = CGPoint(x: selected.boundingBox.midX, y: selected.boundingBox.midY) + let eyes = eyeGeometry( + observation: selected, + imageWidth: cgImage.width, + imageHeight: cgImage.height + ) samples.append( DetectedFaceSample( + time: sampleTimes.count > 1 + ? Double(index) / Double(sampleTimes.count - 1) * duration + : 0, rect: selected.boundingBox, + eyeCenter: eyes?.center, + eyeAngle: eyes?.angle, imageWidth: cgImage.width, imageHeight: cgImage.height ) @@ -2381,7 +2656,7 @@ private enum FaceCropAnalyzer { return nil } - return cropFromMedianFace( + return alignmentFromSamples( samples, targetDimensions: targetDimensions, targetFaceHeightRatio: targetFaceHeightRatio @@ -2390,24 +2665,39 @@ private enum FaceCropAnalyzer { } private static func sampleTimes(start: Double, duration: Double) -> [CMTime] { - let count = max(3, min(9, Int(duration.rounded(.down)) + 1)) + let count = max(9, min(36, Int((duration / 0.25).rounded(.up)) + 1)) guard count > 1 else { return [CMTime(seconds: start, preferredTimescale: 600)] } return (0.. VNFaceObservation { - observations.max { left, right in + private static func selectFace( + from observations: [VNFaceObservation], + previousCenter: CGPoint? + ) -> VNFaceObservation { + if let previousCenter { + return observations.min { left, right in + distance(from: left.boundingBox, to: previousCenter) + < distance(from: right.boundingBox, to: previousCenter) + } ?? observations[0] + } + return observations.max { left, right in score(left.boundingBox) < score(right.boundingBox) } ?? observations[0] } + private static func distance(from rect: CGRect, to point: CGPoint) -> Double { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return sqrt(dx * dx + dy * dy) - Double(rect.width * rect.height) * 0.15 + } + private static func score(_ rect: CGRect) -> Double { let area = rect.width * rect.height let dx = rect.midX - 0.5 @@ -2416,65 +2706,134 @@ private enum FaceCropAnalyzer { return area - centralityPenalty } - private static func cropFromMedianFace( + private static func eyeGeometry( + observation: VNFaceObservation, + imageWidth: Int, + imageHeight: Int + ) -> (center: CGPoint, angle: Double)? { + guard let left = observation.landmarks?.leftEye, + let right = observation.landmarks?.rightEye, + !left.normalizedPoints.isEmpty, + !right.normalizedPoints.isEmpty else { + return nil + } + + func center(of region: VNFaceLandmarkRegion2D) -> CGPoint { + let point = region.normalizedPoints.reduce(CGPoint.zero) { + CGPoint(x: $0.x + $1.x, y: $0.y + $1.y) + } + let count = CGFloat(region.normalizedPoints.count) + let local = CGPoint(x: point.x / count, y: point.y / count) + let box = observation.boundingBox + return CGPoint( + x: (box.minX + local.x * box.width) * Double(imageWidth), + y: (1 - (box.minY + local.y * box.height)) * Double(imageHeight) + ) + } + + let a = center(of: left) + let b = center(of: right) + let leftToRight = a.x <= b.x ? (a, b) : (b, a) + return ( + CGPoint(x: (leftToRight.0.x + leftToRight.1.x) / 2, y: (leftToRight.0.y + leftToRight.1.y) / 2), + atan2(leftToRight.1.y - leftToRight.0.y, leftToRight.1.x - leftToRight.0.x) + ) + } + + private static func alignmentFromSamples( _ samples: [DetectedFaceSample], targetDimensions: VideoDimensions, targetFaceHeightRatio: Double - ) -> FaceCrop? { + ) -> FaceAlignment? { let imageWidth = samples[samples.count / 2].imageWidth let imageHeight = samples[samples.count / 2].imageHeight - - let medianX = median(samples.map { Double($0.rect.origin.x) }) - let medianY = median(samples.map { Double($0.rect.origin.y) }) - let medianWidth = median(samples.map { Double($0.rect.size.width) }) let medianHeight = median(samples.map { Double($0.rect.size.height) }) - guard medianWidth > 0, medianHeight > 0 else { + guard medianHeight > 0 else { return nil } - let faceCenterX = (medianX + medianWidth / 2) * Double(imageWidth) - let faceCenterYFromTop = (1 - (medianY + medianHeight / 2)) * Double(imageHeight) let faceHeightPixels = medianHeight * Double(imageHeight) guard faceHeightPixels > 4 else { return nil } let outputAspect = Double(targetDimensions.width) / Double(targetDimensions.height) - var cropHeight = faceHeightPixels / targetFaceHeightRatio - var cropWidth = cropHeight * outputAspect - - if cropWidth > Double(imageWidth) { - cropWidth = Double(imageWidth) - cropHeight = cropWidth / outputAspect - } - if cropHeight > Double(imageHeight) { - cropHeight = Double(imageHeight) - cropWidth = cropHeight * outputAspect - } - - cropWidth = min(cropWidth, Double(imageWidth)) - cropHeight = min(cropHeight, Double(imageHeight)) - - let desiredFaceCenterY = 0.43 - var cropX = faceCenterX - cropWidth / 2 - var cropY = faceCenterYFromTop - cropHeight * desiredFaceCenterY - - cropX = clamp(cropX, min: 0, max: Double(imageWidth) - cropWidth) - cropY = clamp(cropY, min: 0, max: Double(imageHeight) - cropHeight) - - let evenX = even(Int(cropX.rounded(FloatingPointRoundingRule.down))) - let evenY = even(Int(cropY.rounded(FloatingPointRoundingRule.down))) - let evenWidth = max(2, even(Int(cropWidth.rounded(FloatingPointRoundingRule.down)))) - let evenHeight = max(2, even(Int(cropHeight.rounded(FloatingPointRoundingRule.down)))) - - return FaceCrop( - x: min(evenX, max(0, imageWidth - evenWidth)), - y: min(evenY, max(0, imageHeight - evenHeight)), - width: min(evenWidth, imageWidth), - height: min(evenHeight, imageHeight), - faceHeightRatio: faceHeightPixels / max(1, cropHeight) + let desiredCropHeight = faceHeightPixels / targetFaceHeightRatio + let desiredCropWidth = desiredCropHeight * outputAspect + let contentScale = min( + 1, + Double(imageWidth) / desiredCropWidth, + Double(imageHeight) / desiredCropHeight ) + let cropWidth = max(2, even(Int((desiredCropWidth * contentScale).rounded(.down)))) + let cropHeight = max(2, even(Int((desiredCropHeight * contentScale).rounded(.down)))) + let eyeAngles = samples.compactMap(\.eyeAngle) + let rotation = clamp(-median(eyeAngles), min: -0.14, max: 0.14) + let usesEyes = !eyeAngles.isEmpty + let center = CGPoint(x: Double(imageWidth) / 2, y: Double(imageHeight) / 2) + + let rawPositions = samples.map { sample -> FaceAlignmentSample in + let fallback = CGPoint( + x: sample.rect.midX * Double(imageWidth), + y: (1 - sample.rect.midY) * Double(imageHeight) + ) + var anchor = sample.eyeCenter ?? fallback + anchor = rotated(anchor, around: center, by: rotation) + anchor = CGPoint( + x: center.x + (anchor.x - center.x) * contentScale, + y: center.y + (anchor.y - center.y) * contentScale + ) + let desiredY = sample.eyeCenter == nil ? 0.43 : 0.40 + return FaceAlignmentSample( + time: sample.time, + x: clamp(anchor.x - Double(cropWidth) / 2, min: 0, max: Double(imageWidth - cropWidth)), + y: clamp(anchor.y - Double(cropHeight) * desiredY, min: 0, max: Double(imageHeight - cropHeight)) + ) + } + let positions = smoothed(rawPositions, imageWidth: imageWidth, imageHeight: imageHeight, cropWidth: cropWidth, cropHeight: cropHeight) + + return FaceAlignment( + sourceWidth: imageWidth, + sourceHeight: imageHeight, + width: cropWidth, + height: cropHeight, + rotationRadians: rotation, + contentScale: contentScale, + samples: positions, + faceHeightRatio: targetFaceHeightRatio, + usesEyeLandmarks: usesEyes + ) + } + + private static func rotated(_ point: CGPoint, around center: CGPoint, by angle: Double) -> CGPoint { + let dx = point.x - center.x + let dy = point.y - center.y + let cosine = cos(angle) + let sine = sin(angle) + return CGPoint( + x: center.x + cosine * dx - sine * dy, + y: center.y + sine * dx + cosine * dy + ) + } + + private static func smoothed( + _ samples: [FaceAlignmentSample], + imageWidth: Int, + imageHeight: Int, + cropWidth: Int, + cropHeight: Int + ) -> [FaceAlignmentSample] { + samples.indices.map { index in + let lower = max(0, index - 2) + let upper = min(samples.count - 1, index + 2) + let window = samples[lower...upper] + return FaceAlignmentSample( + time: samples[index].time, + x: clamp(median(window.map(\.x)), min: 0, max: Double(imageWidth - cropWidth)), + y: clamp(median(window.map(\.y)), min: 0, max: Double(imageHeight - cropHeight)) + ) + } } private static func median(_ values: [Double]) -> Double { @@ -2494,6 +2853,6 @@ private enum FaceCropAnalyzer { } private static func even(_ value: Int) -> Int { - max(0, value - (value % 2)) + max(0, value - value % 2) } }