video: FrameSource/FrameSink seams, CPU grade processor, PPM still grabber w/ clamping, test pattern source + null sink — 5 tests

This commit is contained in:
Forge Dev 2026-07-10 20:07:44 +00:00
parent 7b8563d02b
commit bc90bfdf8e
4 changed files with 181 additions and 6 deletions

View file

@ -1 +0,0 @@
// ForgeVideo

View file

@ -0,0 +1,103 @@
import Foundation
import ForgeGrade
import ForgeColor
/// One video frame: linear float RGB pixels, row-major.
public struct VideoFrame: Sendable {
public let width: Int
public let height: Int
public var pixels: [SIMD3<Float>]
public var timecode: String?
public init(width: Int, height: Int, pixels: [SIMD3<Float>], timecode: String? = nil) {
precondition(pixels.count == width * height)
self.width = width
self.height = height
self.pixels = pixels
self.timecode = timecode
}
}
/// Video input seam. DeckLink capture implements this on macOS.
public protocol FrameSource: Actor {
func nextFrame() async throws -> VideoFrame
}
/// Video output seam. DeckLink playback implements this on macOS.
public protocol FrameSink: Actor {
func consume(frame: VideoFrame) async throws
}
/// Solid-color generator for tests/demo.
public actor TestPatternSource: FrameSource {
private let width: Int
private let height: Int
private let color: SIMD3<Float>
private var frameCount = 0
public init(width: Int, height: Int, color: SIMD3<Float>) {
self.width = width
self.height = height
self.color = color
}
public func nextFrame() async throws -> VideoFrame {
frameCount += 1
let f = frameCount % 24
let s = (frameCount / 24) % 60
return VideoFrame(
width: width,
height: height,
pixels: [SIMD3<Float>](repeating: color, count: width * height),
timecode: String(format: "00:00:%02d:%02d", s, f))
}
}
/// Discards frames; counts consumption. SDI-out stand-in for tests.
public actor NullFrameSink: FrameSink {
public private(set) var consumedCount = 0
public init() {}
public func consume(frame: VideoFrame) async throws {
consumedCount += 1
}
}
/// CPU grade application (preview path stand-in; Metal on macOS).
public enum FrameProcessor {
public static func apply(stack: GradeStack, to frame: VideoFrame) -> VideoFrame {
var out = frame
for i in out.pixels.indices {
out.pixels[i] = stack.evaluate(out.pixels[i])
}
return out
}
}
/// Writes frame grabs as binary PPM (P6) dependency-free still format.
public struct StillGrabber: Sendable {
public let directory: String
public init(directory: String) {
self.directory = directory
}
/// Returns written file path.
@discardableResult
public func grab(frame: VideoFrame, clipName: String) throws -> String {
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let path = "\(directory)/\(clipName)_\(timestamp).ppm"
var data = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8)
data.reserveCapacity(data.count + frame.pixels.count * 3)
for px in frame.pixels {
data.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5))
}
try data.write(to: URL(fileURLWithPath: path))
return path
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgeVideoPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}

View file

@ -0,0 +1,78 @@
import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgeVideo
final class VideoTests: XCTestCase {
// Solid-color test source produces frames of declared size.
func testTestPatternSource() async throws {
let source = TestPatternSource(width: 8, height: 4, color: SIMD3(0.5, 0.25, 0.75))
let frame = try await source.nextFrame()
XCTAssertEqual(frame.width, 8)
XCTAssertEqual(frame.height, 4)
XCTAssertEqual(frame.pixels.count, 8 * 4)
XCTAssertEqual(frame.pixels[0].x, 0.5, accuracy: 1e-6)
}
// Grading a frame applies the stack per pixel.
func testGradeFrame() async throws {
let source = TestPatternSource(width: 4, height: 2, color: SIMD3(0.25, 0.25, 0.25))
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)))])
let frame = try await source.nextFrame()
let graded = FrameProcessor.apply(stack: stack, to: frame)
XCTAssertEqual(graded.pixels[0].x, 0.5, accuracy: 1e-6)
XCTAssertEqual(graded.width, 4)
// Timecode preserved.
XCTAssertEqual(graded.timecode, frame.timecode)
}
// Still grab writes PPM (portable, no image lib dep) and returns path.
func testStillGrabWritesPPM() async throws {
let source = TestPatternSource(width: 4, height: 4, color: SIMD3(1, 0, 0))
let frame = try await source.nextFrame()
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
let grabber = StillGrabber(directory: dir)
let path = try grabber.grab(frame: frame, clipName: "A001C001")
defer { try? FileManager.default.removeItem(atPath: dir) }
XCTAssertTrue(FileManager.default.fileExists(atPath: path))
XCTAssertTrue(path.hasSuffix(".ppm"))
XCTAssertTrue(path.contains("A001C001"))
let data = try Data(contentsOf: URL(fileURLWithPath: path))
// Compare header bytes directly string decoding of Data slices is
// unreliable across Foundation implementations.
let expectedHeader = [UInt8]("P6\n4 4\n255\n".utf8)
XCTAssertEqual([UInt8](data.prefix(expectedHeader.count)), expectedHeader)
// First pixel red: 255 0 0.
let headerLen = "P6\n4 4\n255\n".utf8.count
XCTAssertEqual(data[headerLen], 255)
XCTAssertEqual(data[headerLen + 1], 0)
XCTAssertEqual(data[headerLen + 2], 0)
}
// Values clamp to 0-255 on write.
func testStillClampsRange() async throws {
let source = TestPatternSource(width: 2, height: 2, color: SIMD3(2.0, -0.5, 0.5))
let frame = try await source.nextFrame()
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = try StillGrabber(directory: dir).grab(frame: frame, clipName: "clamp")
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let headerLen = "P6\n2 2\n255\n".utf8.count
XCTAssertEqual(data[headerLen], 255) // 2.0 -> 255
XCTAssertEqual(data[headerLen + 1], 0) // -0.5 -> 0
XCTAssertEqual(data[headerLen + 2], 128) // 0.5 -> 128 (rounded)
}
// Null sink accepts frames without error (SDI-out stand-in).
func testNullSink() async throws {
let sink = NullFrameSink()
let source = TestPatternSource(width: 2, height: 2, color: .zero)
try await sink.consume(frame: try await source.nextFrame())
let count = await sink.consumedCount
XCTAssertEqual(count, 1)
}
}