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 — PPM (raw) or PNG (report/browser-friendly), both dependency-free. public struct StillGrabber: Sendable { public enum Format: String, Sendable { case ppm case png } public let directory: String public let format: Format public init(directory: String, format: Format = .ppm) { self.directory = directory self.format = format } /// 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).\(format.rawValue)" let data: Data switch format { case .png: data = PNGWriter.encode(frame) case .ppm: var ppm = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8) ppm.reserveCapacity(ppm.count + frame.pixels.count * 3) for px in frame.pixels { ppm.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5)) ppm.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5)) ppm.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5)) } data = ppm } try data.write(to: URL(fileURLWithPath: path)) return path } }