- NDIReceiver: per-source receive thread, IOSurface pixel buffers, watchdog reconnect - VideoOutput: borderless output window above menu bar, AVSampleBufferVideoRenderer, 50 Hz display mode for app lifetime, display sleep assertion - Screen cards with drag and drop, live fps and frame loss - Per-minute stats CSV in ~/Library/Logs/Musubi for long-run tests - --route launch argument for scripted tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
84d9a392db
commit
37f46791e4
@ -0,0 +1,156 @@
|
||||
import AppKit
|
||||
import MusubiCore
|
||||
import NDIKit
|
||||
import Observation
|
||||
import VideoOutput
|
||||
|
||||
/// A connected screen as shown in the UI.
|
||||
struct ScreenInfo: Identifiable, Equatable {
|
||||
let id: CGDirectDisplayID
|
||||
let name: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
var refreshRate: Double
|
||||
}
|
||||
|
||||
/// One source shown fullscreen on one screen.
|
||||
@MainActor
|
||||
final class ActiveOutput {
|
||||
let source: Source
|
||||
let receiver: NDIReceiver
|
||||
let window: OutputWindow
|
||||
let displayID: CGDirectDisplayID
|
||||
let originalMode: CGDisplayMode?
|
||||
var snapshot: NDIReceiver.Snapshot
|
||||
|
||||
init(source: Source, receiver: NDIReceiver, window: OutputWindow, displayID: CGDirectDisplayID, originalMode: CGDisplayMode?) {
|
||||
self.source = source
|
||||
self.receiver = receiver
|
||||
self.window = window
|
||||
self.displayID = displayID
|
||||
self.originalMode = originalMode
|
||||
self.snapshot = receiver.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Observable
|
||||
final class OutputController {
|
||||
static let targetRefreshRate = 50.0
|
||||
|
||||
private(set) var screens: [ScreenInfo] = []
|
||||
private(set) var outputs: [CGDirectDisplayID: ActiveOutput] = [:]
|
||||
/// Bumped once per second so views re-read the receiver snapshots.
|
||||
private(set) var tick = 0
|
||||
|
||||
private var sleepBlocker: SleepBlocker?
|
||||
private var timer: Timer?
|
||||
private let recorder = StatsRecorder()
|
||||
|
||||
var isSleepBlocked: Bool { sleepBlocker != nil }
|
||||
|
||||
init() {
|
||||
refreshScreens()
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.screensChanged() }
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.updateSnapshots() }
|
||||
}
|
||||
}
|
||||
|
||||
func show(_ source: Source, on displayID: CGDirectDisplayID) {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return }
|
||||
stop(displayID)
|
||||
|
||||
let window = OutputWindow(screen: screen)
|
||||
let sink = window.sink
|
||||
let receiverName = "Musubi \(screen.localizedName)"
|
||||
guard let receiver = NDIReceiver(source: source, receiverName: receiverName, onFrame: { sink.display($0) }) else {
|
||||
Log.ndi.error("Could not create receiver for \(source.ndiName, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
let mode = DisplayModeSwitcher.setRefreshRate(Self.targetRefreshRate, on: displayID)
|
||||
window.onCancel = { [weak self] in self?.stop(displayID) }
|
||||
window.setFrame(screen.frame, display: true)
|
||||
window.orderFrontRegardless()
|
||||
|
||||
outputs[displayID] = ActiveOutput(source: source, receiver: receiver, window: window, displayID: displayID, originalMode: mode.original)
|
||||
Log.ndi.info("Output \(source.ndiName, privacy: .public) → \(screen.localizedName, privacy: .public) @ \(mode.refreshRate) Hz")
|
||||
updateSleepBlocker()
|
||||
refreshScreens()
|
||||
}
|
||||
|
||||
func stop(_ displayID: CGDirectDisplayID) {
|
||||
guard let output = outputs.removeValue(forKey: displayID) else { return }
|
||||
output.receiver.stop()
|
||||
output.window.orderOut(nil)
|
||||
if let original = output.originalMode {
|
||||
DisplayModeSwitcher.restore(original, on: displayID)
|
||||
}
|
||||
Log.ndi.info("Output stopped on display \(displayID)")
|
||||
updateSleepBlocker()
|
||||
refreshScreens()
|
||||
}
|
||||
|
||||
/// Applies `--route "<NDI name>=<screen index>"` launch arguments.
|
||||
func applyLaunchRoutes(_ arguments: [String] = ProcessInfo.processInfo.arguments) {
|
||||
for route in RouteArgument.parse(arguments) {
|
||||
guard route.screenIndex < NSScreen.screens.count else {
|
||||
Log.ndi.error("--route: no screen \(route.screenIndex)")
|
||||
continue
|
||||
}
|
||||
show(Source(ndiName: route.ndiName), on: NSScreen.screens[route.screenIndex].displayID)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSnapshots() {
|
||||
for output in outputs.values {
|
||||
output.snapshot = output.receiver.snapshot
|
||||
}
|
||||
recorder.recordIfDue(outputs: Array(outputs.values), screens: screens)
|
||||
tick += 1
|
||||
}
|
||||
|
||||
private func updateSleepBlocker() {
|
||||
if outputs.isEmpty {
|
||||
sleepBlocker = nil
|
||||
} else if sleepBlocker == nil {
|
||||
sleepBlocker = SleepBlocker(reason: "Musubi: Ausgabe aktiv")
|
||||
}
|
||||
}
|
||||
|
||||
private func screensChanged() {
|
||||
let connected = Set(NSScreen.screens.map(\.displayID))
|
||||
for displayID in outputs.keys where !connected.contains(displayID) {
|
||||
Log.ndi.notice("Display \(displayID) disconnected, stopping its output")
|
||||
stop(displayID)
|
||||
}
|
||||
// Keep windows covering their screen after resolution changes.
|
||||
for output in outputs.values {
|
||||
if let screen = NSScreen.screens.first(where: { $0.displayID == output.displayID }) {
|
||||
output.window.setFrame(screen.frame, display: true)
|
||||
}
|
||||
}
|
||||
refreshScreens()
|
||||
}
|
||||
|
||||
private func refreshScreens() {
|
||||
screens = NSScreen.screens.map { screen in
|
||||
let displayID = screen.displayID
|
||||
return ScreenInfo(
|
||||
id: displayID,
|
||||
name: screen.localizedName,
|
||||
width: Int(CGDisplayPixelsWide(displayID)),
|
||||
height: Int(CGDisplayPixelsHigh(displayID)),
|
||||
refreshRate: DisplayModeSwitcher.currentRefreshRate(of: displayID)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSScreen {
|
||||
var displayID: CGDirectDisplayID {
|
||||
(deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
import NDIKit
|
||||
|
||||
/// Appends one CSV line per output and minute to ~/Library/Logs/Musubi/stats-<date>.csv (for long-run tests).
|
||||
@MainActor
|
||||
final class StatsRecorder {
|
||||
private let interval: TimeInterval = 60
|
||||
private var lastWrite = Date()
|
||||
private var lastCPU = StatsRecorder.cpuTime()
|
||||
private var lastReceived: [CGDirectDisplayID: Int64] = [:]
|
||||
private let directory = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Logs/Musubi")
|
||||
|
||||
private static let header = "time,display,refresh_hz,source,state,width,height,source_fps,shown_fps,received,missing,dropped_by_ndi,loss_rate,cpu_percent\n"
|
||||
|
||||
func recordIfDue(outputs: [ActiveOutput], screens: [ScreenInfo]) {
|
||||
let now = Date()
|
||||
let elapsed = now.timeIntervalSince(lastWrite)
|
||||
guard elapsed >= interval else { return }
|
||||
|
||||
let cpu = Self.cpuTime()
|
||||
let cpuPercent = (cpu - lastCPU) / elapsed * 100
|
||||
lastCPU = cpu
|
||||
lastWrite = now
|
||||
guard !outputs.isEmpty else { return }
|
||||
|
||||
let time = now.ISO8601Format()
|
||||
var lines = ""
|
||||
for output in outputs {
|
||||
let s = output.snapshot
|
||||
let previous = lastReceived[output.displayID] ?? s.stats.received
|
||||
lastReceived[output.displayID] = s.stats.received
|
||||
let shownFPS = Double(s.stats.received - previous) / elapsed
|
||||
let refresh = screens.first { $0.id == output.displayID }?.refreshRate ?? 0
|
||||
let state: String = switch s.state {
|
||||
case .connecting: "connecting"
|
||||
case .live: "live"
|
||||
case .stalled: "stalled"
|
||||
}
|
||||
let name = output.source.ndiName.replacingOccurrences(of: "\"", with: "'")
|
||||
lines += "\(time),\(output.displayID),\(refresh),\"\(name)\",\(state),\(s.width),\(s.height),"
|
||||
lines += String(format: "%.2f,%.2f,%lld,%lld,%lld,%.6f,%.1f\n",
|
||||
s.frameRate, shownFPS, s.stats.received, s.stats.missing, s.stats.droppedByReceiver,
|
||||
s.stats.lossRate, cpuPercent)
|
||||
}
|
||||
append(lines, date: now)
|
||||
}
|
||||
|
||||
private func append(_ text: String, date: Date) {
|
||||
let day = date.formatted(.iso8601.year().month().day())
|
||||
let file = directory.appending(path: "stats-\(day).csv")
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
if !FileManager.default.fileExists(atPath: file.path) {
|
||||
try Self.header.write(to: file, atomically: true, encoding: .utf8)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: file)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: Data(text.utf8))
|
||||
} catch {
|
||||
Log.ndi.error("Stats CSV: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// User + system CPU seconds of this process.
|
||||
private static func cpuTime() -> Double {
|
||||
var usage = rusage()
|
||||
getrusage(RUSAGE_SELF, &usage)
|
||||
func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 }
|
||||
return seconds(usage.ru_utime) + seconds(usage.ru_stime)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
/// Decides when a receiver counts as stalled and when to try reconnecting.
|
||||
public struct ConnectionWatchdog: Sendable, Equatable {
|
||||
public enum State: Equatable, Sendable {
|
||||
case connecting
|
||||
case live
|
||||
/// No frames for a while. `retryIn` is the time until the next reconnect attempt.
|
||||
case stalled(retryIn: Double)
|
||||
}
|
||||
|
||||
public var stallAfter: Double
|
||||
public var retryInterval: Double
|
||||
|
||||
private var lastFrame: Double?
|
||||
private var lastRetry: Double?
|
||||
private let startedAt: Double
|
||||
|
||||
public init(now: Double, stallAfter: Double = 2, retryInterval: Double = 3) {
|
||||
self.startedAt = now
|
||||
self.stallAfter = stallAfter
|
||||
self.retryInterval = retryInterval
|
||||
}
|
||||
|
||||
public mutating func frameArrived(at now: Double) {
|
||||
lastFrame = now
|
||||
lastRetry = nil
|
||||
}
|
||||
|
||||
public func state(at now: Double) -> State {
|
||||
let reference = lastFrame ?? startedAt
|
||||
guard now - reference >= stallAfter else { return lastFrame == nil ? .connecting : .live }
|
||||
let since = lastRetry ?? reference + stallAfter
|
||||
return .stalled(retryIn: max(0, retryInterval - (now - since)))
|
||||
}
|
||||
|
||||
/// Returns true when a reconnect should be attempted now, and records the attempt.
|
||||
public mutating func shouldRetry(at now: Double) -> Bool {
|
||||
guard case .stalled(let retryIn) = state(at: now), retryIn == 0 else { return false }
|
||||
lastRetry = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/// A display mode reduced to what matters for picking one. Mirrors CGDisplayMode.
|
||||
public struct DisplayModeInfo: Hashable, Sendable {
|
||||
public var width: Int // points
|
||||
public var height: Int
|
||||
public var pixelWidth: Int
|
||||
public var pixelHeight: Int
|
||||
public var refreshRate: Double // Hz, 0 for some built-in panels
|
||||
|
||||
public init(width: Int, height: Int, pixelWidth: Int, pixelHeight: Int, refreshRate: Double) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.pixelWidth = pixelWidth
|
||||
self.pixelHeight = pixelHeight
|
||||
self.refreshRate = refreshRate
|
||||
}
|
||||
|
||||
public var isHiDPI: Bool { pixelWidth > width }
|
||||
}
|
||||
|
||||
public enum DisplayModeSelection {
|
||||
/// Picks the mode with the same size as `current` and a refresh rate within
|
||||
/// `tolerance` of `targetHz`. Returns nil if `current` already matches or no such mode exists.
|
||||
public static func mode(
|
||||
matching targetHz: Double,
|
||||
current: DisplayModeInfo,
|
||||
available: [DisplayModeInfo],
|
||||
tolerance: Double = 0.1
|
||||
) -> DisplayModeInfo? {
|
||||
if abs(current.refreshRate - targetHz) <= tolerance { return nil }
|
||||
return available
|
||||
.filter {
|
||||
$0.width == current.width && $0.height == current.height
|
||||
&& abs($0.refreshRate - targetHz) <= tolerance
|
||||
}
|
||||
// Prefer the same backing resolution, then the closest rate.
|
||||
.min { lhs, rhs in
|
||||
let lhsSame = lhs.pixelWidth == current.pixelWidth && lhs.pixelHeight == current.pixelHeight
|
||||
let rhsSame = rhs.pixelWidth == current.pixelWidth && rhs.pixelHeight == current.pixelHeight
|
||||
if lhsSame != rhsSame { return lhsSame }
|
||||
return abs(lhs.refreshRate - targetHz) < abs(rhs.refreshRate - targetHz)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/// Counts received and missing video frames from NDI timestamps.
|
||||
///
|
||||
/// A gap larger than 1.5 frame durations counts the frames that should have arrived in between.
|
||||
/// Gaps longer than `stallThreshold` are treated as an outage (reconnect), not as lost frames.
|
||||
public struct FrameStats: Sendable, Equatable {
|
||||
public private(set) var received: Int64 = 0
|
||||
public private(set) var missing: Int64 = 0
|
||||
public private(set) var droppedByReceiver: Int64 = 0
|
||||
private var lastTimestamp: Int64?
|
||||
|
||||
/// NDI timestamps are in 100 ns units.
|
||||
public static let ticksPerSecond: Double = 10_000_000
|
||||
public var stallThreshold: Double = 2.0 // seconds
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Lost frames relative to all frames that should have been shown.
|
||||
public var lossRate: Double {
|
||||
let lost = missing + droppedByReceiver
|
||||
let expected = received + lost
|
||||
return expected == 0 ? 0 : Double(lost) / Double(expected)
|
||||
}
|
||||
|
||||
public mutating func record(timestamp: Int64, frameRateN: Int32, frameRateD: Int32) {
|
||||
received += 1
|
||||
defer { lastTimestamp = timestamp }
|
||||
guard let last = lastTimestamp, frameRateN > 0, frameRateD > 0 else { return }
|
||||
|
||||
let delta = Double(timestamp - last) / Self.ticksPerSecond
|
||||
let frameDuration = Double(frameRateD) / Double(frameRateN)
|
||||
guard delta > frameDuration * 1.5, delta < stallThreshold else { return }
|
||||
missing += Int64((delta / frameDuration).rounded()) - 1
|
||||
}
|
||||
|
||||
/// Total frames NDI itself dropped because we did not capture fast enough.
|
||||
public mutating func updateDroppedByReceiver(_ total: Int64) {
|
||||
droppedByReceiver = total
|
||||
}
|
||||
|
||||
/// Call after a reconnect so the outage is not counted as lost frames.
|
||||
public mutating func resetTimeline() {
|
||||
lastTimestamp = nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/// Parses `--route "<NDI name>=<screen index>"` launch arguments (for scripted tests until scenes exist).
|
||||
public enum RouteArgument {
|
||||
public struct Parsed: Equatable, Sendable {
|
||||
public var ndiName: String
|
||||
public var screenIndex: Int
|
||||
}
|
||||
|
||||
public static func parse(_ arguments: [String]) -> [Parsed] {
|
||||
zip(arguments, arguments.dropFirst()).compactMap { flag, value in
|
||||
guard flag == "--route", let separator = value.lastIndex(of: "=") else { return nil }
|
||||
let name = String(value[..<separator])
|
||||
guard !name.isEmpty, let index = Int(value[value.index(after: separator)...]), index >= 0 else { return nil }
|
||||
return Parsed(ndiName: name, screenIndex: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
import CNDI
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
import OSLog
|
||||
|
||||
/// Receives video from one NDI source on a dedicated thread and hands out IOSurface-backed pixel buffers.
|
||||
///
|
||||
/// Reconnects on its own when frames stop arriving (see `ConnectionWatchdog`).
|
||||
public final class NDIReceiver: @unchecked Sendable {
|
||||
public struct Snapshot: Sendable, Equatable {
|
||||
public var state: ConnectionWatchdog.State
|
||||
public var stats: FrameStats
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var frameRate: Double
|
||||
}
|
||||
|
||||
public typealias FrameHandler = @Sendable (CVPixelBuffer) -> Void
|
||||
|
||||
public let source: Source
|
||||
private let instance: NDIlib_recv_instance_t
|
||||
private let onFrame: FrameHandler
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "ndi-recv")
|
||||
|
||||
// Shared with the UI, guarded by `lock`.
|
||||
private let lock = NSLock()
|
||||
private var running = true
|
||||
private var watchdog: ConnectionWatchdog
|
||||
private var current: Snapshot
|
||||
|
||||
// Only touched on the receive thread.
|
||||
private var pool: CVPixelBufferPool?
|
||||
private var poolKey: (width: Int, height: Int, format: OSType)?
|
||||
|
||||
public init?(source: Source, receiverName: String, onFrame: @escaping FrameHandler) {
|
||||
let created: NDIlib_recv_instance_t? = source.ndiName.withCString { name in
|
||||
receiverName.withCString { recvName in
|
||||
var ndiSource = NDIlib_source_t()
|
||||
ndiSource.p_ndi_name = name
|
||||
var settings = NDIlib_recv_create_v3_t(
|
||||
source_to_connect_to: ndiSource,
|
||||
color_format: NDIlib_recv_color_format_UYVY_BGRA,
|
||||
bandwidth: NDIlib_recv_bandwidth_highest,
|
||||
allow_video_fields: false,
|
||||
p_ndi_recv_name: recvName
|
||||
)
|
||||
return NDIlib_recv_create_v3(&settings)
|
||||
}
|
||||
}
|
||||
guard let created else { return nil }
|
||||
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
self.source = source
|
||||
self.instance = created
|
||||
self.onFrame = onFrame
|
||||
self.watchdog = ConnectionWatchdog(now: now)
|
||||
self.current = Snapshot(state: .connecting, stats: FrameStats(), width: 0, height: 0, frameRate: 0)
|
||||
|
||||
let thread = Thread { [self] in run() }
|
||||
thread.name = "studio.zielke.musubi.ndi-recv"
|
||||
thread.qualityOfService = .userInteractive
|
||||
thread.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NDIlib_recv_destroy(instance)
|
||||
}
|
||||
|
||||
/// Stops the receive thread. The instance is destroyed once the last reference goes away.
|
||||
public func stop() {
|
||||
lock.withLock { running = false }
|
||||
}
|
||||
|
||||
public var snapshot: Snapshot {
|
||||
lock.withLock {
|
||||
var snapshot = current
|
||||
snapshot.state = watchdog.state(at: ProcessInfo.processInfo.systemUptime)
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receive thread
|
||||
|
||||
private func run() {
|
||||
var lastPerformanceCheck = 0.0
|
||||
while lock.withLock({ running }) {
|
||||
var frame = NDIlib_video_frame_v2_t()
|
||||
let type = NDIlib_recv_capture_v3(instance, &frame, nil, nil, 250)
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
if type == NDIlib_frame_type_video {
|
||||
if let buffer = makePixelBuffer(from: frame) {
|
||||
onFrame(buffer)
|
||||
}
|
||||
let rate = frame.frame_rate_D > 0 ? Double(frame.frame_rate_N) / Double(frame.frame_rate_D) : 0
|
||||
lock.withLock {
|
||||
watchdog.frameArrived(at: now)
|
||||
current.stats.record(timestamp: frame.timestamp, frameRateN: frame.frame_rate_N, frameRateD: frame.frame_rate_D)
|
||||
current.width = Int(frame.xres)
|
||||
current.height = Int(frame.yres)
|
||||
current.frameRate = rate
|
||||
}
|
||||
NDIlib_recv_free_video_v2(instance, &frame)
|
||||
}
|
||||
|
||||
if now - lastPerformanceCheck >= 1 {
|
||||
lastPerformanceCheck = now
|
||||
var dropped = NDIlib_recv_performance_t()
|
||||
NDIlib_recv_get_performance(instance, nil, &dropped)
|
||||
lock.withLock { current.stats.updateDroppedByReceiver(dropped.video_frames) }
|
||||
}
|
||||
|
||||
if lock.withLock({ watchdog.shouldRetry(at: now) }) {
|
||||
log.notice("No frames from \(self.source.ndiName, privacy: .public), reconnecting")
|
||||
reconnect()
|
||||
lock.withLock { current.stats.resetTimeline() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnect() {
|
||||
source.ndiName.withCString { name in
|
||||
var ndiSource = NDIlib_source_t()
|
||||
ndiSource.p_ndi_name = name
|
||||
NDIlib_recv_connect(instance, nil)
|
||||
NDIlib_recv_connect(instance, &ndiSource)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePixelBuffer(from frame: NDIlib_video_frame_v2_t) -> CVPixelBuffer? {
|
||||
let format: OSType
|
||||
let bytesPerPixel: Int
|
||||
switch frame.FourCC {
|
||||
case NDIlib_FourCC_video_type_UYVY:
|
||||
format = kCVPixelFormatType_422YpCbCr8 // '2vuy' has the same byte order as UYVY
|
||||
bytesPerPixel = 2
|
||||
case NDIlib_FourCC_video_type_BGRA, NDIlib_FourCC_video_type_BGRX:
|
||||
format = kCVPixelFormatType_32BGRA
|
||||
bytesPerPixel = 4
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let width = Int(frame.xres), height = Int(frame.yres)
|
||||
guard width > 0, height > 0, let source = frame.p_data,
|
||||
let pool = pixelBufferPool(width: width, height: height, format: format)
|
||||
else { return nil }
|
||||
|
||||
var buffer: CVPixelBuffer?
|
||||
guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) == kCVReturnSuccess, let buffer else { return nil }
|
||||
|
||||
CVPixelBufferLockBaseAddress(buffer, [])
|
||||
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
|
||||
guard let destination = CVPixelBufferGetBaseAddress(buffer) else { return nil }
|
||||
|
||||
let sourceStride = Int(frame.line_stride_in_bytes)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(buffer)
|
||||
if sourceStride == destinationStride {
|
||||
memcpy(destination, source, sourceStride * height)
|
||||
} else {
|
||||
let rowBytes = min(width * bytesPerPixel, sourceStride, destinationStride)
|
||||
for row in 0..<height {
|
||||
memcpy(destination + row * destinationStride, source + row * sourceStride, rowBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if format == kCVPixelFormatType_422YpCbCr8 {
|
||||
// NDI uses BT.709 for HD and BT.601 for SD.
|
||||
let hd = height >= 720
|
||||
CVBufferSetAttachment(buffer, kCVImageBufferYCbCrMatrixKey,
|
||||
hd ? kCVImageBufferYCbCrMatrix_ITU_R_709_2 : kCVImageBufferYCbCrMatrix_ITU_R_601_4, .shouldPropagate)
|
||||
CVBufferSetAttachment(buffer, kCVImageBufferColorPrimariesKey,
|
||||
hd ? kCVImageBufferColorPrimaries_ITU_R_709_2 : kCVImageBufferColorPrimaries_SMPTE_C, .shouldPropagate)
|
||||
CVBufferSetAttachment(buffer, kCVImageBufferTransferFunctionKey, kCVImageBufferTransferFunction_ITU_R_709_2, .shouldPropagate)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
private func pixelBufferPool(width: Int, height: Int, format: OSType) -> CVPixelBufferPool? {
|
||||
if let key = poolKey, key.width == width, key.height == height, key.format == format, let pool { return pool }
|
||||
let attributes: [CFString: Any] = [
|
||||
kCVPixelBufferWidthKey: width,
|
||||
kCVPixelBufferHeightKey: height,
|
||||
kCVPixelBufferPixelFormatTypeKey: format,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [CFString: Any](),
|
||||
kCVPixelBufferMetalCompatibilityKey: true,
|
||||
]
|
||||
var newPool: CVPixelBufferPool?
|
||||
CVPixelBufferPoolCreate(nil, [kCVPixelBufferPoolMinimumBufferCountKey: 4] as CFDictionary, attributes as CFDictionary, &newPool)
|
||||
pool = newPool
|
||||
poolKey = (width, height, format)
|
||||
log.info("\(self.source.ndiName, privacy: .public): \(width)x\(height) \(format == kCVPixelFormatType_32BGRA ? "BGRA" : "UYVY", privacy: .public)")
|
||||
return newPool
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import CoreGraphics
|
||||
import MusubiCore
|
||||
import OSLog
|
||||
|
||||
/// Switches a display to a target refresh rate for the lifetime of the app and restores it on request.
|
||||
public enum DisplayModeSwitcher {
|
||||
private static let log = Logger(subsystem: "studio.zielke.musubi", category: "display")
|
||||
|
||||
/// Returns the refresh rate in effect afterwards, and the original mode to restore later (nil if unchanged).
|
||||
@discardableResult
|
||||
public static func setRefreshRate(_ hz: Double, on display: CGDirectDisplayID) -> (refreshRate: Double, original: CGDisplayMode?) {
|
||||
guard let current = CGDisplayCopyDisplayMode(display) else { return (0, nil) }
|
||||
let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary
|
||||
let modes = (CGDisplayCopyAllDisplayModes(display, options) as? [CGDisplayMode]) ?? []
|
||||
|
||||
guard let target = DisplayModeSelection.mode(matching: hz, current: info(current), available: modes.map(info)),
|
||||
let mode = modes.first(where: { info($0) == target })
|
||||
else {
|
||||
if abs(current.refreshRate - hz) > 0.1 {
|
||||
log.notice("Display \(display): no \(hz) Hz mode, staying at \(current.refreshRate) Hz")
|
||||
}
|
||||
return (current.refreshRate, nil)
|
||||
}
|
||||
|
||||
guard apply(mode, to: display) else { return (current.refreshRate, nil) }
|
||||
log.info("Display \(display): \(current.refreshRate) Hz → \(mode.refreshRate) Hz")
|
||||
return (mode.refreshRate, current)
|
||||
}
|
||||
|
||||
public static func restore(_ mode: CGDisplayMode, on display: CGDirectDisplayID) {
|
||||
if apply(mode, to: display) {
|
||||
log.info("Display \(display): restored \(mode.refreshRate) Hz")
|
||||
}
|
||||
}
|
||||
|
||||
public static func currentRefreshRate(of display: CGDirectDisplayID) -> Double {
|
||||
CGDisplayCopyDisplayMode(display)?.refreshRate ?? 0
|
||||
}
|
||||
|
||||
private static func apply(_ mode: CGDisplayMode, to display: CGDirectDisplayID) -> Bool {
|
||||
var config: CGDisplayConfigRef?
|
||||
guard CGBeginDisplayConfiguration(&config) == .success else { return false }
|
||||
CGConfigureDisplayWithDisplayMode(config, display, mode, nil)
|
||||
// .forAppOnly: macOS reverts the mode when Musubi quits or crashes.
|
||||
let result = CGCompleteDisplayConfiguration(config, .forAppOnly)
|
||||
if result != .success { log.error("Display \(display): mode change failed (\(result.rawValue))") }
|
||||
return result == .success
|
||||
}
|
||||
|
||||
private static func info(_ mode: CGDisplayMode) -> DisplayModeInfo {
|
||||
DisplayModeInfo(width: mode.width, height: mode.height,
|
||||
pixelWidth: mode.pixelWidth, pixelHeight: mode.pixelHeight,
|
||||
refreshRate: mode.refreshRate)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
|
||||
/// A borderless window covering one screen, above the menu bar, that shows a video layer.
|
||||
@MainActor
|
||||
public final class OutputWindow: NSWindow {
|
||||
public let sink: VideoSink
|
||||
/// Called when the operator presses Esc on the output.
|
||||
public var onCancel: (() -> Void)?
|
||||
|
||||
public init(screen: NSScreen) {
|
||||
let view = VideoView()
|
||||
sink = VideoSink(renderer: view.displayLayer.sampleBufferRenderer)
|
||||
super.init(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
|
||||
contentView = view
|
||||
setFrame(screen.frame, display: false)
|
||||
level = .screenSaver
|
||||
collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenNone]
|
||||
backgroundColor = .black
|
||||
isOpaque = true
|
||||
hasShadow = false
|
||||
isReleasedWhenClosed = false
|
||||
animationBehavior = .none
|
||||
}
|
||||
|
||||
public override var canBecomeKey: Bool { true }
|
||||
|
||||
public override func cancelOperation(_ sender: Any?) {
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer-hosting view backed by `AVSampleBufferDisplayLayer`; hides the cursor while it is inside.
|
||||
@MainActor
|
||||
final class VideoView: NSView {
|
||||
let displayLayer = AVSampleBufferDisplayLayer()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
displayLayer.backgroundColor = NSColor.black.cgColor
|
||||
layer = displayLayer
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
trackingAreas.forEach(removeTrackingArea)
|
||||
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self))
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) }
|
||||
override func mouseMoved(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) }
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import IOKit.pwr_mgt
|
||||
|
||||
/// Keeps the display (and therefore the system) awake while held.
|
||||
public final class SleepBlocker {
|
||||
private var assertion: IOPMAssertionID = 0
|
||||
|
||||
public init?(reason: String) {
|
||||
let result = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&assertion
|
||||
)
|
||||
guard result == kIOReturnSuccess else { return nil }
|
||||
}
|
||||
|
||||
deinit {
|
||||
IOPMAssertionRelease(assertion)
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
// Video presentation (Metal / AVSampleBufferDisplayLayer), one fullscreen window per NSScreen.
|
||||
// Implemented in milestone 1.
|
||||
import MusubiCore
|
||||
@ -0,0 +1,43 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
|
||||
/// Hands pixel buffers to an `AVSampleBufferVideoRenderer`. Safe to call from the receive thread.
|
||||
public final class VideoSink: @unchecked Sendable {
|
||||
private let renderer: AVSampleBufferVideoRenderer
|
||||
// Only touched from the single thread that calls `display(_:)`.
|
||||
private var formatDescription: CMVideoFormatDescription?
|
||||
|
||||
public init(renderer: AVSampleBufferVideoRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public func display(_ pixelBuffer: CVPixelBuffer) {
|
||||
if renderer.status == .failed || renderer.requiresFlushToResumeDecoding {
|
||||
renderer.flush()
|
||||
}
|
||||
|
||||
if formatDescription.map({ !CMVideoFormatDescriptionMatchesImageBuffer($0, imageBuffer: pixelBuffer) }) ?? true {
|
||||
formatDescription = nil
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
|
||||
}
|
||||
guard let formatDescription else { return }
|
||||
|
||||
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()), decodeTimeStamp: .invalid)
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
guard CMSampleBufferCreateReadyWithImageBuffer(
|
||||
allocator: nil, imageBuffer: pixelBuffer, formatDescription: formatDescription,
|
||||
sampleTiming: &timing, sampleBufferOut: &sampleBuffer
|
||||
) == noErr, let sampleBuffer else { return }
|
||||
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true),
|
||||
CFArrayGetCount(attachments) > 0 {
|
||||
let dictionary = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(dictionary,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
|
||||
renderer.enqueue(sampleBuffer)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import Testing
|
||||
@testable import MusubiCore
|
||||
|
||||
@Suite struct DisplayModeSelectionTests {
|
||||
let current = DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 1920, pixelHeight: 1080, refreshRate: 60)
|
||||
|
||||
@Test func picksSameSizeAt50Hz() {
|
||||
let modes = [
|
||||
current,
|
||||
DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 1920, pixelHeight: 1080, refreshRate: 50),
|
||||
DisplayModeInfo(width: 1280, height: 720, pixelWidth: 1280, pixelHeight: 720, refreshRate: 50),
|
||||
]
|
||||
#expect(DisplayModeSelection.mode(matching: 50, current: current, available: modes)?.refreshRate == 50)
|
||||
#expect(DisplayModeSelection.mode(matching: 50, current: current, available: modes)?.width == 1920)
|
||||
}
|
||||
|
||||
@Test func prefersSameBackingResolution() {
|
||||
let hidpi = DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 3840, pixelHeight: 2160, refreshRate: 60)
|
||||
let modes = [
|
||||
DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 1920, pixelHeight: 1080, refreshRate: 50),
|
||||
DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 3840, pixelHeight: 2160, refreshRate: 50),
|
||||
]
|
||||
#expect(DisplayModeSelection.mode(matching: 50, current: hidpi, available: modes)?.pixelWidth == 3840)
|
||||
}
|
||||
|
||||
@Test func nilWhenAlreadyAt50HzOrUnavailable() {
|
||||
let at50 = DisplayModeInfo(width: 1920, height: 1080, pixelWidth: 1920, pixelHeight: 1080, refreshRate: 50)
|
||||
#expect(DisplayModeSelection.mode(matching: 50, current: at50, available: [at50]) == nil)
|
||||
#expect(DisplayModeSelection.mode(matching: 50, current: current, available: [current]) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct FrameStatsTests {
|
||||
// 50 fps → 200_000 ticks per frame.
|
||||
let frame: Int64 = 200_000
|
||||
|
||||
@Test func countsGapsAsMissingFrames() {
|
||||
var stats = FrameStats()
|
||||
stats.record(timestamp: 0, frameRateN: 50, frameRateD: 1)
|
||||
stats.record(timestamp: frame, frameRateN: 50, frameRateD: 1)
|
||||
stats.record(timestamp: frame * 4, frameRateN: 50, frameRateD: 1) // 2 frames missing
|
||||
#expect(stats.received == 3)
|
||||
#expect(stats.missing == 2)
|
||||
#expect(stats.lossRate == 0.4)
|
||||
}
|
||||
|
||||
@Test func toleratesJitter() {
|
||||
var stats = FrameStats()
|
||||
stats.record(timestamp: 0, frameRateN: 50, frameRateD: 1)
|
||||
stats.record(timestamp: frame + frame / 4, frameRateN: 50, frameRateD: 1)
|
||||
#expect(stats.missing == 0)
|
||||
}
|
||||
|
||||
@Test func outageIsNotCountedAsLoss() {
|
||||
var stats = FrameStats()
|
||||
stats.record(timestamp: 0, frameRateN: 50, frameRateD: 1)
|
||||
stats.record(timestamp: 50_000_000, frameRateN: 50, frameRateD: 1) // 5 s later
|
||||
#expect(stats.missing == 0)
|
||||
}
|
||||
|
||||
@Test func includesFramesDroppedByReceiver() {
|
||||
var stats = FrameStats()
|
||||
for i in 0..<998 { stats.record(timestamp: Int64(i) * frame, frameRateN: 50, frameRateD: 1) }
|
||||
stats.updateDroppedByReceiver(2)
|
||||
#expect(stats.lossRate == 0.002)
|
||||
}
|
||||
|
||||
@Test func handlesNTSCRates() {
|
||||
var stats = FrameStats()
|
||||
let ticks = Int64((1001.0 / 30000.0 * FrameStats.ticksPerSecond).rounded())
|
||||
stats.record(timestamp: 0, frameRateN: 30000, frameRateD: 1001)
|
||||
stats.record(timestamp: ticks * 3, frameRateN: 30000, frameRateD: 1001)
|
||||
#expect(stats.missing == 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ConnectionWatchdogTests {
|
||||
@Test func connectingThenLive() {
|
||||
var dog = ConnectionWatchdog(now: 0)
|
||||
#expect(dog.state(at: 1) == .connecting)
|
||||
dog.frameArrived(at: 1)
|
||||
#expect(dog.state(at: 1.5) == .live)
|
||||
}
|
||||
|
||||
@Test func stallsAndRetriesEveryInterval() {
|
||||
var dog = ConnectionWatchdog(now: 0)
|
||||
dog.frameArrived(at: 10)
|
||||
#expect(dog.state(at: 11.9) == .live)
|
||||
#expect(dog.state(at: 12) == .stalled(retryIn: 3))
|
||||
let retry1 = dog.shouldRetry(at: 13)
|
||||
#expect(!retry1)
|
||||
let retry2 = dog.shouldRetry(at: 15)
|
||||
#expect(retry2)
|
||||
#expect(dog.state(at: 16) == .stalled(retryIn: 2))
|
||||
let retry3 = dog.shouldRetry(at: 18)
|
||||
#expect(retry3)
|
||||
}
|
||||
|
||||
@Test func recoversWhenFramesReturn() {
|
||||
var dog = ConnectionWatchdog(now: 0)
|
||||
dog.frameArrived(at: 0)
|
||||
let retry4 = dog.shouldRetry(at: 5)
|
||||
#expect(retry4)
|
||||
dog.frameArrived(at: 6)
|
||||
#expect(dog.state(at: 6.5) == .live)
|
||||
}
|
||||
|
||||
@Test func neverConnectedSourceAlsoRetries() {
|
||||
var dog = ConnectionWatchdog(now: 0)
|
||||
let retry5 = dog.shouldRetry(at: 5)
|
||||
#expect(retry5)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct RouteArgumentTests {
|
||||
@Test func parsesRoutes() {
|
||||
let args = ["Musubi", "--route", "ZOWIEBOX (PGM)=1", "-NSFoo", "YES", "--route", "MV=0"]
|
||||
#expect(RouteArgument.parse(args) == [
|
||||
.init(ndiName: "ZOWIEBOX (PGM)", screenIndex: 1),
|
||||
.init(ndiName: "MV", screenIndex: 0),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func ignoresMalformed() {
|
||||
#expect(RouteArgument.parse(["--route", "noindex", "--route", "=1", "--route", "A=-1", "--route"]).isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue