You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

484 lines
19 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
}
}

Powered by TurnKey Linux.