|
|
import AVKit
|
|
|
import DropTwoCore
|
|
|
import SwiftUI
|
|
|
|
|
|
/// 9:16-Master mit eingeblendetem 4:5-Rahmen — zur Framing-Kontrolle und zum
|
|
|
/// Verschieben des Ausschnitts. Der Rahmen lässt sich mit der Maus nach oben
|
|
|
/// oder unten ziehen. Für Clips mit Szenenwechsel setzt man Positionsmarken:
|
|
|
/// ab jeder Marke gilt eine eigene Rahmenposition (`CropPlan`).
|
|
|
///
|
|
|
/// Rahmen und Render nutzen dieselbe Geometrie (`CropEngine.cropRect`) — was
|
|
|
/// hier zu sehen ist, ist tatsächlich das, was beim Crop rauskommt.
|
|
|
///
|
|
|
/// Eigene Transportleiste statt der nativen `AVPlayerView`-Kontrollen: die
|
|
|
/// liegen im Bild und würden mit dem Ziehen des Rahmens kollidieren, und
|
|
|
/// die Marken müssen auf der Zeitleiste sichtbar sein.
|
|
|
struct ReviewSheet: View {
|
|
|
let sourceURL: URL
|
|
|
let onApply: (CropPlan) -> Void
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
|
|
@State private var plan: CropPlan
|
|
|
@State private var player: AVPlayer?
|
|
|
@State private var info: VideoInfo?
|
|
|
@State private var loadError: String?
|
|
|
@State private var currentTime: Double = 0
|
|
|
@State private var isPlaying = false
|
|
|
@State private var timeObserver: Any?
|
|
|
/// Offset beim Beginn eines Rahmen-Drags; nil = gerade kein Drag.
|
|
|
@State private var dragStartOffset: CGFloat?
|
|
|
/// Marke unter dem Mauszeiger auf der Zeitleiste.
|
|
|
@State private var hoveredKeyframeID: CropPlan.Keyframe.ID?
|
|
|
|
|
|
init(sourceURL: URL, initialPlan: CropPlan, onApply: @escaping (CropPlan) -> Void) {
|
|
|
self.sourceURL = sourceURL
|
|
|
self.onApply = onApply
|
|
|
_plan = State(initialValue: initialPlan)
|
|
|
}
|
|
|
|
|
|
var body: some View {
|
|
|
VStack(spacing: 12) {
|
|
|
HStack(spacing: 8) {
|
|
|
Image(systemName: "crop")
|
|
|
.foregroundStyle(Color.gold)
|
|
|
Text(sourceURL.lastPathComponent)
|
|
|
.font(.system(size: 14, weight: .semibold))
|
|
|
.lineLimit(1)
|
|
|
.truncationMode(.middle)
|
|
|
}
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
content
|
|
|
|
|
|
if let info, player != nil {
|
|
|
transportBar(info: info)
|
|
|
markerBar
|
|
|
}
|
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
Button("Zurücksetzen") { plan = CropPlan() }
|
|
|
.disabled(plan.isDefault)
|
|
|
.help("Mittig, alle Marken entfernen")
|
|
|
Spacer()
|
|
|
Button("Abbrechen", role: .cancel) { dismiss() }
|
|
|
.keyboardShortcut(.cancelAction)
|
|
|
Button("Übernehmen") {
|
|
|
onApply(plan)
|
|
|
dismiss()
|
|
|
}
|
|
|
.buttonStyle(ProminentButtonStyle())
|
|
|
.keyboardShortcut(.defaultAction)
|
|
|
.disabled(info == nil)
|
|
|
}
|
|
|
.padding(.top, 4)
|
|
|
}
|
|
|
.padding(18)
|
|
|
.frame(minWidth: 500, idealWidth: 540, minHeight: 700, idealHeight: 800)
|
|
|
.task { await load() }
|
|
|
.onDisappear(perform: tearDown)
|
|
|
}
|
|
|
|
|
|
// MARK: - Vorschau
|
|
|
|
|
|
@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 info, info.pixelSize.height > 0 {
|
|
|
GeometryReader { proxy in
|
|
|
ZStack {
|
|
|
PlayerView(player: player, controlsStyle: .none)
|
|
|
cropOverlay(sourceSize: info.pixelSize, viewSize: proxy.size)
|
|
|
Color.clear
|
|
|
.contentShape(Rectangle())
|
|
|
.gesture(frameDrag(sourceSize: info.pixelSize, viewSize: proxy.size))
|
|
|
.onTapGesture(count: 2, perform: centerFrame)
|
|
|
.onHover { inside in
|
|
|
if inside { NSCursor.openHand.push() } else { NSCursor.pop() }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
.aspectRatio(info.pixelSize.width / info.pixelSize.height, contentMode: .fit)
|
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
|
.overlay(
|
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
.strokeBorder(Color.secondary.opacity(0.3))
|
|
|
)
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
} else {
|
|
|
ProgressView("Lädt Vorschau …")
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt; der
|
|
|
/// goldene Rahmen zeigt die bleibende 4:5-Fläche an der Position, die
|
|
|
/// zum aktuellen Zeitpunkt gilt.
|
|
|
@ViewBuilder
|
|
|
private func cropOverlay(sourceSize: CGSize, viewSize: CGSize) -> some View {
|
|
|
let rect = CropEngine.cropRect(forSourceSize: sourceSize, offset: plan.offset(at: currentTime))
|
|
|
let scale = viewSize.height / sourceSize.height
|
|
|
let topHeight = rect.minY * scale
|
|
|
let cropHeight = rect.height * scale
|
|
|
let bottomHeight = max(viewSize.height - topHeight - cropHeight, 0)
|
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
Rectangle()
|
|
|
.fill(Color.black.opacity(0.55))
|
|
|
.frame(height: topHeight)
|
|
|
Rectangle()
|
|
|
.strokeBorder(Color.gold, lineWidth: dragStartOffset == nil ? 2 : 3)
|
|
|
.frame(height: cropHeight)
|
|
|
Rectangle()
|
|
|
.fill(Color.black.opacity(0.55))
|
|
|
.frame(height: bottomHeight)
|
|
|
}
|
|
|
.allowsHitTesting(false)
|
|
|
}
|
|
|
|
|
|
/// Ziehen irgendwo im Bild verschiebt den Rahmen des gerade geltenden
|
|
|
/// Abschnitts. Die Wiedergabe pausiert dabei, sonst wechselt der
|
|
|
/// Abschnitt mitten im Ziehen.
|
|
|
private func frameDrag(sourceSize: CGSize, viewSize: CGSize) -> some Gesture {
|
|
|
DragGesture(minimumDistance: 1)
|
|
|
.onChanged { value in
|
|
|
if dragStartOffset == nil {
|
|
|
pause()
|
|
|
dragStartOffset = plan.offset(at: currentTime)
|
|
|
NSCursor.closedHand.push()
|
|
|
}
|
|
|
let cropHeight = CropEngine.targetSize(forSourceSize: sourceSize).height
|
|
|
let slackInView = (sourceSize.height - cropHeight) * viewSize.height / sourceSize.height
|
|
|
guard slackInView > 0, let start = dragStartOffset else { return }
|
|
|
// Offset -1…1 überspannt den gesamten Spielraum, also 2 Einheiten.
|
|
|
plan.setOffset(start + value.translation.height / slackInView * 2, at: currentTime)
|
|
|
}
|
|
|
.onEnded { _ in
|
|
|
dragStartOffset = nil
|
|
|
NSCursor.pop()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Transport
|
|
|
|
|
|
private func transportBar(info: VideoInfo) -> some View {
|
|
|
let duration = CMTimeGetSeconds(info.duration)
|
|
|
return HStack(spacing: 8) {
|
|
|
Button(action: togglePlayback) {
|
|
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
|
|
}
|
|
|
.keyboardShortcut(.space, modifiers: [])
|
|
|
.help("Abspielen/Pause (Leertaste)")
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
|
Button { step(by: -1) } label: { Image(systemName: "backward.frame.fill") }
|
|
|
.keyboardShortcut(.leftArrow, modifiers: [])
|
|
|
.help("Ein Frame zurück (←)")
|
|
|
.buttonStyle(.borderless)
|
|
|
Button { step(by: 1) } label: { Image(systemName: "forward.frame.fill") }
|
|
|
.keyboardShortcut(.rightArrow, modifiers: [])
|
|
|
.help("Ein Frame vor (→)")
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
|
Text(timecode(currentTime))
|
|
|
.monospacedDigit()
|
|
|
.font(.caption)
|
|
|
|
|
|
TimelineBar(
|
|
|
duration: duration,
|
|
|
currentTime: currentTime,
|
|
|
keyframes: plan.keyframes,
|
|
|
activeIndex: plan.keyframeIndex(at: currentTime),
|
|
|
hoveredID: $hoveredKeyframeID,
|
|
|
onScrub: { seconds, isFinal in seek(to: seconds, precise: isFinal) },
|
|
|
onDelete: deleteKeyframe
|
|
|
)
|
|
|
|
|
|
Text(timecode(duration))
|
|
|
.monospacedDigit()
|
|
|
.font(.caption)
|
|
|
.foregroundStyle(.secondary)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private var markerBar: some View {
|
|
|
let activeIndex = plan.keyframeIndex(at: currentTime)
|
|
|
let active = plan.keyframes[activeIndex]
|
|
|
return VStack(alignment: .leading, spacing: 8) {
|
|
|
HStack(spacing: 10) {
|
|
|
Button(action: addMarker) {
|
|
|
Label("Marke setzen", systemImage: "plus.diamond")
|
|
|
}
|
|
|
.keyboardShortcut("m", modifiers: [])
|
|
|
.help("Ab hier gilt eine eigene Rahmenposition (M)")
|
|
|
|
|
|
Button(action: centerFrame) {
|
|
|
Label("Mittig setzen", systemImage: "align.vertical.center")
|
|
|
}
|
|
|
.keyboardShortcut("z", modifiers: [])
|
|
|
.disabled(plan.offset(at: currentTime) == 0)
|
|
|
.help(plan.keyframes.count > 1
|
|
|
? "Rahmen in diesem Abschnitt wieder mittig (Z oder Doppelklick ins Bild)"
|
|
|
: "Rahmen wieder mittig (Z oder Doppelklick ins Bild)")
|
|
|
|
|
|
// Unsichtbar, nur für die Taste: löscht die Marke unter dem
|
|
|
// Mauszeiger, sonst die am Abspielkopf.
|
|
|
Button("Marke löschen", action: deleteMarkerFromKeyboard)
|
|
|
.keyboardShortcut(.delete, modifiers: [])
|
|
|
.opacity(0)
|
|
|
.frame(width: 0, height: 0)
|
|
|
.accessibilityHidden(true)
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
if plan.keyframes.count > 1 {
|
|
|
Text("Position \(activeIndex + 1) von \(plan.keyframes.count) · ab \(timecode(active.time))")
|
|
|
.font(.system(size: 11.5))
|
|
|
.monospacedDigit()
|
|
|
.foregroundStyle(.secondary)
|
|
|
}
|
|
|
}
|
|
|
Text("Rahmen im Bild nach oben oder unten ziehen, Doppelklick setzt ihn wieder mittig. Bei einem Szenenwechsel an die Stelle spulen und eine Marke setzen – ab dort gilt eine eigene Position. Zum Löschen mit der Maus auf eine Marke zeigen und auf × klicken (oder ⌫).")
|
|
|
.font(.system(size: 11.5))
|
|
|
.foregroundStyle(.secondary)
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Setzt den Rahmen des gerade geltenden Abschnitts zurück in die Mitte.
|
|
|
private func centerFrame() {
|
|
|
plan.setOffset(0, at: currentTime)
|
|
|
}
|
|
|
|
|
|
private func deleteKeyframe(_ id: CropPlan.Keyframe.ID) {
|
|
|
plan.removeKeyframe(id: id)
|
|
|
if hoveredKeyframeID == id { hoveredKeyframeID = nil }
|
|
|
}
|
|
|
|
|
|
private func deleteMarkerFromKeyboard() {
|
|
|
if let hoveredKeyframeID {
|
|
|
deleteKeyframe(hoveredKeyframeID)
|
|
|
return
|
|
|
}
|
|
|
let frameTime = displayedFrameTime(currentTime)
|
|
|
if let atPlayhead = plan.keyframes.dropFirst().first(where: { abs($0.time - frameTime) <= CropPlan.timeTolerance }) {
|
|
|
deleteKeyframe(atPlayhead.id)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Player
|
|
|
|
|
|
private func load() async {
|
|
|
do {
|
|
|
let inspected = try await VideoInspector.inspect(sourceURL)
|
|
|
let newPlayer = AVPlayer(url: sourceURL)
|
|
|
let interval = CMTime(value: 1, timescale: 30)
|
|
|
timeObserver = newPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
|
|
|
currentTime = CMTimeGetSeconds(time)
|
|
|
isPlaying = newPlayer.rate != 0
|
|
|
}
|
|
|
info = inspected
|
|
|
player = newPlayer
|
|
|
} catch {
|
|
|
loadError = "Konnte Vorschau nicht laden."
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func tearDown() {
|
|
|
player?.pause()
|
|
|
if let timeObserver {
|
|
|
player?.removeTimeObserver(timeObserver)
|
|
|
}
|
|
|
timeObserver = nil
|
|
|
}
|
|
|
|
|
|
private func togglePlayback() {
|
|
|
guard let player else { return }
|
|
|
if player.rate != 0 {
|
|
|
pause()
|
|
|
return
|
|
|
}
|
|
|
if let info, currentTime >= CMTimeGetSeconds(info.duration) - 0.05 {
|
|
|
seek(to: 0, precise: true)
|
|
|
}
|
|
|
player.play()
|
|
|
isPlaying = true
|
|
|
}
|
|
|
|
|
|
private func pause() {
|
|
|
player?.pause()
|
|
|
isPlaying = false
|
|
|
}
|
|
|
|
|
|
private func seek(to seconds: Double, precise: Bool) {
|
|
|
guard let player else { return }
|
|
|
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
|
|
let tolerance = precise ? CMTime.zero : CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
|
player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance)
|
|
|
currentTime = seconds
|
|
|
}
|
|
|
|
|
|
private func step(by frames: Int) {
|
|
|
guard let info, info.frameRate > 0 else { return }
|
|
|
pause()
|
|
|
let duration = CMTimeGetSeconds(info.duration)
|
|
|
let target = displayedFrameTime(currentTime) + Double(frames) / Double(info.frameRate)
|
|
|
seek(to: min(max(target, 0), duration), precise: true)
|
|
|
}
|
|
|
|
|
|
/// Marke auf den Beginn des angezeigten Frames legen, damit genau dieser
|
|
|
/// Frame schon die neue Position bekommt.
|
|
|
private func addMarker() {
|
|
|
pause()
|
|
|
plan.addKeyframe(at: displayedFrameTime(currentTime))
|
|
|
}
|
|
|
|
|
|
/// Startzeit des Frames, der bei `seconds` zu sehen ist.
|
|
|
private func displayedFrameTime(_ seconds: Double) -> Double {
|
|
|
guard let info, info.frameRate > 0 else { return seconds }
|
|
|
let fps = Double(info.frameRate)
|
|
|
return ((seconds * fps) + CropPlan.timeTolerance).rounded(.down) / fps
|
|
|
}
|
|
|
|
|
|
/// `m:ss:ff` — Minuten, Sekunden, Frame.
|
|
|
private func timecode(_ seconds: Double) -> String {
|
|
|
let fps = max(Int((info?.frameRate ?? 25).rounded()), 1)
|
|
|
let totalFrames = Int((max(seconds, 0) * Double(fps) + CropPlan.timeTolerance).rounded(.down))
|
|
|
let frames = totalFrames % fps
|
|
|
let totalSeconds = totalFrames / fps
|
|
|
return String(format: "%d:%02d:%02d", totalSeconds / 60, totalSeconds % 60, frames)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Zeitleiste mit Abspielkopf und Positionsmarken. Klicken oder Ziehen
|
|
|
/// spult; nahe an einer Marke rastet der Kopf auf ihr ein. Zeigt die Maus auf
|
|
|
/// eine Marke, erscheint darüber ein × zum Löschen; Klick auf die Marke
|
|
|
/// springt zu ihr, Rechtsklick bietet ebenfalls Löschen an.
|
|
|
private struct TimelineBar: View {
|
|
|
let duration: Double
|
|
|
let currentTime: Double
|
|
|
let keyframes: [CropPlan.Keyframe]
|
|
|
let activeIndex: Int
|
|
|
@Binding var hoveredID: CropPlan.Keyframe.ID?
|
|
|
let onScrub: (_ seconds: Double, _ isFinal: Bool) -> Void
|
|
|
let onDelete: (CropPlan.Keyframe.ID) -> Void
|
|
|
|
|
|
private let snapDistance: CGFloat = 6
|
|
|
private let height: CGFloat = 40
|
|
|
/// Höhe der Spur innerhalb der Leiste; darüber ist Platz für das ×.
|
|
|
private let trackY: CGFloat = 28
|
|
|
|
|
|
var body: some View {
|
|
|
GeometryReader { proxy in
|
|
|
let width = proxy.size.width
|
|
|
ZStack(alignment: .topLeading) {
|
|
|
// Spur und Scrub-Fläche
|
|
|
Rectangle()
|
|
|
.fill(Color.clear)
|
|
|
.contentShape(Rectangle())
|
|
|
.gesture(
|
|
|
DragGesture(minimumDistance: 0)
|
|
|
.onChanged { value in onScrub(time(at: value.location.x, width: width), false) }
|
|
|
.onEnded { value in onScrub(time(at: value.location.x, width: width), true) }
|
|
|
)
|
|
|
|
|
|
Capsule()
|
|
|
.fill(Color.secondary.opacity(0.25))
|
|
|
.frame(width: width, height: 4)
|
|
|
.position(x: width / 2, y: trackY)
|
|
|
.allowsHitTesting(false)
|
|
|
|
|
|
activeSegment(width: width)
|
|
|
|
|
|
Capsule()
|
|
|
.fill(Color.primary)
|
|
|
.frame(width: 2, height: 18)
|
|
|
.position(x: x(for: currentTime, width: width), y: trackY)
|
|
|
.allowsHitTesting(false)
|
|
|
|
|
|
ForEach(Array(keyframes.enumerated()), id: \.element.id) { index, keyframe in
|
|
|
marker(keyframe, index: index)
|
|
|
.position(x: x(for: keyframe.time, width: width), y: height / 2)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
.frame(height: height)
|
|
|
}
|
|
|
|
|
|
/// Marke samt Löschknopf darüber — beides eine Hover-Fläche, damit das ×
|
|
|
/// nicht verschwindet, sobald die Maus von der Raute zu ihm wandert.
|
|
|
private func marker(_ keyframe: CropPlan.Keyframe, index: Int) -> some View {
|
|
|
let isHovered = hoveredID == keyframe.id
|
|
|
let isDeletable = index > 0
|
|
|
return VStack(spacing: 0) {
|
|
|
Button { onDelete(keyframe.id) } label: {
|
|
|
Image(systemName: "xmark")
|
|
|
.font(.system(size: 7, weight: .bold))
|
|
|
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
|
|
|
.frame(width: 14, height: 14)
|
|
|
.background(Circle().fill(Color.primary))
|
|
|
}
|
|
|
.buttonStyle(.plain)
|
|
|
.opacity(isHovered && isDeletable ? 1 : 0)
|
|
|
.allowsHitTesting(isHovered && isDeletable)
|
|
|
.help("Marke löschen")
|
|
|
.frame(height: 18)
|
|
|
|
|
|
Rectangle()
|
|
|
.fill(index == activeIndex || isHovered ? Color.gold : Color.gold.opacity(0.6))
|
|
|
.frame(width: isHovered ? 11 : 9, height: isHovered ? 11 : 9)
|
|
|
.rotationEffect(.degrees(45))
|
|
|
.frame(width: 22, height: 22)
|
|
|
.contentShape(Rectangle())
|
|
|
.onTapGesture { onScrub(keyframe.time, true) }
|
|
|
}
|
|
|
.frame(width: 22, height: height)
|
|
|
.contentShape(Rectangle())
|
|
|
.onHover { inside in
|
|
|
if inside {
|
|
|
hoveredID = keyframe.id
|
|
|
} else if hoveredID == keyframe.id {
|
|
|
hoveredID = nil
|
|
|
}
|
|
|
}
|
|
|
.contextMenu {
|
|
|
Button("Zu dieser Marke springen") { onScrub(keyframe.time, true) }
|
|
|
if isDeletable {
|
|
|
Button("Marke löschen", role: .destructive) { onDelete(keyframe.id) }
|
|
|
}
|
|
|
}
|
|
|
.animation(.easeOut(duration: 0.12), value: isHovered)
|
|
|
}
|
|
|
|
|
|
private func activeSegment(width: CGFloat) -> some View {
|
|
|
let start = keyframes[activeIndex].time
|
|
|
let end = activeIndex + 1 < keyframes.count ? keyframes[activeIndex + 1].time : duration
|
|
|
let startX = x(for: start, width: width)
|
|
|
let segmentWidth = max(x(for: end, width: width) - startX, 0)
|
|
|
return Capsule()
|
|
|
.fill(Color.gold.opacity(0.55))
|
|
|
.frame(width: segmentWidth, height: 4)
|
|
|
.position(x: startX + segmentWidth / 2, y: trackY)
|
|
|
.allowsHitTesting(false)
|
|
|
}
|
|
|
|
|
|
private func x(for seconds: Double, width: CGFloat) -> CGFloat {
|
|
|
guard duration > 0 else { return 0 }
|
|
|
return CGFloat(min(max(seconds / duration, 0), 1)) * width
|
|
|
}
|
|
|
|
|
|
private func time(at x: CGFloat, width: CGFloat) -> Double {
|
|
|
if let snapped = keyframes.first(where: { abs(self.x(for: $0.time, width: width) - x) <= snapDistance }) {
|
|
|
return snapped.time
|
|
|
}
|
|
|
guard width > 0 else { return 0 }
|
|
|
return Double(min(max(x / width, 0), 1)) * duration
|
|
|
}
|
|
|
}
|