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