Zum Inhalt

apple/PhysarumApp/Tests/VideoPipelineIntegrationTests.swift

// apple/PhysarumApp/Tests/VideoPipelineIntegrationTests.swift
//
// Integration test: exercises the real AVAssetWriter + CVMetalTextureCache render-into-
// pixel-buffer path on the host GPU.  The whole point is to verify that the
// pixel-buffer-backed MTLTexture supports .renderTarget usage and that each frame
// actually makes it through the hardware encode pipeline.

import XCTest
import AVFoundation
import Metal
@testable import Physarum
import PhysarumEngine

final class VideoPipelineIntegrationTests: XCTestCase {

    // MARK: - shared helpers

    /// Returns (device, library, renderer, sim, preset) or nil when no Metal GPU.
    private func makeStack(fieldSize: Int = 128) throws -> (MTLDevice, ShaderLibrary, Renderer, SimEngine, PresetParams)? {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }
        let preset = AppState().edited
        let lib = ShaderLibrary(device: device)
        let renderer = try Renderer(device: device, library: lib, width: fieldSize, height: fieldSize)
        let sim = try SimEngine(device: device, library: lib,
                                width: fieldSize, height: fieldSize,
                                preset: preset)
        return (device, lib, renderer, sim, preset)
    }

    /// Warm up the feedback buffer so early frames are not blank.
    private func warmUp(renderer: Renderer, sim: SimEngine, preset: PresetParams, steps: Int = 10) throws {
        renderer.resetFeedback()
        for _ in 0..<steps {
            sim.step()
            try renderer.renderOffscreen(sim: sim, preset: preset)
        }
    }

    // MARK: - HEVC (.mov) test

    func testVideoPipelineHEVC() async throws {
        guard let (device, _, renderer, sim, preset) = try makeStack() else {
            throw XCTSkip("No Metal device — skipping GPU pipeline test")
        }

        try warmUp(renderer: renderer, sim: sim, preset: preset)

        let outputURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("pipeline-test-hevc-\(UUID().uuidString).mov")

        let recorder = VideoClipRecorder(
            device: device,
            url: outputURL,
            width: 256, height: 256,
            fps: 60,
            codec: .hevc,
            renderInto: { tex in
                sim.step()
                try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: true)
            }
        )

        let resultURL = try await runClip(
            frameCount: 12,
            fps: 60,
            recorder: recorder,
            renderFrame: { _ in },
            isCancelled: { false },
            progress: { _ in }
        )

        defer { try? FileManager.default.removeItem(at: resultURL) }

        // File must exist and be non-trivial.
        XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path),
                      "Output file should exist at \(resultURL.path)")
        let attrs = try FileManager.default.attributesOfItem(atPath: resultURL.path)
        let fileSize = (attrs[.size] as? Int) ?? 0
        XCTAssertGreaterThan(fileSize, 2000,
                             "HEVC output (\(fileSize) bytes) should be non-trivial")

        // Verify AVFoundation metadata: video track + dimensions + duration.
        let asset = AVURLAsset(url: resultURL)
        let tracks = try await asset.loadTracks(withMediaType: .video)
        let track = try XCTUnwrap(tracks.first, "Expected at least one video track")
        let naturalSize = try await track.load(.naturalSize)
        // Allow either dimension order (portrait vs landscape orientation tag).
        let dims = Set([Int(naturalSize.width), Int(naturalSize.height)])
        XCTAssertTrue(dims.contains(256),
                      "naturalSize \(naturalSize) should be 256×256")

        let duration = try await asset.load(.duration)
        let seconds = CMTimeGetSeconds(duration)
        // 12 frames at 60 fps = 0.2 s; allow generous tolerance.
        XCTAssertEqual(seconds, 0.2, accuracy: 0.15,
                       "HEVC clip duration \(seconds)s should be ≈0.2s (12/60)")
    }

    // MARK: - H.264 (.mp4) test

    func testVideoPipelineH264() async throws {
        guard let (device, _, renderer, sim, preset) = try makeStack() else {
            throw XCTSkip("No Metal device — skipping GPU pipeline test")
        }

        try warmUp(renderer: renderer, sim: sim, preset: preset, steps: 6)

        let outputURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("pipeline-test-h264-\(UUID().uuidString).mp4")

        let recorder = VideoClipRecorder(
            device: device,
            url: outputURL,
            width: 256, height: 256,
            fps: 60,
            codec: .h264,
            renderInto: { tex in
                sim.step()
                try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: true)
            }
        )

        let resultURL = try await runClip(
            frameCount: 6,
            fps: 60,
            recorder: recorder,
            renderFrame: { _ in },
            isCancelled: { false },
            progress: { _ in }
        )

        defer { try? FileManager.default.removeItem(at: resultURL) }

        XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path),
                      "Output file should exist at \(resultURL.path)")
        let attrs = try FileManager.default.attributesOfItem(atPath: resultURL.path)
        let fileSize = (attrs[.size] as? Int) ?? 0
        XCTAssertGreaterThan(fileSize, 2000,
                             "H.264 output (\(fileSize) bytes) should be non-trivial")

        let asset = AVURLAsset(url: resultURL)
        let tracks = try await asset.loadTracks(withMediaType: .video)
        let track = try XCTUnwrap(tracks.first, "Expected at least one video track")
        let naturalSize = try await track.load(.naturalSize)
        let dims = Set([Int(naturalSize.width), Int(naturalSize.height)])
        XCTAssertTrue(dims.contains(256),
                      "naturalSize \(naturalSize) should be 256×256")

        let duration = try await asset.load(.duration)
        let seconds = CMTimeGetSeconds(duration)
        // 6 frames at 60 fps = 0.1 s; allow generous tolerance.
        XCTAssertEqual(seconds, 0.1, accuracy: 0.15,
                       "H.264 clip duration \(seconds)s should be ≈0.1s (6/60)")
    }
}