Files
GrowthLapse/Tests/Integration/RenderIntegration.swift
T

386 lines
24 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_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")
print("All integration checks passed.")
}
}