|
|
import CoreMedia
|
|
|
import XCTest
|
|
|
@testable import DropTwoCore
|
|
|
|
|
|
final class CropEngineTests: XCTestCase {
|
|
|
private var tempDirectory: URL!
|
|
|
|
|
|
override func setUpWithError() throws {
|
|
|
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
|
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
|
|
}
|
|
|
|
|
|
override func tearDownWithError() throws {
|
|
|
try? FileManager.default.removeItem(at: tempDirectory)
|
|
|
}
|
|
|
|
|
|
// MARK: - Geometrie (rein arithmetisch, kein Video nötig)
|
|
|
|
|
|
func testTargetSizeMatchesReadmeExamples() {
|
|
|
XCTAssertEqual(
|
|
|
CropEngine.targetSize(forSourceSize: CGSize(width: 1080, height: 1920)),
|
|
|
CGSize(width: 1080, height: 1350)
|
|
|
)
|
|
|
XCTAssertEqual(
|
|
|
CropEngine.targetSize(forSourceSize: CGSize(width: 2160, height: 3840)),
|
|
|
CGSize(width: 2160, height: 2700)
|
|
|
)
|
|
|
}
|
|
|
|
|
|
func testTargetSizeRoundsHeightToEvenNumber() {
|
|
|
let size = CropEngine.targetSize(forSourceSize: CGSize(width: 217, height: 386))
|
|
|
|
|
|
XCTAssertEqual(size.width, 217)
|
|
|
XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0)
|
|
|
}
|
|
|
|
|
|
func testCropRectMapsOffsetToTopCenterBottom() {
|
|
|
let source = CGSize(width: 1080, height: 1920)
|
|
|
|
|
|
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: -1).minY, 0)
|
|
|
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 0).minY, 286)
|
|
|
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 1).maxY, 1920)
|
|
|
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 5).maxY, 1920)
|
|
|
}
|
|
|
|
|
|
func testCropRectTopEdgeIsEven() {
|
|
|
let rect = CropEngine.cropRect(forSourceSize: CGSize(width: 1080, height: 1920), offset: 0.3333)
|
|
|
|
|
|
XCTAssertEqual(rect.minY.truncatingRemainder(dividingBy: 2), 0)
|
|
|
}
|
|
|
|
|
|
// MARK: - End-to-End gegen einen synthetischen Clip
|
|
|
|
|
|
/// Quelle: obere Hälfte weiß, untere schwarz. Ausschnitt ganz oben muss
|
|
|
/// also deutlich heller sein als ganz unten — prüft die Richtung des
|
|
|
/// Offsets im tatsächlichen Render, nicht nur in der Geometrie.
|
|
|
func testOffsetMovesRenderedCropUpAndDown() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
|
|
|
let top = tempDirectory.appendingPathComponent("top.mp4")
|
|
|
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
|
|
|
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
|
|
|
|
|
|
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
|
|
|
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
|
|
|
// 384er Quelle, 270er Ausschnitt: oben 192/270 ≈ 0.71 weiß, unten 78/270 ≈ 0.29.
|
|
|
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
|
|
|
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
|
|
|
}
|
|
|
|
|
|
/// iPhone-Fall: Container quer (384×216), per 90°-Transformation hochkant.
|
|
|
/// Links im gespeicherten Bild ist nach der Drehung oben — der Ausschnitt
|
|
|
/// "ganz oben" muss also hell sein, und das Ergebnis darf nicht quer sein.
|
|
|
func testRotatedSourceIsCroppedUpright() async throws {
|
|
|
let clip = try TestVideoFactory.makeClip(
|
|
|
size: CGSize(width: 384, height: 216),
|
|
|
includeAudio: false,
|
|
|
pattern: .brightLeftDarkRight,
|
|
|
transform: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 216, ty: 0),
|
|
|
in: tempDirectory
|
|
|
)
|
|
|
let top = tempDirectory.appendingPathComponent("top.mp4")
|
|
|
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
|
|
|
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
|
|
|
|
|
|
let resultInfo = try await VideoInspector.inspect(top)
|
|
|
XCTAssertEqual(resultInfo.pixelSize, CGSize(width: 216, height: 270))
|
|
|
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
|
|
|
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
|
|
|
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
|
|
|
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
|
|
|
}
|
|
|
|
|
|
func testOrientationFromTrackTransform() {
|
|
|
XCTAssertEqual(CropEngine.orientation(for: .identity), .up)
|
|
|
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 1080, ty: 0)), .right)
|
|
|
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 1920)), .left)
|
|
|
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 1080, ty: 1920)), .down)
|
|
|
}
|
|
|
|
|
|
func testKeyframeSwitchesCropMidClip() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
var plan = CropPlan(offset: -1)
|
|
|
plan.addKeyframe(at: 1)
|
|
|
plan.setOffset(1, at: 1)
|
|
|
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination, options: .init(cropPlan: plan))
|
|
|
|
|
|
let before = try await TestVideoFactory.averageBrightness(of: destination, at: 0.5)
|
|
|
let after = try await TestVideoFactory.averageBrightness(of: destination, at: 1.5)
|
|
|
XCTAssertEqual(before, 0.71, accuracy: 0.08)
|
|
|
XCTAssertEqual(after, 0.29, accuracy: 0.08)
|
|
|
}
|
|
|
|
|
|
func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
|
|
|
let resultInfo = try await VideoInspector.inspect(destination)
|
|
|
let expectedSize = CropEngine.targetSize(forSourceSize: clip.size)
|
|
|
XCTAssertEqual(resultInfo.pixelSize, expectedSize)
|
|
|
XCTAssertEqual(resultInfo.codec, .h264)
|
|
|
XCTAssertTrue(resultInfo.hasAudioTrack)
|
|
|
XCTAssertEqual(CMTimeGetSeconds(resultInfo.duration), clip.seconds, accuracy: 0.3)
|
|
|
}
|
|
|
|
|
|
func testCropsWithoutAudioTrack() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
|
|
|
let resultInfo = try await VideoInspector.inspect(destination)
|
|
|
XCTAssertFalse(resultInfo.hasAudioTrack)
|
|
|
}
|
|
|
|
|
|
func testReportsProgress() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
|
|
|
var reportedValues: [Double] = []
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) { value in
|
|
|
reportedValues.append(value)
|
|
|
}
|
|
|
|
|
|
XCTAssertFalse(reportedValues.isEmpty)
|
|
|
XCTAssertEqual(reportedValues.last ?? 0, 1.0, accuracy: 0.15)
|
|
|
}
|
|
|
|
|
|
// MARK: - Ablehnungen
|
|
|
|
|
|
func testRejectsNonNineBySixteenSource() async throws {
|
|
|
let clip = try TestVideoFactory.makeClip(size: CGSize(width: 400, height: 400), in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
|
|
|
do {
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
XCTFail("Erwartete Ablehnung für eine Nicht-9:16-Quelle")
|
|
|
} catch CropEngineError.sourceNotNineBySixteen {
|
|
|
// erwartet
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func testRejectsExistingDestinationWithoutOverwrite() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
FileManager.default.createFile(atPath: destination.path, contents: Data())
|
|
|
|
|
|
do {
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
XCTFail("Erwartete Ablehnung wegen bestehender Zieldatei")
|
|
|
} catch CropEngineError.destinationAlreadyExists {
|
|
|
// erwartet
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func testOverwritesExistingDestinationWhenExplicitlyAllowed() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mp4")
|
|
|
FileManager.default.createFile(atPath: destination.path, contents: Data("platzhalter".utf8))
|
|
|
|
|
|
try await CropEngine.crop(
|
|
|
sourceURL: clip.url,
|
|
|
destinationURL: destination,
|
|
|
options: .init(overwrite: true)
|
|
|
)
|
|
|
|
|
|
let resultInfo = try await VideoInspector.inspect(destination)
|
|
|
XCTAssertEqual(resultInfo.pixelSize, CropEngine.targetSize(forSourceSize: clip.size))
|
|
|
}
|
|
|
|
|
|
func testRejectsUnsupportedContainer() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mxf")
|
|
|
|
|
|
do {
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
XCTFail("Erwartete Ablehnung für ein MXF-Ziel")
|
|
|
} catch CropEngineError.unsupportedContainer {
|
|
|
// erwartet
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func testRejectsMovAsDestinationBecauseTargetIsFixedToMp4() async throws {
|
|
|
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
|
|
|
let destination = tempDirectory.appendingPathComponent("out.mov")
|
|
|
|
|
|
do {
|
|
|
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination)
|
|
|
XCTFail("Erwartete Ablehnung für ein MOV-Ziel — Zielformat ist fest auf MP4 gesetzt")
|
|
|
} catch CropEngineError.unsupportedContainer {
|
|
|
// erwartet
|
|
|
}
|
|
|
}
|
|
|
}
|