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 } }