diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 373ec65..92e1a53 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -186,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("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.") + .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.") HStack { Text("Ziel-Gesichtshöhe") diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index a3b9c7c..5624699 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -1579,7 +1579,7 @@ final class VideoProcessor: ObservableObject { private func renderFingerprint(settings: RenderSettings, targetDimensions: VideoDimensions) -> String { [ - "portrait-alignment-v3", + "portrait-alignment-v4-full-frame", "\(targetDimensions.width)x\(targetDimensions.height)", decimal(settings.targetClipLength), "\(settings.fps)", @@ -1786,8 +1786,10 @@ final class VideoProcessor: ObservableObject { 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)") + let limitedByFrame = alignment.faceHeightRatio > settings.targetFaceHeightRatio + 0.01 + ? ", Zielgröße wegen Bildrand nicht vollständig erreichbar" + : "" + appendLog("Gesichtsausrichtung Clip \(clipNumber): \(alignment.width)x\(alignment.height), \(eyeMode), Gesicht \(Int(alignment.faceHeightRatio * 100))%\(limitedByFrame)") } else { appendLog("Kein Gesicht in Clip \(clipNumber) erkannt. Render ohne Face-Normalisierung.") } @@ -2759,19 +2761,38 @@ private enum FaceAlignmentAnalyzer { } let outputAspect = Double(targetDimensions.width) / Double(targetDimensions.height) - 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)))) + 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 + } + let evenCropWidth = max(2, even(Int(cropWidth.rounded(.down)))) + let evenCropHeight = max(2, even(Int(cropHeight.rounded(.down)))) let eyeAngles = samples.compactMap(\.eyeAngle) - let rotation = clamp(-median(eyeAngles), min: -0.14, max: 0.14) + let proposedRotation = clamp(-median(eyeAngles), min: -0.14, max: 0.14) + let safeFrame = safeFrameAfterRotation( + imageWidth: imageWidth, + imageHeight: imageHeight, + aspect: outputAspect, + rotation: proposedRotation + ) + let canRotateWithoutBlack = Double(evenCropWidth) <= safeFrame.width + && Double(evenCropHeight) <= safeFrame.height + let rotation = canRotateWithoutBlack ? proposedRotation : 0 let usesEyes = !eyeAngles.isEmpty let center = CGPoint(x: Double(imageWidth) / 2, y: Double(imageHeight) / 2) + let allowedFrame = rotation == 0 + ? CGRect(x: 0, y: 0, width: imageWidth, height: imageHeight) + : safeFrame + let minX = allowedFrame.minX + let minY = allowedFrame.minY + let maxX = allowedFrame.maxX - Double(evenCropWidth) + let maxY = allowedFrame.maxY - Double(evenCropHeight) let rawPositions = samples.map { sample -> FaceAlignmentSample in let fallback = CGPoint( @@ -2780,32 +2801,58 @@ private enum FaceAlignmentAnalyzer { ) 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)) + x: clamp(anchor.x - Double(evenCropWidth) / 2, min: minX, max: maxX), + y: clamp(anchor.y - Double(evenCropHeight) * desiredY, min: minY, max: maxY) ) } - let positions = smoothed(rawPositions, imageWidth: imageWidth, imageHeight: imageHeight, cropWidth: cropWidth, cropHeight: cropHeight) + let positions = smoothed( + rawPositions, + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY + ) return FaceAlignment( sourceWidth: imageWidth, sourceHeight: imageHeight, - width: cropWidth, - height: cropHeight, + width: evenCropWidth, + height: evenCropHeight, rotationRadians: rotation, - contentScale: contentScale, + contentScale: 1, samples: positions, - faceHeightRatio: targetFaceHeightRatio, + faceHeightRatio: faceHeightPixels / Double(evenCropHeight), usesEyeLandmarks: usesEyes ) } + private static func safeFrameAfterRotation( + imageWidth: Int, + imageHeight: Int, + aspect: Double, + rotation: Double + ) -> CGRect { + let angle = abs(rotation) + let cosine = cos(angle) + let sine = sin(angle) + let width = Double(imageWidth) + let height = Double(imageHeight) + let safeHeight = min( + width / (aspect * cosine + sine), + height / (aspect * sine + cosine) + ) + let safeWidth = safeHeight * aspect + return CGRect( + x: (width - safeWidth) / 2, + y: (height - safeHeight) / 2, + width: safeWidth, + height: safeHeight + ) + } + 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 @@ -2819,10 +2866,10 @@ private enum FaceAlignmentAnalyzer { private static func smoothed( _ samples: [FaceAlignmentSample], - imageWidth: Int, - imageHeight: Int, - cropWidth: Int, - cropHeight: Int + minX: Double, + maxX: Double, + minY: Double, + maxY: Double ) -> [FaceAlignmentSample] { samples.indices.map { index in let lower = max(0, index - 2) @@ -2830,8 +2877,8 @@ private enum FaceAlignmentAnalyzer { 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)) + x: clamp(median(window.map(\.x)), min: minX, max: maxX), + y: clamp(median(window.map(\.y)), min: minY, max: maxY) ) } } diff --git a/dist/GrowthLapse.app/Contents/Info.plist b/dist/GrowthLapse.app/Contents/Info.plist index 2604f64..87c12ba 100644 --- a/dist/GrowthLapse.app/Contents/Info.plist +++ b/dist/GrowthLapse.app/Contents/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.2.1 CFBundleVersion - 2 + 3 LSMinimumSystemVersion 13.0 NSHighResolutionCapable diff --git a/dist/GrowthLapse.app/Contents/MacOS/GrowthLapse b/dist/GrowthLapse.app/Contents/MacOS/GrowthLapse index 5ad90f2..ce5effc 100755 Binary files a/dist/GrowthLapse.app/Contents/MacOS/GrowthLapse and b/dist/GrowthLapse.app/Contents/MacOS/GrowthLapse differ