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.

95 lines
3.6 KiB

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

Powered by TurnKey Linux.