- CropPlan: Ausschnitt-Offset pro Datei, mit Marken ab Zeitpunkt X (harte Schnitte). CropEngine schneidet pro Frame nach Plan; cropRect ist die gemeinsame Geometrie für Vorschau und Render. - CropEngine wendet die Track-Transformation an: iPhone-Clips (quer gespeichert, per 90°-Drehung hochkant) wurden bisher seitlich und mit schwarzem Balken gerendert. - FileNameParser erkennt 9x16/9X16/9×16, getrennt mit -, _ oder Leerzeichen, Trenner bleiben erhalten; Rest hinter dem Format optional. appendingTargetFormat hängt bei fehlendem Format "4x5" an. - ConversionPipeline/JobQueue: appendFormatWhenMissing und CropPlan pro Job. - Tests: Render-Richtung, Marken mitten im Clip, gedrehte Quelle, Parser- Schreibweisen, Anhängen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShKCZzkdZhym6mHuPqXQATmain
parent
b12cdb2cd1
commit
8547621cac
@ -0,0 +1,94 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import XCTest
|
||||
@testable import DropTwoCore
|
||||
|
||||
final class CropPlanTests: XCTestCase {
|
||||
func testDefaultPlanIsCenteredWithSingleKeyframe() {
|
||||
let plan = CropPlan()
|
||||
|
||||
XCTAssertTrue(plan.isDefault)
|
||||
XCTAssertEqual(plan.keyframes.count, 1)
|
||||
XCTAssertEqual(plan.offset(at: 0), 0)
|
||||
XCTAssertEqual(plan.offset(at: 100), 0)
|
||||
}
|
||||
|
||||
func testOffsetIsClamped() {
|
||||
XCTAssertEqual(CropPlan(offset: 3).offset(at: 0), 1)
|
||||
XCTAssertEqual(CropPlan(offset: -3).offset(at: 0), -1)
|
||||
}
|
||||
|
||||
func testKeyframeHoldsUntilNextOne() {
|
||||
var plan = CropPlan(offset: -1)
|
||||
plan.addKeyframe(at: 2)
|
||||
plan.setOffset(1, at: 2)
|
||||
|
||||
XCTAssertEqual(plan.offset(at: 0), -1)
|
||||
XCTAssertEqual(plan.offset(at: 1.99), -1)
|
||||
XCTAssertEqual(plan.offset(at: 2), 1)
|
||||
XCTAssertEqual(plan.offset(at: 10), 1)
|
||||
XCTAssertFalse(plan.isDefault)
|
||||
}
|
||||
|
||||
func testNewKeyframeTakesOverCurrentOffset() {
|
||||
var plan = CropPlan(offset: 0.4)
|
||||
let keyframe = plan.addKeyframe(at: 3)
|
||||
|
||||
XCTAssertEqual(keyframe?.offset, 0.4)
|
||||
}
|
||||
|
||||
func testKeyframeAtExistingTimeIsIgnored() {
|
||||
var plan = CropPlan()
|
||||
plan.addKeyframe(at: 2)
|
||||
|
||||
XCTAssertNil(plan.addKeyframe(at: 2.0005))
|
||||
XCTAssertNil(plan.addKeyframe(at: 0))
|
||||
XCTAssertEqual(plan.keyframes.count, 2)
|
||||
}
|
||||
|
||||
func testKeyframesStaySorted() {
|
||||
var plan = CropPlan()
|
||||
plan.addKeyframe(at: 5)
|
||||
plan.addKeyframe(at: 1)
|
||||
plan.addKeyframe(at: 3)
|
||||
|
||||
XCTAssertEqual(plan.keyframes.map(\.time), [0, 1, 3, 5])
|
||||
}
|
||||
|
||||
func testFirstKeyframeCannotBeRemoved() {
|
||||
var plan = CropPlan()
|
||||
let second = plan.addKeyframe(at: 2)!
|
||||
|
||||
plan.removeKeyframe(id: plan.keyframes[0].id)
|
||||
XCTAssertEqual(plan.keyframes.count, 2)
|
||||
|
||||
plan.removeKeyframe(id: second.id)
|
||||
XCTAssertEqual(plan.keyframes.count, 1)
|
||||
}
|
||||
|
||||
func testInitFromKeyframesPullsEarliestToZero() {
|
||||
let plan = CropPlan(keyframes: [
|
||||
.init(time: 4, offset: 1),
|
||||
.init(time: 1.5, offset: -1)
|
||||
])
|
||||
|
||||
XCTAssertEqual(plan.keyframes.map(\.time), [0, 4])
|
||||
XCTAssertEqual(plan.offset(at: 0), -1)
|
||||
}
|
||||
|
||||
func testToleranceAbsorbsPlayerRoundingNoise() {
|
||||
var plan = CropPlan()
|
||||
plan.addKeyframe(at: 1.0)
|
||||
plan.setOffset(1, at: 1.0)
|
||||
|
||||
// Frame-PTS minimal vor der Marke, wie es aus CMTime-Rundung kommt.
|
||||
XCTAssertEqual(plan.offset(at: 0.9999), 1)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue