@ -2,38 +2,84 @@ import AVKit
import DropTwoCore
import SwiftUI
// / S c h r i t t 5 : 9 : 1 6 - M a s t e r m i t e i n g e b l e n d e t e m 4 : 5 - R a h m e n z u r F r a m i n g - K o n t r o l l e .
// / S c r u b b a r ü b e r d i e n a t i v e n K o n t r o l l e n v o n ` P l a y e r V i e w ` ( A p p K i t s
// / ` A V P l a y e r V i e w ` , n i c h t A V K i t s S w i f t U I - ` V i d e o P l a y e r ` — s i e h e d o r t f ü r d e n
// / G r u n d ) . D e r R a h m e n n u t z t e x a k t d i e s e l b e G e o m e t r i e w i e
// / ` C r o p E n g i n e . t a r g e t S i z e ` — w a s h i e r z u s e h e n i s t , i s t t a t s ä c h l i c h d a s , w a s
// / b e i m C r o p r a u s k o m m t . R e i n z u r K o n t r o l l e : d e r O f f s e t i s t h i e r n o c h n i c h t
// / v e r s t e l l b a r ( R E A D M E , " D a n a c h " - L i s t e ) .
// / 9 : 1 6 - M a s t e r m i t e i n g e b l e n d e t e m 4 : 5 - R a h m e n — z u r F r a m i n g - K o n t r o l l e u n d z u m
// / V e r s c h i e b e n d e s A u s s c h n i t t s . D e r R a h m e n l ä s s t s i c h m i t d e r M a u s n a c h o b e n
// / o d e r u n t e n z i e h e n . F ü r C l i p s m i t S z e n e n w e c h s e l s e t z t m a n P o s i t i o n s m a r k e n :
// / a b j e d e r M a r k e g i l t e i n e e i g e n e R a h m e n p o s i t i o n ( ` C r o p P l a n ` ) .
// /
// / R a h m e n u n d R e n d e r n u t z e n d i e s e l b e G e o m e t r i e ( ` C r o p E n g i n e . c r o p R e c t ` ) — w a s
// / h i e r z u s e h e n i s t , i s t t a t s ä c h l i c h d a s , w a s b e i m C r o p r a u s k o m m t .
// /
// / E i g e n e T r a n s p o r t l e i s t e s t a t t d e r n a t i v e n ` A V P l a y e r V i e w ` - K o n t r o l l e n : d i e
// / l i e g e n i m B i l d u n d w ü r d e n m i t d e m Z i e h e n d e s R a h m e n s k o l l i d i e r e n , u n d
// / d i e M a r k e n m ü s s e n a u f d e r Z e i t l e i s t e s i c h t b a r s e i n .
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 videoSize: CGSize ?
@ 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 ?
// / O f f s e t b e i m B e g i n n e i n e s R a h m e n - D r a g s ; n i l = g e r a d e k e i n D r a g .
@ State private var dragStartOffset : CGFloat ?
// / M a r k e u n t e r d e m M a u s z e i g e r a u f d e r Z e i t l e i s t e .
@ 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 {
HStack ( spacing : 8 ) {
Image ( systemName : " crop " )
. foregroundStyle ( Color . gold )
Text ( sourceURL . lastPathComponent )
. font ( . headline )
. font ( . system( size : 14 , weight : . semibold ) )
. lineLimit ( 1 )
. truncationMode ( . middle )
Spacer ( )
Button ( " Schließen " ) { dismiss ( ) }
}
. 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 ( 16 )
. frame ( minWidth : 420 , minHeight : 520 )
. padding ( . top , 4 )
}
. padding ( 18 )
. frame ( minWidth : 500 , idealWidth : 540 , minHeight : 700 , idealHeight : 800 )
. task { await load ( ) }
. onDisappear ( perform : tearDown )
}
// MARK: - V o r s c h a u
@ ViewBuilder
private var content : some View {
if let loadError {
@ -44,50 +90,394 @@ struct ReviewSheet: View {
}
. foregroundStyle ( . secondary )
. frame ( maxWidth : . infinity , maxHeight : . infinity )
} else if let player , let videoSize, video Size. height > 0 {
} else if let player , let info, info . pixel Size. height > 0 {
GeometryReader { proxy in
ZStack {
PlayerView ( player : player )
cropOverlay ( sourceSize : videoSize , viewSize : proxy . size )
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 ( videoSize . width / videoSize . height , contentMode : . fit )
}
. 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 )
}
}
// / Z w e i a b g e d u n k e l t e B a l k e n o b e n / u n t e n m a r k i e r e n , w a s b e i m C r o p w e g f ä l l t ;
// / de r h e l l e R a h m e n z e i g t d i e b l e i b e n d e 4 : 5 - F l ä c h e — v o l l e B r e i t e ,
// / ve r t i k a l z e n t r i e r t , e x a k t w i e ` C r o p E n g i n e ` e s t a t s ä c h l i c h s c h n e i d e t .
// / A b g e d u n k e l t e B a l k e n o b e n / u n t e n m a r k i e r e n , w a s b e i m C r o p w e g f ä l l t ; d e r
// / go l d e n e R a h m e n z e i g t d i e b l e i b e n d e 4 : 5 - F l ä c h e an d e r P o s i t i o n , d i e
// / zu m a k t u e l l e n Z e i t p u n k t g i l t .
@ 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
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 : margin Height)
. frame ( height : top Height)
Rectangle ( )
. strokeBorder ( Color (red : 0.784 , green : 0.663 , blue : 0.416 ) , lineWidth : 2 )
. frame ( height : max ( viewSize . height - marginHeight * 2 , 0 ) )
. strokeBorder ( Color .gold , lineWidth : dragStartOffset = = nil ? 2 : 3 )
. frame ( height : cropHeight )
Rectangle ( )
. fill ( Color . black . opacity ( 0.55 ) )
. frame ( height : margin Height)
. frame ( height : botto mHeight)
}
. allowsHitTesting ( false )
}
// / Z i e h e n i r g e n d w o i m B i l d v e r s c h i e b t d e n R a h m e n d e s g e r a d e g e l t e n d e n
// / A b s c h n i t t s . D i e W i e d e r g a b e p a u s i e r t d a b e i , s o n s t w e c h s e l t d e r
// / A b s c h n i t t m i t t e n i m Z i e h e n .
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 }
// O f f s e t - 1 … 1 ü b e r s p a n n t d e n g e s a m t e n S p i e l r a u m , a l s o 2 E i n h e i t e n .
plan . setOffset ( start + value . translation . height / slackInView * 2 , at : currentTime )
}
. onEnded { _ in
dragStartOffset = nil
NSCursor . pop ( )
}
}
// MARK: - T r a n s p o r t
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) " )
// U n s i c h t b a r , n u r f ü r d i e T a s t e : l ö s c h t d i e M a r k e u n t e r d e m
// M a u s z e i g e r , s o n s t d i e a m A b s p i e l k o p f .
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 )
}
}
// / S e t z t d e n R a h m e n d e s g e r a d e g e l t e n d e n A b s c h n i t t s z u r ü c k i n d i e M i t t e .
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: - P l a y e r
private func load ( ) async {
do {
let info = try await VideoInspector . inspect ( sourceURL )
videoSize = info . pixelSize
player = AVPlayer ( url : sourceURL )
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 )
}
// / M a r k e a u f d e n B e g i n n d e s a n g e z e i g t e n F r a m e s l e g e n , d a m i t g e n a u d i e s e r
// / F r a m e s c h o n d i e n e u e P o s i t i o n b e k o m m t .
private func addMarker ( ) {
pause ( )
plan . addKeyframe ( at : displayedFrameTime ( currentTime ) )
}
// / S t a r t z e i t d e s F r a m e s , d e r b e i ` s e c o n d s ` z u s e h e n i s t .
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 : s s : f f ` — M i n u t e n , S e k u n d e n , F r a m e .
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 )
}
}
// / Z e i t l e i s t e m i t A b s p i e l k o p f u n d P o s i t i o n s m a r k e n . K l i c k e n o d e r Z i e h e n
// / s p u l t ; n a h e a n e i n e r M a r k e r a s t e t d e r K o p f a u f i h r e i n . Z e i g t d i e M a u s a u f
// / e i n e M a r k e , e r s c h e i n t d a r ü b e r e i n × z u m L ö s c h e n ; K l i c k a u f d i e M a r k e
// / s p r i n g t z u i h r , R e c h t s k l i c k b i e t e t e b e n f a l l s L ö s c h e n a n .
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 ö h e d e r S p u r i n n e r h a l b d e r L e i s t e ; d a r ü b e r i s t P l a t z f ü r d a s × .
private let trackY : CGFloat = 28
var body : some View {
GeometryReader { proxy in
let width = proxy . size . width
ZStack ( alignment : . topLeading ) {
// S p u r u n d S c r u b - F l ä c h e
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 )
}
// / M a r k e s a m t L ö s c h k n o p f d a r ü b e r — b e i d e s e i n e H o v e r - F l ä c h e , d a m i t d a s ×
// / n i c h t v e r s c h w i n d e t , s o b a l d d i e M a u s v o n d e r R a u t e z u i h m w a n d e r t .
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
}
}