rainbow-dragon/Sources/ForgeVideo/Video.swift

117 lines
3.6 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 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
}
}