import CoreGraphics import Foundation /// Wo der 4:5-Ausschnitt über die Laufzeit eines Clips sitzt. Eine Liste von /// Positionsmarken: jede Marke gilt ab ihrem Zeitpunkt bis zur nächsten — /// harte Schnitte, keine Kamerafahrt dazwischen. Gedacht für Clips mit ein, /// zwei Szenen, in denen das Framing jeweils anders sitzen muss. /// /// Invariante: Es gibt immer mindestens eine Marke, und die erste liegt bei 0. /// Ein Plan mit genau einer Marke ist damit schlicht ein fester Offset für /// die ganze Datei. public struct CropPlan: Equatable, Sendable { public struct Keyframe: Equatable, Sendable, Identifiable { public let id: UUID /// Sekunden ab Clip-Beginn. public var time: Double /// -1 = Ausschnitt ganz oben, 0 = mittig, 1 = ganz unten. public var offset: CGFloat public init(id: UUID = UUID(), time: Double, offset: CGFloat) { self.id = id self.time = time self.offset = offset } } /// Zwei Marken näher beieinander als das gelten als dieselbe Stelle. /// Fängt auch Rundungsrauschen zwischen Player-Zeit und Frame-PTS ab. public static let timeTolerance: Double = 0.001 public private(set) var keyframes: [Keyframe] public init(offset: CGFloat = 0) { keyframes = [Keyframe(time: 0, offset: Self.clamp(offset))] } /// Baut einen Plan aus beliebigen Marken: sortiert, Offsets geklemmt, die /// früheste Marke auf 0 gezogen. Leere Liste = mittig. public init(keyframes: [Keyframe]) { var sorted = keyframes .map { Keyframe(id: $0.id, time: max($0.time, 0), offset: Self.clamp($0.offset)) } .sorted { $0.time < $1.time } if sorted.isEmpty { sorted = [Keyframe(time: 0, offset: 0)] } sorted[0].time = 0 self.keyframes = sorted } /// true = mittig, keine Marken — also genau das bisherige Verhalten. public var isDefault: Bool { keyframes.count == 1 && keyframes[0].offset == 0 } /// Index der Marke, die zum Zeitpunkt `seconds` gilt. public func keyframeIndex(at seconds: Double) -> Int { keyframes.lastIndex { $0.time <= seconds + Self.timeTolerance } ?? 0 } public func offset(at seconds: Double) -> CGFloat { keyframes[keyframeIndex(at: seconds)].offset } /// Verschiebt den Ausschnitt der Marke, die zum Zeitpunkt `seconds` gilt — /// also des ganzen Abschnitts bis zur nächsten Marke. public mutating func setOffset(_ offset: CGFloat, at seconds: Double) { keyframes[keyframeIndex(at: seconds)].offset = Self.clamp(offset) } /// Setzt eine neue Marke bei `seconds`; sie übernimmt den Offset, der dort /// bisher galt, damit sich am Bild erst mal nichts ändert. Liegt dort schon /// eine Marke, passiert nichts. @discardableResult public mutating func addKeyframe(at seconds: Double) -> Keyframe? { let time = max(seconds, 0) guard !keyframes.contains(where: { abs($0.time - time) <= Self.timeTolerance }) else { return nil } let keyframe = Keyframe(time: time, offset: offset(at: time)) keyframes.append(keyframe) keyframes.sort { $0.time < $1.time } return keyframe } /// Die erste Marke (bei 0) bleibt immer erhalten. public mutating func removeKeyframe(id: Keyframe.ID) { guard let index = keyframes.firstIndex(where: { $0.id == id }), index > 0 else { return } keyframes.remove(at: index) } private static func clamp(_ offset: CGFloat) -> CGFloat { min(max(offset, -1), 1) } }