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