103 lines
3.2 KiB
Swift
103 lines
3.2 KiB
Swift
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
|
|
}
|
|
}
|