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.") } }