video: FrameSource/FrameSink seams, CPU grade processor, PPM still grabber w/ clamping, test pattern source + null sink — 5 tests
This commit is contained in:
parent
7b8563d02b
commit
bc90bfdf8e
4 changed files with 181 additions and 6 deletions
|
|
@ -1 +0,0 @@
|
|||
// ForgeVideo
|
||||
103
Sources/ForgeVideo/Video.swift
Normal file
103
Sources/ForgeVideo/Video.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeVideoPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
78
Tests/ForgeVideoTests/VideoTests.swift
Normal file
78
Tests/ForgeVideoTests/VideoTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue