From bc90bfdf8ee4926be2b30424f18e115e5bfe10c0 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:07:44 +0000 Subject: [PATCH] =?UTF-8?q?video:=20FrameSource/FrameSink=20seams,=20CPU?= =?UTF-8?q?=20grade=20processor,=20PPM=20still=20grabber=20w/=20clamping,?= =?UTF-8?q?=20test=20pattern=20source=20+=20null=20sink=20=E2=80=94=205=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeVideo/ForgeVideo.swift | 1 - Sources/ForgeVideo/Video.swift | 103 +++++++++++++++++++ Tests/ForgeVideoTests/PlaceholderTests.swift | 5 - Tests/ForgeVideoTests/VideoTests.swift | 78 ++++++++++++++ 4 files changed, 181 insertions(+), 6 deletions(-) delete mode 100644 Sources/ForgeVideo/ForgeVideo.swift create mode 100644 Sources/ForgeVideo/Video.swift delete mode 100644 Tests/ForgeVideoTests/PlaceholderTests.swift create mode 100644 Tests/ForgeVideoTests/VideoTests.swift diff --git a/Sources/ForgeVideo/ForgeVideo.swift b/Sources/ForgeVideo/ForgeVideo.swift deleted file mode 100644 index 1b1e587..0000000 --- a/Sources/ForgeVideo/ForgeVideo.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeVideo diff --git a/Sources/ForgeVideo/Video.swift b/Sources/ForgeVideo/Video.swift new file mode 100644 index 0000000..2649c36 --- /dev/null +++ b/Sources/ForgeVideo/Video.swift @@ -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] + public var timecode: String? + + public init(width: Int, height: Int, pixels: [SIMD3], 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 + private var frameCount = 0 + + public init(width: Int, height: Int, color: SIMD3) { + 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](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 + } +} diff --git a/Tests/ForgeVideoTests/PlaceholderTests.swift b/Tests/ForgeVideoTests/PlaceholderTests.swift deleted file mode 100644 index de3d311..0000000 --- a/Tests/ForgeVideoTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeVideoPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -} diff --git a/Tests/ForgeVideoTests/VideoTests.swift b/Tests/ForgeVideoTests/VideoTests.swift new file mode 100644 index 0000000..66b9e58 --- /dev/null +++ b/Tests/ForgeVideoTests/VideoTests.swift @@ -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) + } +}