- 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