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