439 lines
28 KiB
Swift
439 lines
28 KiB
Swift
import Foundation
|
|
|
|
private enum TestFailure: Error { case failed(String) }
|
|
|
|
@main
|
|
struct RenderIntegration {
|
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
|
guard condition() else { throw TestFailure.failed(message) }
|
|
}
|
|
|
|
@MainActor
|
|
static func wait(_ processor: VideoProcessor) async throws {
|
|
let deadline = Date().addingTimeInterval(60)
|
|
while processor.state.isRunning, Date() < deadline {
|
|
try await Task.sleep(nanoseconds: 20_000_000)
|
|
}
|
|
if processor.state.isRunning { processor.cancel() }
|
|
guard case .finished = processor.state else {
|
|
throw TestFailure.failed("Render: \(processor.state)\n\(processor.logs.suffix(15).joined(separator: "\n"))")
|
|
}
|
|
}
|
|
|
|
static func probe(_ url: URL, tools: FFmpegService, ffprobe: URL) async throws -> [String: Any] {
|
|
let result = try await tools.run(executable: ffprobe,
|
|
arguments: ["-v", "error", "-show_streams", "-show_format", "-of", "json", url.path])
|
|
try require(result.exitCode == 0, "ffprobe failed")
|
|
return try JSONSerialization.jsonObject(with: Data(result.output.utf8)) as! [String: Any]
|
|
}
|
|
|
|
@MainActor
|
|
static func main() async throws {
|
|
if let path = ProcessInfo.processInfo.environment["GROWTHLAPSE_VALIDATE_PROJECT"] {
|
|
let validator = VideoProcessor()
|
|
guard let project = validator.loadProject(from: URL(fileURLWithPath: path)) else {
|
|
throw TestFailure.failed("Real project validation failed: \(validator.state)")
|
|
}
|
|
print("PASS real project load: \(project.clips.count) clips")
|
|
if ProcessInfo.processInfo.environment["GROWTHLAPSE_TEST_QUALITY"] == "1" {
|
|
let original = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
var subset = project
|
|
let prefixes = ["035", "062", "184"]
|
|
subset.clips = project.clips.filter { c in prefixes.contains { c.displayName.hasPrefix($0) } }
|
|
try require(subset.clips.count == 3, "Expected three visual regression clips")
|
|
validator.project = subset
|
|
var settings = SettingsStore.load()
|
|
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("GrowthLapse-quality-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
settings.outputFile = folder.appendingPathComponent("Vergleich-neu.mp4")
|
|
settings.keepIntermediateClips = true
|
|
settings.keepRenderCacheForReview = true
|
|
validator.rerenderProject(settings: settings)
|
|
let deadline = Date().addingTimeInterval(300)
|
|
while validator.state.isRunning && Date() < deadline { try await Task.sleep(nanoseconds: 100_000_000) }
|
|
guard case .finished(let output) = validator.state else {
|
|
validator.cancel()
|
|
throw TestFailure.failed("Quality render: \(validator.state)\n\(validator.logs.suffix(25).joined(separator: "\n"))")
|
|
}
|
|
let after = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
try require(original == after, "Quality check changed user's project")
|
|
print("PASS real full-resolution quality render: \(output.path)")
|
|
return
|
|
}
|
|
if ProcessInfo.processInfo.environment["GROWTHLAPSE_TEST_REAL_DRAFT"] == "1" {
|
|
let original = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
var subset = project
|
|
subset.clips = project.clips.filter { $0.displayName.hasPrefix("063") || $0.displayName.hasPrefix("066") }
|
|
try require(subset.clips.count == 2, "Expected two selected regression clips")
|
|
validator.project = subset
|
|
validator.startDraft(settings: SettingsStore.load())
|
|
let deadline = Date().addingTimeInterval(180)
|
|
while validator.state.isRunning && Date() < deadline {
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
guard let draft = validator.draftOutput else {
|
|
validator.cancel()
|
|
throw TestFailure.failed("Real draft failed: \(validator.state)\n\(validator.logs.suffix(25).joined(separator: "\n"))")
|
|
}
|
|
try require(FileManager.default.fileExists(atPath: draft.path), "Real draft missing")
|
|
let after = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
try require(original == after, "Real draft changed source project")
|
|
print("PASS actual affected clips rendered as isolated draft: \(draft.path)")
|
|
validator.discardDraft()
|
|
return
|
|
}
|
|
}
|
|
try ContinuityChecks.run()
|
|
let defaults = RenderSettings()
|
|
try require(defaults.inputFolder == nil && defaults.outputFile == nil
|
|
&& defaults.ffmpegExecutable == nil && defaults.ffprobeExecutable == nil, "Machine-specific defaults")
|
|
var custom = defaults
|
|
custom.segmentLength = 12
|
|
custom.inputFolder = URL(fileURLWithPath: "/tmp/Meine Videos")
|
|
let encoded = try JSONEncoder().encode(PersistedRenderSettings(settings: custom))
|
|
let restored = try JSONDecoder().decode(PersistedRenderSettings.self, from: encoded)
|
|
try require(restored.renderSettings == custom, "Settings round-trip")
|
|
var older = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
|
|
for key in ["renderMode", "duplicateClipSelection", "keepRenderCacheForReview", "burnInClipNumbers", "bestSegmentDetectionEnabled"] {
|
|
older.removeValue(forKey: key)
|
|
}
|
|
let migrated = try JSONDecoder().decode(PersistedRenderSettings.self,
|
|
from: JSONSerialization.data(withJSONObject: older)).renderSettings
|
|
try require(migrated.duplicateClipSelection == .first && migrated.keepRenderCacheForReview
|
|
&& !migrated.burnInClipNumbers && !migrated.bestSegmentDetectionEnabled, "Old settings migration")
|
|
print("PASS neutral defaults, settings round-trip and legacy migration")
|
|
let manager = FileManager.default
|
|
let root = manager.temporaryDirectory.resolvingSymlinksInPath()
|
|
.appendingPathComponent("GrowthLapse-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try manager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer {
|
|
if ProcessInfo.processInfo.environment["GROWTHLAPSE_KEEP_TEST_OUTPUT"] == "1" {
|
|
print("Testdateien: \(root.path)")
|
|
} else {
|
|
try? manager.removeItem(at: root)
|
|
}
|
|
}
|
|
let input = root.appendingPathComponent("input", isDirectory: true)
|
|
try manager.createDirectory(at: input, withIntermediateDirectories: true)
|
|
let service = FFmpegService()
|
|
let tools = try await service.validateTools()
|
|
|
|
// Pipe output must be complete even when the child exits immediately.
|
|
for _ in 0..<20 {
|
|
let result = try await service.run(executable: URL(fileURLWithPath: "/usr/bin/printf"),
|
|
arguments: ["Grüße 🌱\nEND\n"])
|
|
try require(result.output == "Grüße 🌱\nEND\n", "Truncated process output")
|
|
}
|
|
let sleeping = Task { try await service.run(executable: URL(fileURLWithPath: "/bin/sleep"), arguments: ["10"]) }
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
let cancellationStart = Date()
|
|
sleeping.cancel()
|
|
do { _ = try await sleeping.value; throw TestFailure.failed("Process ignored cancellation") }
|
|
catch is CancellationError {}
|
|
try require(Date().timeIntervalSince(cancellationStart) < 2, "Slow process cancellation")
|
|
print("PASS process output and cancellation")
|
|
|
|
let analysis = Task {
|
|
try await cancellableAnalysis {
|
|
while true { try Task.checkCancellation(); Thread.sleep(forTimeInterval: 0.01) }
|
|
}
|
|
}
|
|
analysis.cancel()
|
|
do { _ = try await analysis.value; throw TestFailure.failed("Analysis ignored cancellation") }
|
|
catch is CancellationError {}
|
|
print("PASS analysis cancellation forwarding")
|
|
|
|
for (name, color, audio) in [("Z 1 Wochen alt V1.mp4", "red", true), ("A 2 Wochen alt V1.mp4", "blue", false)] {
|
|
var args = ["-v", "error", "-y", "-f", "lavfi", "-i", "color=c=\(color):s=160x120:r=10:d=4"]
|
|
if audio { args += ["-f", "lavfi", "-i", "sine=frequency=440:duration=4", "-c:a", "aac"] }
|
|
args += ["-c:v", "libx264", "-pix_fmt", "yuv420p", input.appendingPathComponent(name).path]
|
|
let result = try await service.run(executable: tools.ffmpeg, arguments: args)
|
|
try require(result.exitCode == 0, "Fixture generation: \(result.output)")
|
|
}
|
|
|
|
var settings = RenderSettings()
|
|
settings.inputFolder = input
|
|
settings.outputFile = root.appendingPathComponent("A.mp4")
|
|
settings.ffmpegExecutable = tools.ffmpeg
|
|
settings.ffprobeExecutable = tools.ffprobe
|
|
settings.videoEncoder = .x264
|
|
settings.outputFormat = .keepOriginal
|
|
settings.segmentLength = 2
|
|
settings.targetClipLength = 1
|
|
settings.transitionLength = 0
|
|
settings.fps = 10
|
|
settings.bestSegmentDetectionEnabled = false
|
|
settings.faceNormalizationEnabled = false
|
|
settings.stabilizationEnabled = false
|
|
settings.removeAudio = true
|
|
settings.startOffset = 0
|
|
let processor = VideoProcessor()
|
|
processor.start(settings: settings)
|
|
try await wait(processor)
|
|
let first = processor.project!
|
|
try require(first.clips.count == 2, "Clip discovery")
|
|
try require(RenderWorkspace.isOwned(first.cacheDirectoryURL, by: first.outputFileURL), "Cache ownership")
|
|
let originalOutput = try Data(contentsOf: first.outputFileURL)
|
|
let metadata = try await probe(first.outputFileURL, tools: service, ffprobe: tools.ffprobe)
|
|
let streams = metadata["streams"] as! [[String: Any]]
|
|
try require(streams.count == 1 && streams[0]["width"] as? Int == 160, "Silent native-size render")
|
|
print("PASS two-clip render, ownership, dimensions and audio removal")
|
|
|
|
let draftSourceState = processor.project!
|
|
let finalBytesBeforeDraft = try Data(contentsOf: first.outputFileURL)
|
|
let finalJSONBeforeDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
|
|
var draftSettings = settings
|
|
draftSettings.outputFormat = .portrait1080x1920
|
|
processor.startDraft(settings: draftSettings)
|
|
let draftDeadline = Date().addingTimeInterval(60)
|
|
while processor.state.isRunning && Date() < draftDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
try require(processor.draftOutput != nil && !processor.state.isRunning, "Draft failed: \(processor.state)")
|
|
try require(processor.project == draftSourceState, "Draft modified the working project")
|
|
let finalBytesAfterDraft = try Data(contentsOf: first.outputFileURL)
|
|
let finalJSONAfterDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
|
|
try require(finalBytesBeforeDraft == finalBytesAfterDraft && finalJSONBeforeDraft == finalJSONAfterDraft, "Draft replaced final artifacts")
|
|
let draftMetadata = try await probe(processor.draftOutput!, tools: service, ffprobe: tools.ffprobe)
|
|
let draftStreams = draftMetadata["streams"] as! [[String: Any]]
|
|
let draftVideo = draftStreams.first { $0["codec_type"] as? String == "video" }!
|
|
try require(draftVideo["width"] as? Int == 360 && draftVideo["height"] as? Int == 640, "Draft dimensions")
|
|
let draftURL = processor.draftOutput!
|
|
processor.discardDraft()
|
|
try require(!manager.fileExists(atPath: draftURL.path), "Draft cleanup failed")
|
|
print("PASS portrait draft 360x640 preserves final output, project and cache; cleanup removes only draft")
|
|
|
|
let analysisOnly = VideoProcessor()
|
|
var analysisSettings = settings
|
|
analysisSettings.outputFile = root.appendingPathComponent("Analysis-only.mp4")
|
|
analysisOnly.analyze(settings: analysisSettings)
|
|
let analysisDeadline = Date().addingTimeInterval(60)
|
|
while analysisOnly.state.isRunning && Date() < analysisDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
try require(analysisOnly.state == .idle && analysisOnly.project?.clips.count == 2, "Analysis-only failed")
|
|
try require(!manager.fileExists(atPath: analysisSettings.outputFile!.path), "Analysis created a final video")
|
|
try require(analysisOnly.project!.clips.allSatisfy { !($0.review?.messages.isEmpty ?? true) }, "Missing-face warnings absent")
|
|
print("PASS pre-render analysis and missing-face diagnostics without producing final video")
|
|
|
|
|
|
let alias = root.appendingPathComponent("GrowthLapse-alias.growthlapse-cache")
|
|
try manager.createSymbolicLink(at: alias, withDestinationURL: first.cacheDirectoryURL)
|
|
try require(!RenderWorkspace.isOwned(alias, by: first.outputFileURL), "Symlink cache accepted")
|
|
var sourceTarget = settings
|
|
sourceTarget.outputFile = first.clips[0].sourceURL
|
|
let sourceBytes = try Data(contentsOf: first.clips[0].sourceURL)
|
|
let protect = VideoProcessor()
|
|
protect.start(settings: sourceTarget)
|
|
while protect.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
guard case .failed = protect.state else { throw TestFailure.failed("Source overwrite permitted") }
|
|
let protectedBytes = try Data(contentsOf: first.clips[0].sourceURL)
|
|
try require(sourceBytes == protectedBytes, "Source was modified")
|
|
print("PASS symlink deletion and accidental source overwrite blocked")
|
|
|
|
// A failed rerender must leave all previous work byte-for-byte intact.
|
|
var invalid = settings
|
|
invalid.outputFile = root.appendingPathComponent("missing/B.mp4")
|
|
processor.rerenderProject(settings: invalid)
|
|
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
guard case .failed = processor.state else { throw TestFailure.failed("Invalid destination accepted") }
|
|
let afterFailure = try Data(contentsOf: first.outputFileURL)
|
|
try require(afterFailure == originalOutput, "Existing output changed on failure")
|
|
try require(manager.fileExists(atPath: first.clips[0].normalizedClipPath), "Old cache lost")
|
|
|
|
settings.outputFile = root.appendingPathComponent("B.mp4")
|
|
processor.rerenderProject(settings: settings)
|
|
try await wait(processor)
|
|
let second = processor.project!
|
|
try require(second.outputFileURL == settings.outputFile, "Project output not migrated")
|
|
try require(second.cacheDirectoryPath != first.cacheDirectoryPath, "Cache not isolated")
|
|
try require(processor.logs.contains(where: { $0.contains("Cache verwendet") }), "Cache reuse missing")
|
|
try require(manager.fileExists(atPath: first.clips[0].normalizedClipPath), "Save-as deleted A cache")
|
|
try require(second.settings?.renderSettings == settings, "Project settings not saved")
|
|
let loaded = VideoProcessor()
|
|
try require(loaded.loadProject(from: GrowthLapseProject.url(for: second.outputFileURL)) != nil, "Project reload")
|
|
print("PASS failure preservation, save-as, cache reuse and project reload")
|
|
|
|
// Vision's single-precision ±pi must survive project validation unchanged.
|
|
var angleProject = second
|
|
let visionPi = 3.1415927410125732 // Exact value observed in Vision JSON; not Swift Float.pi
|
|
angleProject.clips[0].visualAnalysis = ClipVisualAnalysis(sourceSignature: "angle-test", samples: [
|
|
VisualSample(time: 0, faceCount: 1,
|
|
pose: FacePose(x: 0.5, y: 0.5, height: 0.3, yaw: visionPi,
|
|
roll: -visionPi, eyesVisible: true, pitch: visionPi), color: nil, quality: 0)
|
|
])
|
|
let angleURL = root.appendingPathComponent("vision-angles.json")
|
|
try JSONEncoder().encode(angleProject).write(to: angleURL)
|
|
let angleLoader = VideoProcessor()
|
|
let accepted = angleLoader.loadProject(from: angleURL)
|
|
try require(accepted?.clips[0].visualAnalysis == angleProject.clips[0].visualAnalysis,
|
|
"Valid Vision Float.pi angles rejected or altered")
|
|
angleProject.clips[0].visualAnalysis!.samples[0].pose!.roll = Double.pi + 0.00001
|
|
try JSONEncoder().encode(angleProject).write(to: angleURL)
|
|
try require(angleLoader.loadProject(from: angleURL) == nil, "Out-of-range angle accepted")
|
|
print("PASS Vision exact boundary project round-trip and invalid-angle rejection")
|
|
|
|
// Exercise failure after normalization, while FFmpeg is producing the final video.
|
|
let wrapper = root.appendingPathComponent("ffmpeg-fail")
|
|
let quotedFFmpeg = "'" + tools.ffmpeg.path.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
let wrapperText = "#!/bin/bash\nfor arg in \"$@\"; do\n if [ \"$arg\" = -filter_complex ]; then exit 42; fi\ndone\nexec \(quotedFFmpeg) \"$@\"\n"
|
|
try wrapperText.write(to: wrapper, atomically: true, encoding: .utf8)
|
|
try manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path)
|
|
var failingSettings = settings
|
|
failingSettings.ffmpegExecutable = wrapper
|
|
failingSettings.transitionLength = 0.2
|
|
let priorVideo = try Data(contentsOf: second.outputFileURL)
|
|
let priorProject = try Data(contentsOf: GrowthLapseProject.url(for: second.outputFileURL))
|
|
processor.rerenderProject(settings: failingSettings)
|
|
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
guard case .failed = processor.state else { throw TestFailure.failed("End-encoder failure not surfaced") }
|
|
let failedVideo = try Data(contentsOf: second.outputFileURL)
|
|
let failedProject = try Data(contentsOf: GrowthLapseProject.url(for: second.outputFileURL))
|
|
try require(failedVideo == priorVideo && failedProject == priorProject, "End-encoder failure damaged saved work")
|
|
print("PASS end-encoder failure preserves video and project")
|
|
|
|
let transaction = try RenderWorkspace.create(for: root.appendingPathComponent("transaction.mp4"))
|
|
let firstStaged = transaction.appendingPathComponent("first")
|
|
let secondStaged = transaction.appendingPathComponent("second")
|
|
try Data("new".utf8).write(to: firstStaged)
|
|
try Data("new".utf8).write(to: secondStaged)
|
|
do {
|
|
try RenderWorkspace.publish([(firstStaged, second.outputFileURL),
|
|
(secondStaged, root.appendingPathComponent("missing/project.json"))], in: transaction)
|
|
throw TestFailure.failed("Publish unexpectedly succeeded")
|
|
} catch is POSIXError {}
|
|
let rolledBack = try Data(contentsOf: second.outputFileURL)
|
|
try require(rolledBack == priorVideo, "Publication rollback failed")
|
|
print("PASS publication rollback after partial publish")
|
|
|
|
// Normal rerender preserves manually selected segments; explicit planning applies new lengths.
|
|
settings.segmentLength = 1
|
|
processor.rerenderProject(settings: settings)
|
|
try await wait(processor)
|
|
try require(processor.project!.clips[0].segmentLength == 2, "Manual segment unexpectedly reset")
|
|
processor.rerenderProject(settings: settings, replanSegments: true)
|
|
try await wait(processor)
|
|
try require(processor.project!.clips.allSatisfy { $0.segmentLength == 1 }, "Replan ignored new length")
|
|
processor.reorderProject(sortMode: .filenameAscending)
|
|
try require(processor.project!.clips.allSatisfy(\.needsRender), "Reorder did not invalidate clips")
|
|
print("PASS explicit segment planning and reordered-cache invalidation")
|
|
|
|
settings.removeAudio = false
|
|
settings.transitionLength = 0.2
|
|
processor.rerenderProject(settings: settings)
|
|
try await wait(processor)
|
|
let mixed = try await probe(settings.outputFile!, tools: service, ffprobe: tools.ffprobe)
|
|
let mixedStreams = mixed["streams"] as! [[String: Any]]
|
|
let duration = Double((mixed["format"] as! [String: Any])["duration"] as! String)!
|
|
try require(mixedStreams.contains { $0["codec_type"] as? String == "audio" }, "Missing mixed audio")
|
|
try require(abs(duration - 1.8) < 0.25, "Crossfade duration \(duration)")
|
|
print("PASS crossfade and mixed source audio (\(duration)s)")
|
|
|
|
let beforeStopProject = processor.project!
|
|
let beforeStopOutput = try Data(contentsOf: beforeStopProject.outputFileURL)
|
|
let slowText = wrapperText.replacingOccurrences(of: "exit 42", with: "exec /bin/sleep 10")
|
|
try slowText.write(to: wrapper, atomically: true, encoding: .utf8)
|
|
failingSettings = settings
|
|
failingSettings.ffmpegExecutable = wrapper
|
|
processor.rerenderProject(settings: failingSettings)
|
|
let stopDeadline = Date().addingTimeInterval(10)
|
|
while processor.state.isRunning, processor.progressText != "Erzeuge Übergänge", Date() < stopDeadline {
|
|
try await Task.sleep(nanoseconds: 20_000_000)
|
|
}
|
|
try require(processor.progressText == "Erzeuge Übergänge", "Never reached final export")
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
processor.cancel()
|
|
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
let afterStopOutput = try Data(contentsOf: beforeStopProject.outputFileURL)
|
|
try require(processor.state == .cancelled && afterStopOutput == beforeStopOutput, "Active cancellation damaged output")
|
|
try require(manager.fileExists(atPath: beforeStopProject.clips[0].normalizedClipPath), "Active cancellation removed old cache")
|
|
print("PASS cancellation during final export preserves previous output and cache")
|
|
|
|
let changedSource = input.appendingPathComponent("Z 1 Wochen alt V1.mp4")
|
|
let replacement = try await service.run(executable: tools.ffmpeg, arguments: ["-v", "error", "-y",
|
|
"-f", "lavfi", "-i", "color=c=green:s=200x150:r=10:d=1.5", "-c:v", "libx264", changedSource.path])
|
|
try require(replacement.exitCode == 0, "Replacement fixture failed")
|
|
processor.rerenderProject(settings: settings)
|
|
try await wait(processor)
|
|
guard let refreshed = processor.project!.clips.first(where: {
|
|
$0.sourceURL.resolvingSymlinksInPath().standardizedFileURL.path == changedSource.resolvingSymlinksInPath().standardizedFileURL.path
|
|
}) else { throw TestFailure.failed("Changed source missing from project") }
|
|
try require(refreshed.width == 200 && abs(refreshed.duration - 1.5) < 0.1, "Source metadata was not refreshed")
|
|
try require(refreshed.segmentStart + refreshed.segmentLength <= refreshed.duration + 0.001, "Changed source segment out of bounds")
|
|
print("PASS externally changed source invalidates cache and refreshes metadata")
|
|
|
|
let soloFolder = root.appendingPathComponent("solo", isDirectory: true)
|
|
try manager.createDirectory(at: soloFolder, withIntermediateDirectories: true)
|
|
try manager.copyItem(at: input.appendingPathComponent("A 2 Wochen alt V1.mp4"), to: soloFolder.appendingPathComponent("solo.mp4"))
|
|
var soloSettings = settings
|
|
soloSettings.inputFolder = soloFolder
|
|
soloSettings.outputFile = root.appendingPathComponent("Solo.mp4")
|
|
soloSettings.faceNormalizationEnabled = true
|
|
soloSettings.bestSegmentDetectionEnabled = true
|
|
soloSettings.stabilizationStrength = .light
|
|
let availability = try await service.availableFilters(ffmpeg: tools.ffmpeg)
|
|
soloSettings.stabilizationEnabled = availability.hasVidstab || availability.hasDeshake
|
|
let solo = VideoProcessor()
|
|
solo.start(settings: soloSettings)
|
|
try await wait(solo)
|
|
try require(solo.project!.clips.count == 1, "Single clip failed")
|
|
print("PASS single clip, Vision fallback and available stabilization (enabled: \(soloSettings.stabilizationEnabled))")
|
|
|
|
// A loaded JSON must not authorize recursive deletion of an arbitrary folder.
|
|
var tampered = processor.project!
|
|
let protected = root.appendingPathComponent("protected", isDirectory: true)
|
|
try manager.createDirectory(at: protected, withIntermediateDirectories: true)
|
|
let marker = protected.appendingPathComponent("keep.txt")
|
|
try Data("keep".utf8).write(to: marker)
|
|
tampered.cacheDirectoryPath = protected.path
|
|
for index in tampered.clips.indices {
|
|
tampered.clips[index].normalizedClipPath = protected.appendingPathComponent("clip-\(index).mp4").path
|
|
}
|
|
let tamperedURL = root.appendingPathComponent("tampered.json")
|
|
try JSONEncoder().encode(tampered).write(to: tamperedURL)
|
|
_ = loaded.loadProject(from: tamperedURL)
|
|
loaded.confirmProjectAndDeleteCache()
|
|
try require(manager.fileExists(atPath: marker.path), "Unsafe cache deletion")
|
|
tampered.clips = []
|
|
try JSONEncoder().encode(tampered).write(to: tamperedURL)
|
|
try require(loaded.loadProject(from: tamperedURL) == nil, "Empty project accepted")
|
|
print("PASS unsafe deletion blocked and empty project rejected")
|
|
|
|
let goodProject = processor.project!
|
|
processor.rerenderProject(settings: settings)
|
|
processor.cancel()
|
|
while processor.state.isRunning { try await Task.sleep(nanoseconds: 20_000_000) }
|
|
try require(processor.state == .cancelled, "Immediate cancellation failed")
|
|
try require(manager.fileExists(atPath: goodProject.clips[0].normalizedClipPath), "Cancellation destroyed prior cache")
|
|
processor.confirmProjectAndDeleteCache()
|
|
try require(!manager.fileExists(atPath: goodProject.cacheDirectoryPath), "Owned cache was not deleted")
|
|
try require(manager.fileExists(atPath: goodProject.outputFilePath), "Confirm deleted output")
|
|
print("PASS cancelled rerender preserves old cache and confirm only deletes owned cache")
|
|
// HDR must become SDR before the first stabilization intermediate.
|
|
let hdrInput = root.appendingPathComponent("hdr", isDirectory: true)
|
|
try manager.createDirectory(at: hdrInput, withIntermediateDirectories: true)
|
|
let hdrSource = hdrInput.appendingPathComponent("hdr.mkv")
|
|
let hdrFixture = try await service.run(executable: tools.ffmpeg, arguments: [
|
|
"-v", "error", "-y", "-f", "lavfi", "-i", "color=c=gray:s=320x240:r=15:d=1",
|
|
"-pix_fmt", "yuv420p10le", "-c:v", "ffv1", "-color_primaries", "bt2020",
|
|
"-color_trc", "arib-std-b67", "-colorspace", "bt2020nc", hdrSource.path])
|
|
try require(hdrFixture.exitCode == 0, "HDR fixture generation failed")
|
|
var hdrSettings = RenderSettings()
|
|
hdrSettings.inputFolder = hdrInput
|
|
hdrSettings.outputFile = root.appendingPathComponent("HDR.mp4")
|
|
hdrSettings.outputFormat = .keepOriginal
|
|
hdrSettings.videoEncoder = .x264
|
|
hdrSettings.segmentLength = 1
|
|
hdrSettings.targetClipLength = 1
|
|
hdrSettings.transitionLength = 0
|
|
hdrSettings.stabilizationEnabled = true
|
|
hdrSettings.keepIntermediateClips = true
|
|
let hdrProcessor = VideoProcessor()
|
|
hdrProcessor.start(settings: hdrSettings)
|
|
try await wait(hdrProcessor)
|
|
let intermediate = hdrProcessor.project!.cacheDirectoryURL.appendingPathComponent("segment_0001.mp4")
|
|
let hdrProbe = try await probe(intermediate, tools: service, ffprobe: tools.ffprobe)
|
|
let hdrStreams = hdrProbe["streams"] as! [[String: Any]]
|
|
try require(hdrStreams[0]["color_transfer"] as? String == "bt709"
|
|
&& hdrStreams[0]["color_primaries"] as? String == "bt709", "HDR persisted into 8-bit stabilization intermediate")
|
|
print("PASS 10-bit HLG source converted to SDR before stabilization encoding")
|
|
print("All integration checks passed.")
|
|
}
|
|
}
|