Schritt 5 der Umsetzungsreihenfolge. ReviewSheet zeigt die Quelldatei per AVKit VideoPlayer (Scrubbing, Play/Pause etc. kommen dadurch kostenlos mit) und blendet oben/unten abgedunkelte Balken plus einen hellen Rahmen für die bleibende 4:5- Fläche ein. Die Geometrie kommt direkt aus CropEngine.targetSize(forSourceSize:) — die Vorschau zeigt also exakt das, was beim tatsächlichen Crop rauskommt, keine zweite Berechnung. Erreichbar über einen Vorschau-Button pro Zeile in der Dateiliste. Bewusst noch nicht enthalten (README, "Danach"-Liste): verstellbarer vertikaler Crop-Offset — die Ansicht ist reine Framing-Kontrolle.main
parent
64a230a667
commit
4a8583f0f7
@ -0,0 +1,91 @@
|
||||
import AVKit
|
||||
import DropTwoCore
|
||||
import SwiftUI
|
||||
|
||||
/// Schritt 5: 9:16-Master mit eingeblendetem 4:5-Rahmen zur Framing-Kontrolle.
|
||||
/// Scrubbar über die nativen AVKit-Kontrollen von `VideoPlayer`. Der Rahmen
|
||||
/// nutzt exakt dieselbe Geometrie wie `CropEngine.targetSize` — was hier zu
|
||||
/// sehen ist, ist tatsächlich das, was beim Crop rauskommt. Rein zur Kontrolle:
|
||||
/// der Offset ist hier noch nicht verstellbar (README, "Danach"-Liste).
|
||||
struct ReviewSheet: View {
|
||||
let sourceURL: URL
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var player: AVPlayer?
|
||||
@State private var videoSize: CGSize?
|
||||
@State private var loadError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text(sourceURL.lastPathComponent)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button("Schließen") { dismiss() }
|
||||
}
|
||||
content
|
||||
}
|
||||
.padding(16)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let loadError {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title)
|
||||
Text(loadError)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let player, let videoSize, videoSize.height > 0 {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
VideoPlayer(player: player)
|
||||
cropOverlay(sourceSize: videoSize, viewSize: proxy.size)
|
||||
}
|
||||
}
|
||||
.aspectRatio(videoSize.width / videoSize.height, contentMode: .fit)
|
||||
} else {
|
||||
ProgressView("Lädt Vorschau …")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwei abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt;
|
||||
/// der helle Rahmen zeigt die bleibende 4:5-Fläche — volle Breite,
|
||||
/// vertikal zentriert, exakt wie `CropEngine` es tatsächlich schneidet.
|
||||
@ViewBuilder
|
||||
private func cropOverlay(sourceSize: CGSize, viewSize: CGSize) -> some View {
|
||||
let cropSize = CropEngine.targetSize(forSourceSize: sourceSize)
|
||||
let marginFraction = max(0, (1 - cropSize.height / sourceSize.height) / 2)
|
||||
let marginHeight = viewSize.height * marginFraction
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.55))
|
||||
.frame(height: marginHeight)
|
||||
Rectangle()
|
||||
.strokeBorder(Color(red: 0.784, green: 0.663, blue: 0.416), lineWidth: 2)
|
||||
.frame(height: max(viewSize.height - marginHeight * 2, 0))
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.55))
|
||||
.frame(height: marginHeight)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
let info = try await VideoInspector.inspect(sourceURL)
|
||||
videoSize = info.pixelSize
|
||||
player = AVPlayer(url: sourceURL)
|
||||
} catch {
|
||||
loadError = "Konnte Vorschau nicht laden."
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue