Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
+1059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "fluxer_mac_screen_capture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8", "async"]}
|
||||
napi-derive = "3.5.6"
|
||||
fluxer_encoder_ring = {path = "../encoder-ring"}
|
||||
parking_lot = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.fluxer_screen_frame_bus]
|
||||
path = "../screen-frame-bus"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2.186"
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = {version = "0.3", features = ["NSString", "NSArray", "NSDictionary", "NSError", "NSValue", "NSBundle", "NSProcessInfo"]}
|
||||
objc2-screen-capture-kit = {version = "0.3", features = ["SCStream", "SCShareableContent", "objc2-core-graphics", "objc2-core-media", "objc2-core-foundation", "block2", "dispatch2", "libc"]}
|
||||
objc2-core-media = {version = "0.3", features = ["CMSampleBuffer", "CMBlockBuffer", "CMFormatDescription", "CMTime", "objc2-core-audio-types"]}
|
||||
objc2-core-audio-types = {version = "0.3", features = ["CoreAudioBaseTypes", "objc2"]}
|
||||
objc2-core-video = {version = "0.3", features = ["CVPixelBuffer", "CVPixelBufferIOSurface", "CVImageBuffer", "CVBuffer", "CVPixelFormatDescription", "CVReturn", "CVBase", "objc2-io-surface"]}
|
||||
objc2-io-surface = {version = "0.3", features = ["IOSurfaceRef", "objc2-core-foundation"]}
|
||||
objc2-core-foundation = {version = "0.3", features = ["CFArray", "CFDictionary", "CFNumber", "CFString", "CFBase"]}
|
||||
objc2-core-graphics = {version = "0.3.2", default-features = false, features = ["std", "CGWindow"]}
|
||||
block2 = "0.6"
|
||||
dispatch2 = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = {version = "0.8", default-features = false, features = ["cargo_bench_support"]}
|
||||
|
||||
[[bench]]
|
||||
name = "audio_pool"
|
||||
harness = false
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::hint::black_box;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use fluxer_mac_screen_capture::audio_pool::{
|
||||
MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioFramePool,
|
||||
};
|
||||
|
||||
fn bench_acquire_write_release_960_floats(c: &mut Criterion) {
|
||||
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
|
||||
.expect("pool must build");
|
||||
let payload = vec![0xCC_u8; 960 * 4];
|
||||
c.bench_function("audio_pool/acquire_write_release/960_floats", |b| {
|
||||
b.iter(|| {
|
||||
let mut slot = pool
|
||||
.try_acquire()
|
||||
.expect("steady state single-thread never starves");
|
||||
slot.write(&payload).expect("write fits");
|
||||
black_box(slot.data_slice().len());
|
||||
drop(slot);
|
||||
});
|
||||
});
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
fn bench_acquire_release_only(c: &mut Criterion) {
|
||||
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
|
||||
.expect("pool must build");
|
||||
c.bench_function("audio_pool/acquire_release", |b| {
|
||||
b.iter(|| {
|
||||
let slot = pool.try_acquire().expect("slot");
|
||||
black_box(slot.slot_index());
|
||||
});
|
||||
});
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
fn bench_acquire_write_into_external_parts(c: &mut Criterion) {
|
||||
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
|
||||
.expect("pool must build");
|
||||
let payload = vec![0xCC_u8; 960 * 4];
|
||||
c.bench_function(
|
||||
"audio_pool/acquire_write_into_external_parts/960_floats",
|
||||
|b| {
|
||||
b.iter(|| {
|
||||
let mut slot = pool
|
||||
.try_acquire()
|
||||
.expect("steady state single-thread never starves");
|
||||
slot.write(&payload).expect("write fits");
|
||||
let (ptr, len, owned) = slot.into_external_parts();
|
||||
black_box((ptr, len));
|
||||
drop(owned);
|
||||
});
|
||||
},
|
||||
);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_acquire_write_release_960_floats,
|
||||
bench_acquire_release_only,
|
||||
bench_acquire_write_into_external_parts,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EventEmitter} from 'node:events';
|
||||
|
||||
export interface MacScreenCaptureBackendInfo {
|
||||
backend: string;
|
||||
supported: boolean;
|
||||
reason: string;
|
||||
minMacosVersion: string;
|
||||
detectedMacosVersion?: string;
|
||||
sckAvailable: boolean;
|
||||
}
|
||||
|
||||
export declare function getBackendInfo(): MacScreenCaptureBackendInfo;
|
||||
|
||||
export interface MacScreenCaptureBackendAvailability {
|
||||
sck?: {
|
||||
supported: boolean;
|
||||
macosVersion?: string;
|
||||
};
|
||||
screenPermission?: string;
|
||||
}
|
||||
|
||||
export declare function getBackendAvailability(): Promise<MacScreenCaptureBackendAvailability>;
|
||||
|
||||
export type MacScreenCaptureSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface MacScreenCaptureSource {
|
||||
kind: MacScreenCaptureSourceKind;
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
appName?: string;
|
||||
bundleId?: string;
|
||||
targetPid?: number;
|
||||
}
|
||||
|
||||
export declare function listSources(): Promise<Array<MacScreenCaptureSource>>;
|
||||
|
||||
export interface ScreenCaptureRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ScreenCaptureOptions {
|
||||
sourceId: string;
|
||||
sourceKind: MacScreenCaptureSourceKind;
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
captureId?: string;
|
||||
colorRange?: 'full' | 'limited';
|
||||
colorSpace?: 'rec709' | 'srgb';
|
||||
showCursorClicks?: boolean;
|
||||
captureRect?: ScreenCaptureRect;
|
||||
frameSinkHandle?: unknown;
|
||||
nativeFrameSinkRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenCaptureStartResult {
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
pixelFormat: 'nv12' | 'bgra';
|
||||
}
|
||||
|
||||
export interface FrameSinkDiagnostics {
|
||||
accepted: number;
|
||||
coalesced: number;
|
||||
rejected: number;
|
||||
mediaFramesDroppedWithoutSink: number;
|
||||
}
|
||||
|
||||
export declare const loadError: Error | null;
|
||||
|
||||
export declare function __setBindingForTests(binding: unknown): void;
|
||||
|
||||
export declare interface ScreenCapture {
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: 'closed', listener: () => void): this;
|
||||
on(event: 'diagnostic', listener: (message?: string) => void): this;
|
||||
on(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
off(event: 'error', listener: (err: Error) => void): this;
|
||||
off(event: 'closed', listener: () => void): this;
|
||||
off(event: 'diagnostic', listener: (message?: string) => void): this;
|
||||
off(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
emit(event: 'error', err: Error): boolean;
|
||||
emit(event: 'closed'): boolean;
|
||||
emit(event: 'diagnostic', message?: string): boolean;
|
||||
}
|
||||
|
||||
export declare class ScreenCapture extends EventEmitter {
|
||||
constructor(options: ScreenCaptureOptions);
|
||||
|
||||
start(): Promise<ScreenCaptureStartResult>;
|
||||
|
||||
stop(): Promise<void>;
|
||||
|
||||
attachEncoder(width: number, height: number, frameRate?: number): void;
|
||||
detachEncoder(): void;
|
||||
isEncoderAttached(): boolean;
|
||||
encoderRingFullCount(): number;
|
||||
getFrameSinkDiagnostics(): FrameSinkDiagnostics;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/mac-screen-capture';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error(`@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'mac-screen-capture.darwin-x64.node';
|
||||
case 'arm64':
|
||||
return 'mac-screen-capture.darwin-arm64.node';
|
||||
default:
|
||||
throw new Error(`Unsupported macOS architecture: ${process.arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, nativeFileName());
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
} catch (error) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendInfo() {
|
||||
if (!binding) {
|
||||
return {
|
||||
backend: 'mac-screen-capture',
|
||||
supported: false,
|
||||
reason:
|
||||
process.platform === 'darwin'
|
||||
? `@fluxer/mac-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
|
||||
: `@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`,
|
||||
minMacosVersion: '12.3',
|
||||
detectedMacosVersion: undefined,
|
||||
sckAvailable: false,
|
||||
};
|
||||
}
|
||||
return binding.getBackendInfo();
|
||||
}
|
||||
|
||||
function getBackendAvailability() {
|
||||
if (!binding) {
|
||||
return Promise.resolve({
|
||||
sck: {supported: false},
|
||||
screenPermission: 'not-determined',
|
||||
});
|
||||
}
|
||||
return binding.getBackendAvailability();
|
||||
}
|
||||
|
||||
function listSources() {
|
||||
if (!binding) return Promise.resolve([]);
|
||||
return binding.listSources();
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding) {
|
||||
binding = nextBinding;
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
class ScreenCapture extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError || new Error('@fluxer/mac-screen-capture binding unavailable');
|
||||
}
|
||||
this.sourceId = options.sourceId;
|
||||
this.sourceKind = options.sourceKind ?? 'screen';
|
||||
this.width = options.width ?? 0;
|
||||
this.height = options.height ?? 0;
|
||||
this.frameRate = options.frameRate ?? 30;
|
||||
this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined;
|
||||
this.colorRange = options.colorRange;
|
||||
this.colorSpace = options.colorSpace;
|
||||
this.showCursorClicks = options.showCursorClicks === true;
|
||||
this.captureRect = options.captureRect;
|
||||
this.frameSinkHandle = options.frameSinkHandle;
|
||||
this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true;
|
||||
this.started = false;
|
||||
this.stopped = false;
|
||||
this.closedEmitted = false;
|
||||
this.native = new binding.ScreenCapture();
|
||||
this.native.setLifecycleCallback((type, message) => {
|
||||
if (type === 'diagnostic') {
|
||||
if (this.stopped) return;
|
||||
this.emit('diagnostic', message);
|
||||
return;
|
||||
}
|
||||
if (type === 'error') {
|
||||
this.emit('error', new Error(message || 'macOS screen capture stream stopped'));
|
||||
return;
|
||||
}
|
||||
if (type === 'closed') {
|
||||
if (this.stopped) {
|
||||
this.emitClosedOnce();
|
||||
return;
|
||||
}
|
||||
this.stopped = true;
|
||||
Promise.resolve()
|
||||
.then(() => this.native.stop())
|
||||
.catch(() => {});
|
||||
this.emitClosedOnce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emitClosedOnce() {
|
||||
if (this.closedEmitted) return;
|
||||
this.closedEmitted = true;
|
||||
this.emit('closed');
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started || this.stopped) return;
|
||||
this.started = true;
|
||||
try {
|
||||
if (this.frameSinkHandle != null) {
|
||||
if (typeof this.native.setFrameSinkHandle !== 'function') {
|
||||
throw new Error('@fluxer/mac-screen-capture native binding does not support native frame sink handles');
|
||||
}
|
||||
this.native.setFrameSinkHandle(this.frameSinkHandle);
|
||||
} else if (this.nativeFrameSinkRequired) {
|
||||
throw new Error('Native frame sink handle is required for macOS screen capture');
|
||||
}
|
||||
const result = await this.native.start(
|
||||
this.sourceId,
|
||||
this.sourceKind,
|
||||
this.width,
|
||||
this.height,
|
||||
this.frameRate,
|
||||
this.captureId,
|
||||
{
|
||||
colorRange: this.colorRange,
|
||||
colorSpace: this.colorSpace,
|
||||
showCursorClicks: this.showCursorClicks,
|
||||
captureRect: this.captureRect,
|
||||
},
|
||||
);
|
||||
if (result) {
|
||||
this.width = result.width ?? this.width;
|
||||
this.height = result.height ?? this.height;
|
||||
this.frameRate = result.frameRate ?? this.frameRate;
|
||||
this.pixelFormat = result.pixelFormat ?? 'nv12';
|
||||
}
|
||||
return {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
frameRate: this.frameRate,
|
||||
pixelFormat: this.pixelFormat ?? 'nv12',
|
||||
};
|
||||
} catch (error) {
|
||||
this.stopped = true;
|
||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
try {
|
||||
await this.native.stop();
|
||||
} finally {
|
||||
this.emitClosedOnce();
|
||||
}
|
||||
}
|
||||
|
||||
getFrameSinkDiagnostics() {
|
||||
if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') {
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return this.native.getFrameSinkDiagnostics();
|
||||
} catch (error) {
|
||||
console.warn('[mac-screen-capture] getFrameSinkDiagnostics failed:', error?.message || error);
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ScreenCapture,
|
||||
getBackendAvailability,
|
||||
getBackendInfo,
|
||||
listSources,
|
||||
loadError,
|
||||
__setBindingForTests,
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {afterEach, describe, test} from 'node:test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const macScreenCapture = require('./index.js');
|
||||
|
||||
function makeFakeBinding({sources = [], availability = {sck: {supported: true}, screenPermission: 'authorized'}} = {}) {
|
||||
const calls = [];
|
||||
const frameSinkHandleCalls = [];
|
||||
const natives = [];
|
||||
const frameSinkDiagnostics = {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
};
|
||||
class FakeNative {
|
||||
constructor() {
|
||||
this.lifecycleCallback = undefined;
|
||||
this.stopCount = 0;
|
||||
natives.push(this);
|
||||
}
|
||||
|
||||
setLifecycleCallback(callback) {
|
||||
this.lifecycleCallback = callback;
|
||||
}
|
||||
|
||||
setFrameSinkHandle(handle) {
|
||||
frameSinkHandleCalls.push(handle);
|
||||
}
|
||||
|
||||
async start(sourceId, sourceKind, width, height, frameRate, captureId, captureOptions) {
|
||||
calls.push({sourceId, sourceKind, width, height, frameRate, captureId, captureOptions});
|
||||
return {width: width || 1920, height: height || 1080, frameRate: frameRate || 30, pixelFormat: 'nv12'};
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.stopCount += 1;
|
||||
}
|
||||
|
||||
getFrameSinkDiagnostics() {
|
||||
return {...frameSinkDiagnostics};
|
||||
}
|
||||
}
|
||||
return {
|
||||
binding: {
|
||||
ScreenCapture: FakeNative,
|
||||
listSources: async () => sources,
|
||||
getBackendAvailability: async () => availability,
|
||||
getBackendInfo: () => ({
|
||||
backend: 'mac-screen-capture',
|
||||
supported: true,
|
||||
reason: '',
|
||||
minMacosVersion: '12.3',
|
||||
detectedMacosVersion: '14.0',
|
||||
sckAvailable: true,
|
||||
}),
|
||||
},
|
||||
calls,
|
||||
frameSinkHandleCalls,
|
||||
frameSinkDiagnostics,
|
||||
natives,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
macScreenCapture.__setBindingForTests(null);
|
||||
});
|
||||
|
||||
describe('mac-screen-capture loader wrapper', () => {
|
||||
test('forwards source id, kind, and dimensions to native binding', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({
|
||||
sourceId: '12345',
|
||||
sourceKind: 'window',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
const result = await capture.start();
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
sourceId: '12345',
|
||||
sourceKind: 'window',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
captureId: undefined,
|
||||
captureOptions: {
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(result.pixelFormat, 'nv12');
|
||||
assert.equal(result.width, 1280);
|
||||
assert.equal(result.height, 720);
|
||||
});
|
||||
test('defaults sourceKind to screen and frameRate to 30', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
|
||||
capture.on('error', () => {});
|
||||
await capture.start();
|
||||
assert.equal(calls[0].sourceKind, 'screen');
|
||||
assert.equal(calls[0].frameRate, 30);
|
||||
});
|
||||
test('forwards display and window sources from native binding without rewriting ids', async () => {
|
||||
const {binding} = makeFakeBinding({
|
||||
sources: [
|
||||
{kind: 'screen', id: 'display:69733632', name: 'Studio Display', width: 5120, height: 2880},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'window:4242',
|
||||
name: 'Fluxer',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
appName: 'Fluxer',
|
||||
bundleId: 'app.fluxer.desktop',
|
||||
targetPid: 1234,
|
||||
},
|
||||
],
|
||||
});
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
|
||||
const sources = await macScreenCapture.listSources();
|
||||
|
||||
assert.deepEqual(sources, [
|
||||
{kind: 'screen', id: 'display:69733632', name: 'Studio Display', width: 5120, height: 2880},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'window:4242',
|
||||
name: 'Fluxer',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
appName: 'Fluxer',
|
||||
bundleId: 'app.fluxer.desktop',
|
||||
targetPid: 1234,
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('reports ScreenCaptureKit support and permission from native binding', async () => {
|
||||
const {binding} = makeFakeBinding({
|
||||
availability: {
|
||||
sck: {supported: true, macosVersion: '15.0'},
|
||||
screenPermission: 'authorized',
|
||||
},
|
||||
});
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
|
||||
assert.deepEqual(await macScreenCapture.getBackendAvailability(), {
|
||||
sck: {supported: true, macosVersion: '15.0'},
|
||||
screenPermission: 'authorized',
|
||||
});
|
||||
});
|
||||
test('installs a native frame sink handle once before start', async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const frameSinkHandle = {native: true};
|
||||
const capture = new macScreenCapture.ScreenCapture({
|
||||
sourceId: '12345',
|
||||
sourceKind: 'window',
|
||||
frameSinkHandle,
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
|
||||
await capture.start();
|
||||
|
||||
assert.deepEqual(frameSinkHandleCalls, [frameSinkHandle]);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
test('fails before native start when a native frame sink is required but missing', async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({
|
||||
sourceId: '12345',
|
||||
sourceKind: 'window',
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
capture.on('error', () => {});
|
||||
|
||||
await assert.rejects(() => capture.start(), /native frame sink handle is required/i);
|
||||
assert.deepEqual(frameSinkHandleCalls, []);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
test('frame sink diagnostics are forwarded by the wrapper', () => {
|
||||
const {binding, frameSinkDiagnostics} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
|
||||
|
||||
frameSinkDiagnostics.accepted = 5;
|
||||
frameSinkDiagnostics.coalesced = 1;
|
||||
frameSinkDiagnostics.rejected = 2;
|
||||
frameSinkDiagnostics.mediaFramesDroppedWithoutSink = 3;
|
||||
|
||||
assert.deepEqual(capture.getFrameSinkDiagnostics(), {
|
||||
accepted: 5,
|
||||
coalesced: 1,
|
||||
rejected: 2,
|
||||
mediaFramesDroppedWithoutSink: 3,
|
||||
});
|
||||
});
|
||||
test('emits closed once when native lifecycle closes and stop is called later', async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
|
||||
let closed = 0;
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback('closed', '');
|
||||
await Promise.resolve();
|
||||
await capture.stop();
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(natives[0].stopCount, 1);
|
||||
});
|
||||
test('lifecycle error emits Error event', async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
|
||||
const errors = [];
|
||||
capture.on('error', (err) => errors.push(err));
|
||||
natives[0].lifecycleCallback('error', 'permission lost mid-stream');
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].message, 'permission lost mid-stream');
|
||||
});
|
||||
test('lifecycle diagnostic emits diagnostic event', () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
macScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
|
||||
const diagnostics = [];
|
||||
capture.on('diagnostic', (message) => diagnostics.push(message));
|
||||
natives[0].lifecycleCallback('diagnostic', 'frame sink rejected a frame');
|
||||
assert.deepEqual(diagnostics, ['frame sink rejected a frame']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {basename} = require('node:path');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
|
||||
const MAX_TEXT_LENGTH = 6000;
|
||||
const MAX_DIRECTORY_ENTRIES = 80;
|
||||
|
||||
function trimText(value, limit = MAX_TEXT_LENGTH) {
|
||||
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
|
||||
}
|
||||
|
||||
function errorDiagnostic(error) {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || 'Error',
|
||||
message: error.message,
|
||||
code: error.code || null,
|
||||
stack: trimText(error.stack || error.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: typeof error,
|
||||
message: trimText(String(error)),
|
||||
code: null,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const lines = [];
|
||||
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
|
||||
if (diagnostic.stack) lines.push(diagnostic.stack);
|
||||
else if (diagnostic.message) lines.push(diagnostic.message);
|
||||
return trimText(lines.join('\n'));
|
||||
}
|
||||
|
||||
function fileDiagnostic(filePath) {
|
||||
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
size: stat.size,
|
||||
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {path: filePath, exists: false, error: reason};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return 'not resolved';
|
||||
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
|
||||
return [
|
||||
`exists=true`,
|
||||
`size=${diagnostic.size}`,
|
||||
`mode=${diagnostic.mode}`,
|
||||
`mtime=${diagnostic.mtime}`,
|
||||
`isFile=${diagnostic.isFile}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function directoryDiagnostic(dirPath) {
|
||||
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
|
||||
try {
|
||||
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
||||
}));
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: true,
|
||||
entries: visible,
|
||||
total: entries.length,
|
||||
omitted: Math.max(0, entries.length - visible.length),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
entries: [],
|
||||
total: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return '<unavailable>';
|
||||
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
|
||||
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
|
||||
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
|
||||
return [...entries, ...suffix].join('\n') || '<empty>';
|
||||
}
|
||||
|
||||
function selectedEnvironmentNames(skipNativeProbeEnv) {
|
||||
const names = [
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DISPLAY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_TYPE',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'PULSE_SERVER',
|
||||
'PIPEWIRE_REMOTE',
|
||||
'PATH',
|
||||
];
|
||||
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
|
||||
return names;
|
||||
}
|
||||
|
||||
function environmentDiagnostics(skipNativeProbeEnv) {
|
||||
return Object.fromEntries(
|
||||
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnvironment(diagnostic) {
|
||||
return Object.entries(diagnostic)
|
||||
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function runtimeDiagnostics() {
|
||||
const versions = process.versions || {};
|
||||
let reportHeader = null;
|
||||
if (process.report && typeof process.report.getReport === 'function') {
|
||||
try {
|
||||
reportHeader = process.report.getReport().header || null;
|
||||
} catch {
|
||||
reportHeader = null;
|
||||
}
|
||||
}
|
||||
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
|
||||
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
|
||||
return {
|
||||
node: versions.node || null,
|
||||
electron: versions.electron || null,
|
||||
modules: versions.modules || null,
|
||||
napi: versions.napi || null,
|
||||
v8: versions.v8 || null,
|
||||
uv: versions.uv || null,
|
||||
openssl: versions.openssl || null,
|
||||
glibcRuntime,
|
||||
glibcCompiler,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: typeof os.version === 'function' ? os.version() : null,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath || null,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeDiagnostics(diagnostic) {
|
||||
return [
|
||||
`node=${diagnostic.node || '<unknown>'}`,
|
||||
`electron=${diagnostic.electron || '<none>'}`,
|
||||
`modules=${diagnostic.modules || '<unknown>'}`,
|
||||
`napi=${diagnostic.napi || '<unknown>'}`,
|
||||
`v8=${diagnostic.v8 || '<unknown>'}`,
|
||||
`uv=${diagnostic.uv || '<unknown>'}`,
|
||||
`openssl=${diagnostic.openssl || '<unknown>'}`,
|
||||
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
|
||||
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
|
||||
`process=${diagnostic.platform}/${diagnostic.arch}`,
|
||||
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
|
||||
`execPath=${diagnostic.execPath}`,
|
||||
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
|
||||
`cwd=${diagnostic.cwd}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
|
||||
/^vcruntime\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcp\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcr\d+(?:_\d+)?\.dll$/i,
|
||||
/^concrt\d+\.dll$/i,
|
||||
/^vcamp\d+\.dll$/i,
|
||||
/^vcomp\d+\.dll$/i,
|
||||
];
|
||||
|
||||
function readPeImports(filePath) {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length < 0x40) return null;
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
|
||||
const coffOffset = peOffset + 4;
|
||||
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
|
||||
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
|
||||
const magic = buffer.readUInt16LE(optionalHeaderOffset);
|
||||
if (magic !== 0x10b && magic !== 0x20b) return null;
|
||||
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
|
||||
const importEntryOffset = dataDirectoriesOffset + 8;
|
||||
if (importEntryOffset + 8 > buffer.length) return null;
|
||||
const importRva = buffer.readUInt32LE(importEntryOffset);
|
||||
if (importRva === 0) return [];
|
||||
const sections = [];
|
||||
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
|
||||
for (let i = 0; i < numberOfSections; i++) {
|
||||
const base = sectionTableOffset + i * 40;
|
||||
if (base + 40 > buffer.length) return null;
|
||||
sections.push({
|
||||
virtualSize: buffer.readUInt32LE(base + 8),
|
||||
virtualAddress: buffer.readUInt32LE(base + 12),
|
||||
rawSize: buffer.readUInt32LE(base + 16),
|
||||
rawPointer: buffer.readUInt32LE(base + 20),
|
||||
});
|
||||
}
|
||||
const rvaToOffset = (rva) => {
|
||||
for (const s of sections) {
|
||||
const span = Math.max(s.virtualSize, s.rawSize);
|
||||
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
|
||||
return rva - s.virtualAddress + s.rawPointer;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const readCString = (offset) => {
|
||||
let end = offset;
|
||||
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||
return buffer.toString('ascii', offset, end);
|
||||
};
|
||||
const importTableOffset = rvaToOffset(importRva);
|
||||
if (importTableOffset < 0) return [];
|
||||
const imports = new Set();
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const base = importTableOffset + i * 20;
|
||||
if (base + 20 > buffer.length) break;
|
||||
const lookupRva = buffer.readUInt32LE(base);
|
||||
const nameRva = buffer.readUInt32LE(base + 12);
|
||||
const iatRva = buffer.readUInt32LE(base + 16);
|
||||
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
|
||||
const nameOffset = rvaToOffset(nameRva);
|
||||
if (nameOffset < 0) continue;
|
||||
const name = readCString(nameOffset);
|
||||
if (name) imports.add(name);
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function windowsImportProbe(nativePath) {
|
||||
const imports = readPeImports(nativePath);
|
||||
if (imports === null) return null;
|
||||
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const redistributable = sortedImports.filter((dll) =>
|
||||
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
|
||||
);
|
||||
return {
|
||||
command: ['pe-imports', nativePath],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
stdout: sortedImports.join('\n') || null,
|
||||
stderr: null,
|
||||
missing: [],
|
||||
redistributable,
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyProbe(nativePath) {
|
||||
if (!nativePath || !existsSync(nativePath)) return null;
|
||||
if (process.platform === 'win32') return windowsImportProbe(nativePath);
|
||||
const command =
|
||||
process.platform === 'linux'
|
||||
? ['ldd', nativePath]
|
||||
: process.platform === 'darwin'
|
||||
? ['otool', '-L', nativePath]
|
||||
: null;
|
||||
if (!command) return null;
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = trimText(result.stdout);
|
||||
const stderr = trimText(result.stderr);
|
||||
const missing =
|
||||
process.platform === 'linux' && stdout
|
||||
? stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.includes('not found'))
|
||||
: [];
|
||||
return {
|
||||
command,
|
||||
status: result.status,
|
||||
signal: result.signal || null,
|
||||
error: result.error ? result.error.message : null,
|
||||
stdout,
|
||||
stderr,
|
||||
missing,
|
||||
redistributable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDependencyProbe(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const status = diagnostic.error
|
||||
? `error=${diagnostic.error}`
|
||||
: diagnostic.signal
|
||||
? `signal=${diagnostic.signal}`
|
||||
: `status=${diagnostic.status}`;
|
||||
return [
|
||||
`$ ${diagnostic.command.join(' ')}`,
|
||||
status,
|
||||
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
|
||||
diagnostic.redistributable?.length
|
||||
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
|
||||
: null,
|
||||
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
|
||||
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatExtraDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
if (typeof diagnostic === 'string') return diagnostic;
|
||||
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
|
||||
return `${diagnostic.name}:\n${diagnostic.text}`;
|
||||
}
|
||||
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
|
||||
}
|
||||
|
||||
function collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
moduleName,
|
||||
reason,
|
||||
target: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packageDir: packageDir || null,
|
||||
nativeRoot: nativeRoot || null,
|
||||
nativePath: nativePath || null,
|
||||
nativeFile: nativePath ? basename(nativePath) : null,
|
||||
nativeFileStat: fileDiagnostic(nativePath),
|
||||
runtime: runtimeDiagnostics(),
|
||||
environment: environmentDiagnostics(skipNativeProbeEnv),
|
||||
nativeRootEntries: directoryDiagnostic(nativeRoot),
|
||||
dependencyProbe: dependencyProbe(nativePath),
|
||||
extraDiagnostics: extraDiagnostics.filter(Boolean),
|
||||
cause: errorDiagnostic(cause),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNativeDiagnostics(diagnostics) {
|
||||
const sections = [
|
||||
`module=${diagnostics.moduleName}`,
|
||||
`reason=${diagnostics.reason}`,
|
||||
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
|
||||
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
|
||||
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
|
||||
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
|
||||
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
|
||||
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
|
||||
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
|
||||
`environment:\n${formatEnvironment(diagnostics.environment)}`,
|
||||
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
|
||||
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
|
||||
];
|
||||
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
|
||||
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
|
||||
const causeText = formatErrorDiagnostic(diagnostics.cause);
|
||||
if (causeText) sections.push(`cause:\n${causeText}`);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function isNativeLoadError(error) {
|
||||
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
|
||||
}
|
||||
|
||||
function createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
if (isNativeLoadError(cause)) return cause;
|
||||
const diagnostics = collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics,
|
||||
});
|
||||
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
|
||||
error.name = 'NativeModuleLoadError';
|
||||
error[NATIVE_LOAD_ERROR_MARKER] = true;
|
||||
error.nativeDiagnostics = diagnostics;
|
||||
error.toJSON = () => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
nativeDiagnostics: diagnostics,
|
||||
});
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
|
||||
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
|
||||
return null;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
|
||||
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.status === 0) return null;
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `safety probe terminated by signal ${result.signal}`
|
||||
: `safety probe exited with code ${result.status}`;
|
||||
return createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics: [
|
||||
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
|
||||
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
|
||||
if (!existsSync(nativePath)) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'native binary not found',
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const nativeProbeError = probe
|
||||
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
|
||||
: null;
|
||||
if (nativeProbeError) {
|
||||
return {binding: null, loadError: nativeProbeError};
|
||||
}
|
||||
try {
|
||||
return {binding: require(nativePath), loadError: null};
|
||||
} catch (error) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'require(nativePath) threw',
|
||||
cause: error,
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectNativeDiagnostics,
|
||||
createNativeLoadError,
|
||||
formatNativeDiagnostics,
|
||||
isNativeLoadError,
|
||||
loadNativeBinding,
|
||||
probeNativeBinary,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@fluxer/mac-screen-capture",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"mac-screen-capture.darwin-x64.node",
|
||||
"mac-screen-capture.darwin-arm64.node"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "pnpm test:cargo && node --test index.test.mjs",
|
||||
"test:cargo": "cargo test --manifest-path Cargo.toml"
|
||||
},
|
||||
"binary": {
|
||||
"napi_versions": [
|
||||
8
|
||||
]
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::cell::UnsafeCell;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
pub const MAC_AUDIO_POOL_CAP: usize = 16;
|
||||
pub const MAX_FRAME_BYTES_PER_SLOT: usize = 8192;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MacAudioError {
|
||||
ZeroCapacity,
|
||||
ZeroBytesPerSlot,
|
||||
BytesPerSlotTooLarge(usize),
|
||||
PayloadTooLarge { offered: usize, capacity: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for MacAudioError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ZeroCapacity => write!(f, "MacAudioFramePool capacity must be > 0"),
|
||||
Self::ZeroBytesPerSlot => write!(f, "MacAudioFramePool bytes_per_slot must be > 0"),
|
||||
Self::BytesPerSlotTooLarge(n) => write!(
|
||||
f,
|
||||
"MacAudioFramePool bytes_per_slot {n} exceeds MAX_FRAME_BYTES_PER_SLOT={MAX_FRAME_BYTES_PER_SLOT}"
|
||||
),
|
||||
Self::PayloadTooLarge { offered, capacity } => write!(
|
||||
f,
|
||||
"MacAudioFramePool payload {offered} bytes exceeds slot capacity {capacity}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MacAudioError {}
|
||||
|
||||
struct PoolSlotCell {
|
||||
inner: UnsafeCell<Box<[u8]>>,
|
||||
}
|
||||
|
||||
unsafe impl Send for PoolSlotCell {}
|
||||
unsafe impl Sync for PoolSlotCell {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MacAudioPoolStats {
|
||||
pub acquired: u64,
|
||||
pub released: u64,
|
||||
pub dropped: u64,
|
||||
pub in_flight: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct MacAudioFramePoolInner {
|
||||
slots: Vec<PoolSlotCell>,
|
||||
free: Mutex<Vec<usize>>,
|
||||
capacity: u32,
|
||||
bytes_per_slot: u32,
|
||||
acquired_total: AtomicU64,
|
||||
released_total: AtomicU64,
|
||||
dropped_total: AtomicU64,
|
||||
in_flight: AtomicU32,
|
||||
}
|
||||
|
||||
pub struct MacAudioFramePool {
|
||||
inner: Arc<MacAudioFramePoolInner>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for MacAudioFramePool {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = self.stats();
|
||||
f.debug_struct("MacAudioFramePool")
|
||||
.field("capacity", &self.inner.capacity)
|
||||
.field("bytes_per_slot", &self.inner.bytes_per_slot)
|
||||
.field("stats", &stats)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl MacAudioFramePool {
|
||||
pub fn new(capacity: usize, bytes_per_slot: usize) -> Result<Self, MacAudioError> {
|
||||
if capacity == 0 {
|
||||
return Err(MacAudioError::ZeroCapacity);
|
||||
}
|
||||
if bytes_per_slot == 0 {
|
||||
return Err(MacAudioError::ZeroBytesPerSlot);
|
||||
}
|
||||
if bytes_per_slot > MAX_FRAME_BYTES_PER_SLOT {
|
||||
return Err(MacAudioError::BytesPerSlotTooLarge(bytes_per_slot));
|
||||
}
|
||||
assert!(capacity > 0);
|
||||
assert!(bytes_per_slot > 0);
|
||||
assert!(bytes_per_slot <= MAX_FRAME_BYTES_PER_SLOT);
|
||||
|
||||
let mut slots: Vec<PoolSlotCell> = Vec::with_capacity(capacity);
|
||||
for _ in 0..capacity {
|
||||
let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice();
|
||||
assert_eq!(buf.len(), bytes_per_slot);
|
||||
slots.push(PoolSlotCell {
|
||||
inner: UnsafeCell::new(buf),
|
||||
});
|
||||
}
|
||||
assert_eq!(slots.len(), capacity);
|
||||
|
||||
let mut free: Vec<usize> = Vec::with_capacity(capacity);
|
||||
for index in 0..capacity {
|
||||
free.push(index);
|
||||
}
|
||||
assert_eq!(free.len(), capacity);
|
||||
|
||||
let cap_u32 = u32::try_from(capacity).map_err(|_| MacAudioError::ZeroCapacity)?;
|
||||
let bps_u32 = u32::try_from(bytes_per_slot).map_err(|_| MacAudioError::ZeroBytesPerSlot)?;
|
||||
let inner = MacAudioFramePoolInner {
|
||||
slots,
|
||||
free: Mutex::new(free),
|
||||
capacity: cap_u32,
|
||||
bytes_per_slot: bps_u32,
|
||||
acquired_total: AtomicU64::new(0),
|
||||
released_total: AtomicU64::new(0),
|
||||
dropped_total: AtomicU64::new(0),
|
||||
in_flight: AtomicU32::new(0),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: Arc::new(inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_acquire(&self) -> Option<PooledMacAudioFrame> {
|
||||
assert!(self.inner.capacity > 0);
|
||||
assert!(self.inner.bytes_per_slot > 0);
|
||||
|
||||
let mut free = self.inner.free.lock();
|
||||
assert!(free.len() <= self.inner.capacity as usize);
|
||||
let index = match free.pop() {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
drop(free);
|
||||
self.inner.dropped_total.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
assert!(index < self.inner.capacity as usize);
|
||||
self.inner.acquired_total.fetch_add(1, Ordering::Relaxed);
|
||||
let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
assert!(after <= self.inner.capacity);
|
||||
drop(free);
|
||||
|
||||
Some(PooledMacAudioFrame {
|
||||
slot_index: index,
|
||||
filled_len: 0,
|
||||
pool: Arc::clone(&self.inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 {
|
||||
let cap = self.inner.capacity;
|
||||
assert!(cap > 0);
|
||||
assert!(cap as usize == self.inner.slots.len());
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn bytes_per_slot(&self) -> u32 {
|
||||
let bps = self.inner.bytes_per_slot;
|
||||
assert!(bps > 0);
|
||||
assert!(bps as usize <= MAX_FRAME_BYTES_PER_SLOT);
|
||||
bps
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> MacAudioPoolStats {
|
||||
assert!(self.inner.capacity > 0);
|
||||
let in_flight = self.inner.in_flight.load(Ordering::Acquire);
|
||||
assert!(in_flight <= self.inner.capacity);
|
||||
let acquired = self.inner.acquired_total.load(Ordering::Relaxed);
|
||||
let released = self.inner.released_total.load(Ordering::Relaxed);
|
||||
let dropped = self.inner.dropped_total.load(Ordering::Relaxed);
|
||||
assert!(released <= acquired);
|
||||
MacAudioPoolStats {
|
||||
acquired,
|
||||
released,
|
||||
dropped,
|
||||
in_flight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for MacAudioFramePool {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PooledMacAudioFrame {
|
||||
slot_index: usize,
|
||||
filled_len: usize,
|
||||
pool: Arc<MacAudioFramePoolInner>,
|
||||
}
|
||||
|
||||
impl PooledMacAudioFrame {
|
||||
pub fn write(&mut self, samples: &[u8]) -> Result<(), MacAudioError> {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let cap = self.pool.bytes_per_slot as usize;
|
||||
if samples.len() > cap {
|
||||
return Err(MacAudioError::PayloadTooLarge {
|
||||
offered: samples.len(),
|
||||
capacity: cap,
|
||||
});
|
||||
}
|
||||
assert!(samples.len() <= cap);
|
||||
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), cap);
|
||||
if !samples.is_empty() {
|
||||
buf[..samples.len()].copy_from_slice(samples);
|
||||
}
|
||||
self.filled_len = samples.len();
|
||||
assert!(self.filled_len <= cap);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append(&mut self, samples: &[u8]) -> Result<(), MacAudioError> {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let cap = self.pool.bytes_per_slot as usize;
|
||||
let new_len =
|
||||
self.filled_len
|
||||
.checked_add(samples.len())
|
||||
.ok_or(MacAudioError::PayloadTooLarge {
|
||||
offered: usize::MAX,
|
||||
capacity: cap,
|
||||
})?;
|
||||
if new_len > cap {
|
||||
return Err(MacAudioError::PayloadTooLarge {
|
||||
offered: new_len,
|
||||
capacity: cap,
|
||||
});
|
||||
}
|
||||
assert!(new_len <= cap);
|
||||
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), cap);
|
||||
if !samples.is_empty() {
|
||||
buf[self.filled_len..new_len].copy_from_slice(samples);
|
||||
}
|
||||
self.filled_len = new_len;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn data_slice(&self) -> &[u8] {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &[u8] = unsafe { &*cell.inner.get() };
|
||||
&buf[..self.filled_len]
|
||||
}
|
||||
|
||||
pub fn filled_len(&self) -> usize {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
|
||||
self.filled_len
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
let cap = self.pool.bytes_per_slot as usize;
|
||||
assert!(cap > 0);
|
||||
assert!(cap <= MAX_FRAME_BYTES_PER_SLOT);
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn slot_index(&self) -> usize {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
self.slot_index
|
||||
}
|
||||
|
||||
pub fn as_mut_ptr(&mut self) -> *mut u8 {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), self.pool.bytes_per_slot as usize);
|
||||
buf.as_mut_ptr()
|
||||
}
|
||||
|
||||
pub fn into_external_parts(mut self) -> (*mut u8, usize, Self) {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
|
||||
let len = self.filled_len;
|
||||
let ptr = self.as_mut_ptr();
|
||||
assert!(!ptr.is_null());
|
||||
(ptr, len, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PooledMacAudioFrame {
|
||||
fn drop(&mut self) {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let mut free = self.pool.free.lock();
|
||||
assert!(free.len() < self.pool.capacity as usize);
|
||||
free.push(self.slot_index);
|
||||
let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
assert!(before >= 1);
|
||||
self.pool.released_total.fetch_add(1, Ordering::Relaxed);
|
||||
drop(free);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Barrier;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SLOT_BYTES: usize = 3840;
|
||||
|
||||
fn default_pool() -> MacAudioFramePool {
|
||||
MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, SLOT_BYTES).expect("default pool builds")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_capacity() {
|
||||
let err = MacAudioFramePool::new(0, SLOT_BYTES).unwrap_err();
|
||||
assert_eq!(err, MacAudioError::ZeroCapacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_bytes_per_slot() {
|
||||
let err = MacAudioFramePool::new(4, 0).unwrap_err();
|
||||
assert_eq!(err, MacAudioError::ZeroBytesPerSlot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_bytes_per_slot_above_max() {
|
||||
let err = MacAudioFramePool::new(4, MAX_FRAME_BYTES_PER_SLOT + 1).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
MacAudioError::BytesPerSlotTooLarge(MAX_FRAME_BYTES_PER_SLOT + 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_release_cycle_increments_counters() {
|
||||
let pool = MacAudioFramePool::new(4, SLOT_BYTES).expect("pool");
|
||||
let stats_before = pool.stats();
|
||||
assert_eq!(stats_before.acquired, 0);
|
||||
assert_eq!(stats_before.released, 0);
|
||||
assert_eq!(stats_before.in_flight, 0);
|
||||
{
|
||||
let _slot = pool.try_acquire().expect("slot");
|
||||
let stats_held = pool.stats();
|
||||
assert_eq!(stats_held.acquired, 1);
|
||||
assert_eq!(stats_held.in_flight, 1);
|
||||
}
|
||||
let stats_after = pool.stats();
|
||||
assert_eq!(stats_after.acquired, 1);
|
||||
assert_eq!(stats_after.released, 1);
|
||||
assert_eq!(stats_after.in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_exhausts_at_cap_seventeenth_acquire_returns_none() {
|
||||
let pool = default_pool();
|
||||
let mut held = Vec::with_capacity(MAC_AUDIO_POOL_CAP);
|
||||
for _ in 0..MAC_AUDIO_POOL_CAP {
|
||||
held.push(pool.try_acquire().expect("slot in capacity"));
|
||||
}
|
||||
assert!(pool.try_acquire().is_none());
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.dropped, 1);
|
||||
assert_eq!(stats.acquired as usize, MAC_AUDIO_POOL_CAP);
|
||||
assert_eq!(stats.in_flight as usize, MAC_AUDIO_POOL_CAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_returns_slot_to_free_list() {
|
||||
let pool = MacAudioFramePool::new(2, SLOT_BYTES).expect("pool");
|
||||
let first = pool.try_acquire().expect("first");
|
||||
let second = pool.try_acquire().expect("second");
|
||||
assert!(pool.try_acquire().is_none());
|
||||
drop(first);
|
||||
let revived = pool.try_acquire().expect("revived");
|
||||
assert_eq!(pool.stats().in_flight, 2);
|
||||
drop(second);
|
||||
drop(revived);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_data_slice_matches_payload() {
|
||||
let pool = MacAudioFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let payload = [0xAB_u8; 32];
|
||||
slot.write(&payload).expect("payload fits");
|
||||
assert_eq!(slot.data_slice(), &payload[..]);
|
||||
assert_eq!(slot.filled_len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_payload_larger_than_slot() {
|
||||
let pool = MacAudioFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let big = [0u8; 128];
|
||||
let err = slot.write(&big).unwrap_err();
|
||||
assert!(matches!(err, MacAudioError::PayloadTooLarge { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_concatenates_planar_to_interleaved_like_payload() {
|
||||
let pool = MacAudioFramePool::new(1, 32).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let left = [1_u8, 2, 3, 4];
|
||||
let right = [5_u8, 6, 7, 8];
|
||||
slot.append(&left).expect("left fits");
|
||||
slot.append(&right).expect("right fits");
|
||||
assert_eq!(slot.data_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert_eq!(slot.filled_len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_rejects_overflow() {
|
||||
let pool = MacAudioFramePool::new(1, 4).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.append(&[1, 2, 3]).expect("3 of 4 fits");
|
||||
let err = slot.append(&[4, 5]).unwrap_err();
|
||||
assert!(matches!(err, MacAudioError::PayloadTooLarge { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycled_slot_persists_buffer_storage_until_overwritten() {
|
||||
let pool = MacAudioFramePool::new(1, 16).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("first");
|
||||
let payload = [0x42_u8; 8];
|
||||
slot.write(&payload).expect("write");
|
||||
let observed_first = slot.data_slice().to_vec();
|
||||
drop(slot);
|
||||
let slot2 = pool.try_acquire().expect("recycled");
|
||||
assert_eq!(slot2.filled_len(), 0);
|
||||
assert_eq!(observed_first, payload.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_thread_acquire_release_stress_8_threads_1000_ops_no_deadlock() {
|
||||
const THREADS: usize = 8;
|
||||
const OPS_PER_THREAD: usize = 1000;
|
||||
const POOL_CAP: usize = 4;
|
||||
|
||||
let pool = Arc::new(MacAudioFramePool::new(POOL_CAP, 64).expect("pool"));
|
||||
let barrier = Arc::new(Barrier::new(THREADS));
|
||||
let acquired_obs = Arc::new(AtomicUsize::new(0));
|
||||
let dropped_obs = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let mut handles = Vec::with_capacity(THREADS);
|
||||
for _ in 0..THREADS {
|
||||
let pool = Arc::clone(&pool);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let acquired_obs = Arc::clone(&acquired_obs);
|
||||
let dropped_obs = Arc::clone(&dropped_obs);
|
||||
handles.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
let mut local_acq: usize = 0;
|
||||
let mut local_drop: usize = 0;
|
||||
for _ in 0..OPS_PER_THREAD {
|
||||
match pool.try_acquire() {
|
||||
Some(mut slot) => {
|
||||
local_acq += 1;
|
||||
let _ = slot.write(&[0xAA; 16]);
|
||||
drop(slot);
|
||||
}
|
||||
None => {
|
||||
local_drop += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
acquired_obs.fetch_add(local_acq, Ordering::Relaxed);
|
||||
dropped_obs.fetch_add(local_drop, Ordering::Relaxed);
|
||||
}));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
for h in handles {
|
||||
assert!(Instant::now() < deadline, "stress test exceeded 30s budget");
|
||||
h.join().expect("worker panicked");
|
||||
}
|
||||
|
||||
let stats = pool.stats();
|
||||
let total = stats.acquired + stats.dropped;
|
||||
assert_eq!(total as usize, THREADS * OPS_PER_THREAD);
|
||||
assert_eq!(
|
||||
stats.acquired as usize,
|
||||
acquired_obs.load(Ordering::Relaxed)
|
||||
);
|
||||
assert_eq!(stats.dropped as usize, dropped_obs.load(Ordering::Relaxed));
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
assert_eq!(stats.acquired, stats.released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_pool_dimensions_match_constants() {
|
||||
let pool = default_pool();
|
||||
assert_eq!(pool.capacity() as usize, MAC_AUDIO_POOL_CAP);
|
||||
assert_eq!(pool.bytes_per_slot() as usize, SLOT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_in_flight_matches_simultaneous_holders() {
|
||||
let pool = MacAudioFramePool::new(8, 64).expect("pool");
|
||||
let a = pool.try_acquire().expect("a");
|
||||
let b = pool.try_acquire().expect("b");
|
||||
let c = pool.try_acquire().expect("c");
|
||||
assert_eq!(pool.stats().in_flight, 3);
|
||||
drop(b);
|
||||
assert_eq!(pool.stats().in_flight, 2);
|
||||
drop(a);
|
||||
drop(c);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_external_parts_exposes_filled_pointer_and_length() {
|
||||
let pool = MacAudioFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let payload = [0x5A_u8; 16];
|
||||
slot.write(&payload).expect("write fits");
|
||||
let (ptr, len, owned) = slot.into_external_parts();
|
||||
assert!(!ptr.is_null());
|
||||
assert_eq!(len, 16);
|
||||
let observed = unsafe { core::slice::from_raw_parts(ptr, len) };
|
||||
assert_eq!(observed, &payload[..]);
|
||||
assert_eq!(pool.stats().in_flight, 1);
|
||||
drop(owned);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_external_parts_drop_returns_slot_to_pool() {
|
||||
let pool = MacAudioFramePool::new(1, 32).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[0xCC_u8; 8]).expect("write");
|
||||
let (_ptr, _len, owned) = slot.into_external_parts();
|
||||
assert!(pool.try_acquire().is_none());
|
||||
drop(owned);
|
||||
let revived = pool.try_acquire().expect("revived");
|
||||
assert_eq!(revived.filled_len(), 0);
|
||||
drop(revived);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_external_parts_holds_slot_across_send() {
|
||||
let pool = MacAudioFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[0x77_u8; 32]).expect("write");
|
||||
let (ptr, len, owned) = slot.into_external_parts();
|
||||
let ptr_addr = ptr as usize;
|
||||
let handle = thread::spawn(move || {
|
||||
let owned = owned;
|
||||
assert_eq!(owned.filled_len(), 32);
|
||||
let observed = unsafe { core::slice::from_raw_parts(ptr_addr as *const u8, len) };
|
||||
assert_eq!(observed[0], 0x77);
|
||||
drop(owned);
|
||||
});
|
||||
handle.join().expect("worker");
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_mut_ptr_returns_slot_base_address() {
|
||||
let pool = MacAudioFramePool::new(1, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let payload = [0x11_u8; 8];
|
||||
slot.write(&payload).expect("write");
|
||||
let ptr = slot.as_mut_ptr();
|
||||
assert!(!ptr.is_null());
|
||||
unsafe { ptr.add(0).write(0x22) };
|
||||
unsafe { ptr.add(1).write(0x33) };
|
||||
assert_eq!(&slot.data_slice()[..2], &[0x22, 0x33]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_one_pool_round_trips() {
|
||||
let pool = MacAudioFramePool::new(1, 32).expect("pool");
|
||||
for _ in 0..5 {
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[1, 2, 3]).expect("write");
|
||||
assert_eq!(slot.data_slice(), &[1, 2, 3]);
|
||||
drop(slot);
|
||||
}
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.acquired, 5);
|
||||
assert_eq!(stats.released, 5);
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub const FPS_MIN: u32 = 1;
|
||||
pub const FPS_MAX: u32 = 120;
|
||||
pub const MAX_OUTPUT_WIDTH_DEFAULT: u32 = 3840;
|
||||
pub const MAX_OUTPUT_HEIGHT_DEFAULT: u32 = 2160;
|
||||
pub const OUTPUT_DIMENSION_MIN: u32 = 2;
|
||||
pub const QUEUE_DEPTH_MIN: u32 = 1;
|
||||
pub const QUEUE_DEPTH_MAX: u32 = 16;
|
||||
pub const QUEUE_DEPTH_DEFAULT: u32 = 8;
|
||||
pub const FPS_DEFAULT: u32 = 30;
|
||||
pub const FRAME_INTERVAL_FACTOR_NUM: u64 = 9;
|
||||
pub const FRAME_INTERVAL_FACTOR_DEN: u64 = 10;
|
||||
|
||||
pub const PIXEL_FORMAT_BGRA_FOURCC: u32 = u32::from_be_bytes(*b"BGRA");
|
||||
pub const PIXEL_FORMAT_L10R_FOURCC: u32 = u32::from_be_bytes(*b"l10r");
|
||||
pub const PIXEL_FORMAT_420V_FOURCC: u32 = u32::from_be_bytes(*b"420v");
|
||||
pub const PIXEL_FORMAT_420F_FOURCC: u32 = u32::from_be_bytes(*b"420f");
|
||||
|
||||
pub const AUDIO_SAMPLE_RATE_DEFAULT_HZ: u32 = 48_000;
|
||||
pub const AUDIO_CHANNEL_COUNT_DEFAULT: u32 = 2;
|
||||
pub const AUDIO_SAMPLE_RATE_MIN_HZ: u32 = 8_000;
|
||||
pub const AUDIO_SAMPLE_RATE_MAX_HZ: u32 = 192_000;
|
||||
pub const AUDIO_CHANNEL_COUNT_MIN: u32 = 1;
|
||||
pub const AUDIO_CHANNEL_COUNT_MAX: u32 = 8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SckPixelFormat {
|
||||
Bgra8,
|
||||
L10rHdr,
|
||||
Nv12VideoRange,
|
||||
Nv12FullRange,
|
||||
}
|
||||
|
||||
impl SckPixelFormat {
|
||||
pub fn as_fourcc(self) -> u32 {
|
||||
let value = match self {
|
||||
SckPixelFormat::Bgra8 => PIXEL_FORMAT_BGRA_FOURCC,
|
||||
SckPixelFormat::L10rHdr => PIXEL_FORMAT_L10R_FOURCC,
|
||||
SckPixelFormat::Nv12VideoRange => PIXEL_FORMAT_420V_FOURCC,
|
||||
SckPixelFormat::Nv12FullRange => PIXEL_FORMAT_420F_FOURCC,
|
||||
};
|
||||
assert!(value != 0);
|
||||
value
|
||||
}
|
||||
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(self, SckPixelFormat::L10rHdr)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SckColorSpace {
|
||||
DisplayP3,
|
||||
SrgbBt709,
|
||||
}
|
||||
|
||||
impl SckColorSpace {
|
||||
pub fn as_cf_name(self) -> &'static str {
|
||||
let name = match self {
|
||||
SckColorSpace::DisplayP3 => "kCGColorSpaceDisplayP3",
|
||||
SckColorSpace::SrgbBt709 => "kCGColorSpaceSRGB",
|
||||
};
|
||||
assert!(!name.is_empty());
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SckError {
|
||||
InvalidFps(u32),
|
||||
InvalidQueueDepth(u32),
|
||||
HdrRequiresWideColorSpace,
|
||||
InvalidAudioSampleRate(u32),
|
||||
InvalidAudioChannelCount(u32),
|
||||
}
|
||||
|
||||
impl fmt::Display for SckError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SckError::InvalidFps(v) => write!(
|
||||
f,
|
||||
"SckCaptureConfig: target_fps={v} out of range [{FPS_MIN}..={FPS_MAX}]"
|
||||
),
|
||||
SckError::InvalidQueueDepth(v) => write!(
|
||||
f,
|
||||
"SckCaptureConfig: queue_depth={v} out of range [{QUEUE_DEPTH_MIN}..={QUEUE_DEPTH_MAX}]"
|
||||
),
|
||||
SckError::HdrRequiresWideColorSpace => write!(
|
||||
f,
|
||||
"SckCaptureConfig: l10r HDR pixel format requires DisplayP3 color space"
|
||||
),
|
||||
SckError::InvalidAudioSampleRate(v) => write!(
|
||||
f,
|
||||
"SckCaptureConfig: audio_sample_rate_hz={v} out of range [{AUDIO_SAMPLE_RATE_MIN_HZ}..={AUDIO_SAMPLE_RATE_MAX_HZ}]"
|
||||
),
|
||||
SckError::InvalidAudioChannelCount(v) => write!(
|
||||
f,
|
||||
"SckCaptureConfig: audio_channels={v} out of range [{AUDIO_CHANNEL_COUNT_MIN}..={AUDIO_CHANNEL_COUNT_MAX}]"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SckError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SckCaptureConfig {
|
||||
target_fps: u32,
|
||||
queue_depth: u32,
|
||||
pixel_format: SckPixelFormat,
|
||||
color_space: SckColorSpace,
|
||||
captures_audio: bool,
|
||||
audio_sample_rate_hz: u32,
|
||||
audio_channels: u32,
|
||||
}
|
||||
|
||||
impl SckCaptureConfig {
|
||||
pub fn new(
|
||||
target_fps: u32,
|
||||
queue_depth: u32,
|
||||
pixel_format: SckPixelFormat,
|
||||
color_space: SckColorSpace,
|
||||
) -> Result<Self, SckError> {
|
||||
Self::new_with_audio(
|
||||
target_fps,
|
||||
queue_depth,
|
||||
pixel_format,
|
||||
color_space,
|
||||
false,
|
||||
AUDIO_SAMPLE_RATE_DEFAULT_HZ,
|
||||
AUDIO_CHANNEL_COUNT_DEFAULT,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_audio(
|
||||
target_fps: u32,
|
||||
queue_depth: u32,
|
||||
pixel_format: SckPixelFormat,
|
||||
color_space: SckColorSpace,
|
||||
captures_audio: bool,
|
||||
audio_sample_rate_hz: u32,
|
||||
audio_channels: u32,
|
||||
) -> Result<Self, SckError> {
|
||||
if !(FPS_MIN..=FPS_MAX).contains(&target_fps) {
|
||||
return Err(SckError::InvalidFps(target_fps));
|
||||
}
|
||||
if !(QUEUE_DEPTH_MIN..=QUEUE_DEPTH_MAX).contains(&queue_depth) {
|
||||
return Err(SckError::InvalidQueueDepth(queue_depth));
|
||||
}
|
||||
if pixel_format.is_hdr() && color_space != SckColorSpace::DisplayP3 {
|
||||
return Err(SckError::HdrRequiresWideColorSpace);
|
||||
}
|
||||
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&audio_sample_rate_hz) {
|
||||
return Err(SckError::InvalidAudioSampleRate(audio_sample_rate_hz));
|
||||
}
|
||||
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&audio_channels) {
|
||||
return Err(SckError::InvalidAudioChannelCount(audio_channels));
|
||||
}
|
||||
let cfg = Self {
|
||||
target_fps,
|
||||
queue_depth,
|
||||
pixel_format,
|
||||
color_space,
|
||||
captures_audio,
|
||||
audio_sample_rate_hz,
|
||||
audio_channels,
|
||||
};
|
||||
assert!(cfg.target_fps >= FPS_MIN);
|
||||
assert!(cfg.target_fps <= FPS_MAX);
|
||||
assert!(cfg.queue_depth >= QUEUE_DEPTH_MIN);
|
||||
assert!(cfg.queue_depth <= QUEUE_DEPTH_MAX);
|
||||
assert!(cfg.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
|
||||
assert!(cfg.audio_channels >= AUDIO_CHANNEL_COUNT_MIN);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn builder() -> SckCaptureConfigBuilder {
|
||||
SckCaptureConfigBuilder::default()
|
||||
}
|
||||
|
||||
pub fn target_fps(&self) -> u32 {
|
||||
assert!(self.target_fps >= FPS_MIN);
|
||||
assert!(self.target_fps <= FPS_MAX);
|
||||
self.target_fps
|
||||
}
|
||||
|
||||
pub fn queue_depth(&self) -> u32 {
|
||||
assert!(self.queue_depth >= QUEUE_DEPTH_MIN);
|
||||
assert!(self.queue_depth <= QUEUE_DEPTH_MAX);
|
||||
self.queue_depth
|
||||
}
|
||||
|
||||
pub fn pixel_format(&self) -> SckPixelFormat {
|
||||
let pf = self.pixel_format;
|
||||
assert!(pf.as_fourcc() != 0);
|
||||
pf
|
||||
}
|
||||
|
||||
pub fn color_space(&self) -> SckColorSpace {
|
||||
let cs = self.color_space;
|
||||
assert!(!cs.as_cf_name().is_empty());
|
||||
cs
|
||||
}
|
||||
|
||||
pub fn minimum_frame_interval_ns(&self) -> u64 {
|
||||
assert!(self.target_fps >= FPS_MIN);
|
||||
assert!(self.target_fps <= FPS_MAX);
|
||||
let base_ns: u64 = 1_000_000_000 / (self.target_fps as u64);
|
||||
let scaled = base_ns * FRAME_INTERVAL_FACTOR_NUM / FRAME_INTERVAL_FACTOR_DEN;
|
||||
assert!(scaled > 0);
|
||||
assert!(scaled <= 1_000_000_000);
|
||||
scaled
|
||||
}
|
||||
|
||||
pub fn captures_audio(&self) -> bool {
|
||||
self.captures_audio
|
||||
}
|
||||
|
||||
pub fn audio_sample_rate_hz(&self) -> u32 {
|
||||
assert!(self.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
|
||||
assert!(self.audio_sample_rate_hz <= AUDIO_SAMPLE_RATE_MAX_HZ);
|
||||
self.audio_sample_rate_hz
|
||||
}
|
||||
|
||||
pub fn audio_channels(&self) -> u32 {
|
||||
assert!(self.audio_channels >= AUDIO_CHANNEL_COUNT_MIN);
|
||||
assert!(self.audio_channels <= AUDIO_CHANNEL_COUNT_MAX);
|
||||
self.audio_channels
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SckCaptureConfig {
|
||||
fn default() -> Self {
|
||||
let cfg = Self::new_with_audio(
|
||||
FPS_DEFAULT,
|
||||
QUEUE_DEPTH_DEFAULT,
|
||||
SckPixelFormat::Nv12VideoRange,
|
||||
SckColorSpace::SrgbBt709,
|
||||
false,
|
||||
AUDIO_SAMPLE_RATE_DEFAULT_HZ,
|
||||
AUDIO_CHANNEL_COUNT_DEFAULT,
|
||||
);
|
||||
match cfg {
|
||||
Ok(c) => c,
|
||||
Err(_) => unreachable!("default SckCaptureConfig must validate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SckCaptureConfigBuilder {
|
||||
target_fps: u32,
|
||||
queue_depth: u32,
|
||||
pixel_format: SckPixelFormat,
|
||||
color_space: SckColorSpace,
|
||||
captures_audio: bool,
|
||||
audio_sample_rate_hz: u32,
|
||||
audio_channels: u32,
|
||||
}
|
||||
|
||||
impl Default for SckCaptureConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_fps: FPS_DEFAULT,
|
||||
queue_depth: QUEUE_DEPTH_DEFAULT,
|
||||
pixel_format: SckPixelFormat::Nv12VideoRange,
|
||||
color_space: SckColorSpace::SrgbBt709,
|
||||
captures_audio: false,
|
||||
audio_sample_rate_hz: AUDIO_SAMPLE_RATE_DEFAULT_HZ,
|
||||
audio_channels: AUDIO_CHANNEL_COUNT_DEFAULT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SckCaptureConfigBuilder {
|
||||
pub fn target_fps(mut self, target_fps: u32) -> Self {
|
||||
self.target_fps = target_fps;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn queue_depth(mut self, queue_depth: u32) -> Self {
|
||||
self.queue_depth = queue_depth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pixel_format(mut self, pixel_format: SckPixelFormat) -> Self {
|
||||
self.pixel_format = pixel_format;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color_space(mut self, color_space: SckColorSpace) -> Self {
|
||||
self.color_space = color_space;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn captures_audio(mut self, captures_audio: bool) -> Self {
|
||||
self.captures_audio = captures_audio;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn audio_sample_rate_hz(mut self, audio_sample_rate_hz: u32) -> Self {
|
||||
self.audio_sample_rate_hz = audio_sample_rate_hz;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn audio_channels(mut self, audio_channels: u32) -> Self {
|
||||
self.audio_channels = audio_channels;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<SckCaptureConfig, SckError> {
|
||||
SckCaptureConfig::new_with_audio(
|
||||
self.target_fps,
|
||||
self.queue_depth,
|
||||
self.pixel_format,
|
||||
self.color_space,
|
||||
self.captures_audio,
|
||||
self.audio_sample_rate_hz,
|
||||
self.audio_channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SckCaptureFailure {
|
||||
StreamStoppedWithError(String),
|
||||
StreamStartFailed(String),
|
||||
SystemDeniedAccess,
|
||||
DisplayDisconnected,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl SckCaptureFailure {
|
||||
pub fn reason(&self) -> &str {
|
||||
match self {
|
||||
SckCaptureFailure::StreamStoppedWithError(m) => m.as_str(),
|
||||
SckCaptureFailure::StreamStartFailed(m) => m.as_str(),
|
||||
SckCaptureFailure::SystemDeniedAccess => "screen recording permission denied",
|
||||
SckCaptureFailure::DisplayDisconnected => "captured display was disconnected",
|
||||
SckCaptureFailure::Unknown(m) => m.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CaptureFailureSurface: Send + Sync {
|
||||
fn on_failure(&self, reason: SckCaptureFailure);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
pub enum AudioSampleFormat {
|
||||
F32Planar = 0,
|
||||
F32Interleaved = 1,
|
||||
I16Interleaved = 2,
|
||||
Unknown = 3,
|
||||
}
|
||||
|
||||
pub const AUDIO_SAMPLE_FORMAT_CODE_MAX: u32 = 3;
|
||||
|
||||
impl AudioSampleFormat {
|
||||
pub fn code(self) -> u32 {
|
||||
let code = match self {
|
||||
AudioSampleFormat::F32Planar => 0,
|
||||
AudioSampleFormat::F32Interleaved => 1,
|
||||
AudioSampleFormat::I16Interleaved => 2,
|
||||
AudioSampleFormat::Unknown => 3,
|
||||
};
|
||||
assert!(code <= AUDIO_SAMPLE_FORMAT_CODE_MAX);
|
||||
assert_eq!(code, self as u32);
|
||||
code
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
let s = match self {
|
||||
AudioSampleFormat::F32Planar => "f32_planar",
|
||||
AudioSampleFormat::F32Interleaved => "f32_interleaved",
|
||||
AudioSampleFormat::I16Interleaved => "i16_interleaved",
|
||||
AudioSampleFormat::Unknown => "unknown",
|
||||
};
|
||||
assert!(!s.is_empty());
|
||||
s
|
||||
}
|
||||
|
||||
pub fn bytes_per_sample(self) -> u32 {
|
||||
let bytes = match self {
|
||||
AudioSampleFormat::F32Planar => 4,
|
||||
AudioSampleFormat::F32Interleaved => 4,
|
||||
AudioSampleFormat::I16Interleaved => 2,
|
||||
AudioSampleFormat::Unknown => 0,
|
||||
};
|
||||
assert!(bytes <= 4);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MacScreenShareAudioFrame {
|
||||
pub sample_rate_hz: u32,
|
||||
pub channels: u32,
|
||||
pub num_samples_per_channel: u32,
|
||||
pub pts_us: i64,
|
||||
}
|
||||
|
||||
impl MacScreenShareAudioFrame {
|
||||
pub fn new(
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
num_samples_per_channel: u32,
|
||||
pts_us: i64,
|
||||
) -> Result<Self, SckError> {
|
||||
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) {
|
||||
return Err(SckError::InvalidAudioSampleRate(sample_rate_hz));
|
||||
}
|
||||
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) {
|
||||
return Err(SckError::InvalidAudioChannelCount(channels));
|
||||
}
|
||||
assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
|
||||
assert!(channels >= AUDIO_CHANNEL_COUNT_MIN);
|
||||
Ok(Self {
|
||||
sample_rate_hz,
|
||||
channels,
|
||||
num_samples_per_channel,
|
||||
pts_us,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MacScreenShareAudioFrameWithBytes {
|
||||
pub sample_rate_hz: u32,
|
||||
pub channels: u32,
|
||||
pub num_samples_per_channel: u32,
|
||||
pub pts_us: i64,
|
||||
pub format: AudioSampleFormat,
|
||||
pub samples: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MacScreenShareAudioFrameWithBytes {
|
||||
pub fn new(
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
num_samples_per_channel: u32,
|
||||
pts_us: i64,
|
||||
format: AudioSampleFormat,
|
||||
samples: Vec<u8>,
|
||||
) -> Result<Self, SckError> {
|
||||
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) {
|
||||
return Err(SckError::InvalidAudioSampleRate(sample_rate_hz));
|
||||
}
|
||||
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) {
|
||||
return Err(SckError::InvalidAudioChannelCount(channels));
|
||||
}
|
||||
assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
|
||||
assert!(channels >= AUDIO_CHANNEL_COUNT_MIN);
|
||||
Ok(Self {
|
||||
sample_rate_hz,
|
||||
channels,
|
||||
num_samples_per_channel,
|
||||
pts_us,
|
||||
format,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builder_defaults_produce_valid_config() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.build()
|
||||
.expect("default builder");
|
||||
assert_eq!(cfg.target_fps(), FPS_DEFAULT);
|
||||
assert_eq!(cfg.queue_depth(), QUEUE_DEPTH_DEFAULT);
|
||||
assert_eq!(cfg.pixel_format(), SckPixelFormat::Nv12VideoRange);
|
||||
assert_eq!(cfg.color_space(), SckColorSpace::SrgbBt709);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_fps_zero() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.target_fps(0)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidFps(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_fps_above_max() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.target_fps(FPS_MAX + 1)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidFps(FPS_MAX + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_accepts_fps_120() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.target_fps(FPS_MAX)
|
||||
.build()
|
||||
.expect("120 fps ok");
|
||||
assert_eq!(cfg.target_fps(), FPS_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_queue_depth_zero() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.queue_depth(0)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidQueueDepth(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_queue_depth_above_max() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.queue_depth(QUEUE_DEPTH_MAX + 1)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidQueueDepth(QUEUE_DEPTH_MAX + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_hdr_without_displayp3() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.pixel_format(SckPixelFormat::L10rHdr)
|
||||
.color_space(SckColorSpace::SrgbBt709)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::HdrRequiresWideColorSpace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_accepts_hdr_with_displayp3() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.pixel_format(SckPixelFormat::L10rHdr)
|
||||
.color_space(SckColorSpace::DisplayP3)
|
||||
.build()
|
||||
.expect("hdr with p3 ok");
|
||||
assert!(cfg.pixel_format().is_hdr());
|
||||
assert_eq!(cfg.color_space(), SckColorSpace::DisplayP3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_frame_interval_60_fps_is_15_ms() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.target_fps(60)
|
||||
.build()
|
||||
.expect("60 fps");
|
||||
let ns = cfg.minimum_frame_interval_ns();
|
||||
let expected = (1_000_000_000_u64 / 60) * 9 / 10;
|
||||
assert_eq!(ns, expected);
|
||||
assert!((14_000_000..=16_000_000).contains(&ns));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_frame_interval_30_fps_factor_applied() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.target_fps(30)
|
||||
.build()
|
||||
.expect("30 fps");
|
||||
let ns = cfg.minimum_frame_interval_ns();
|
||||
let base = 1_000_000_000_u64 / 30;
|
||||
assert_eq!(ns, base * 9 / 10);
|
||||
assert!(ns < base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_frame_interval_120_fps_under_8_3ms() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.target_fps(120)
|
||||
.build()
|
||||
.expect("120 fps");
|
||||
let ns = cfg.minimum_frame_interval_ns();
|
||||
assert!(ns < 8_400_000);
|
||||
assert!(ns > 6_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_fourcc_matches_obs_constants() {
|
||||
assert_eq!(SckPixelFormat::Bgra8.as_fourcc(), PIXEL_FORMAT_BGRA_FOURCC);
|
||||
assert_eq!(
|
||||
SckPixelFormat::L10rHdr.as_fourcc(),
|
||||
PIXEL_FORMAT_L10R_FOURCC
|
||||
);
|
||||
assert_eq!(
|
||||
SckPixelFormat::Nv12VideoRange.as_fourcc(),
|
||||
PIXEL_FORMAT_420V_FOURCC
|
||||
);
|
||||
assert_eq!(
|
||||
SckPixelFormat::Nv12FullRange.as_fourcc(),
|
||||
PIXEL_FORMAT_420F_FOURCC
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_names_present() {
|
||||
assert_eq!(
|
||||
SckColorSpace::DisplayP3.as_cf_name(),
|
||||
"kCGColorSpaceDisplayP3"
|
||||
);
|
||||
assert_eq!(SckColorSpace::SrgbBt709.as_cf_name(), "kCGColorSpaceSRGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_reason_strings_non_empty() {
|
||||
let f = SckCaptureFailure::StreamStoppedWithError("oops".into());
|
||||
assert_eq!(f.reason(), "oops");
|
||||
let f = SckCaptureFailure::SystemDeniedAccess;
|
||||
assert!(!f.reason().is_empty());
|
||||
let f = SckCaptureFailure::DisplayDisconnected;
|
||||
assert!(!f.reason().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_validates() {
|
||||
let cfg = SckCaptureConfig::default();
|
||||
assert_eq!(cfg.target_fps(), FPS_DEFAULT);
|
||||
assert_eq!(cfg.queue_depth(), QUEUE_DEPTH_DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_audio_defaults_to_false() {
|
||||
let cfg = SckCaptureConfig::default();
|
||||
assert!(!cfg.captures_audio());
|
||||
assert_eq!(cfg.audio_sample_rate_hz(), AUDIO_SAMPLE_RATE_DEFAULT_HZ);
|
||||
assert_eq!(cfg.audio_channels(), AUDIO_CHANNEL_COUNT_DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_captures_audio_toggle_preserves_frame_interval() {
|
||||
let cfg_off = SckCaptureConfig::builder()
|
||||
.target_fps(60)
|
||||
.build()
|
||||
.expect("60 fps off");
|
||||
let cfg_on = SckCaptureConfig::builder()
|
||||
.target_fps(60)
|
||||
.captures_audio(true)
|
||||
.build()
|
||||
.expect("60 fps on");
|
||||
assert!(!cfg_off.captures_audio());
|
||||
assert!(cfg_on.captures_audio());
|
||||
assert_eq!(
|
||||
cfg_off.minimum_frame_interval_ns(),
|
||||
cfg_on.minimum_frame_interval_ns(),
|
||||
"captures_audio toggle must not alter minimum_frame_interval_ns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_audio_sample_rate_zero() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.audio_sample_rate_hz(0)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_rejects_audio_channel_count_zero() {
|
||||
let err = SckCaptureConfig::builder()
|
||||
.audio_channels(0)
|
||||
.build()
|
||||
.unwrap_err();
|
||||
assert_eq!(err, SckError::InvalidAudioChannelCount(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_accepts_48khz_stereo_audio() {
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.captures_audio(true)
|
||||
.audio_sample_rate_hz(48_000)
|
||||
.audio_channels(2)
|
||||
.build()
|
||||
.expect("48k stereo ok");
|
||||
assert!(cfg.captures_audio());
|
||||
assert_eq!(cfg.audio_sample_rate_hz(), 48_000);
|
||||
assert_eq!(cfg.audio_channels(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_screen_share_audio_frame_constructs_valid() {
|
||||
let frame =
|
||||
MacScreenShareAudioFrame::new(48_000, 2, 1024, 12_345).expect("valid audio frame");
|
||||
assert_eq!(frame.sample_rate_hz, 48_000);
|
||||
assert_eq!(frame.channels, 2);
|
||||
assert_eq!(frame.num_samples_per_channel, 1024);
|
||||
assert_eq!(frame.pts_us, 12_345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_screen_share_audio_frame_rejects_invalid_sample_rate() {
|
||||
let err = MacScreenShareAudioFrame::new(0, 2, 1024, 0).expect_err("0 hz invalid");
|
||||
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_screen_share_audio_frame_rejects_too_many_channels() {
|
||||
let err = MacScreenShareAudioFrame::new(48_000, 16, 1024, 0).expect_err("16 ch invalid");
|
||||
assert_eq!(err, SckError::InvalidAudioChannelCount(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sample_format_strings_and_widths() {
|
||||
assert_eq!(AudioSampleFormat::F32Planar.as_str(), "f32_planar");
|
||||
assert_eq!(
|
||||
AudioSampleFormat::F32Interleaved.as_str(),
|
||||
"f32_interleaved"
|
||||
);
|
||||
assert_eq!(
|
||||
AudioSampleFormat::I16Interleaved.as_str(),
|
||||
"i16_interleaved"
|
||||
);
|
||||
assert_eq!(AudioSampleFormat::Unknown.as_str(), "unknown");
|
||||
assert_eq!(AudioSampleFormat::F32Planar.bytes_per_sample(), 4);
|
||||
assert_eq!(AudioSampleFormat::F32Interleaved.bytes_per_sample(), 4);
|
||||
assert_eq!(AudioSampleFormat::I16Interleaved.bytes_per_sample(), 2);
|
||||
assert_eq!(AudioSampleFormat::Unknown.bytes_per_sample(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sample_format_codes_are_stable_and_bounded() {
|
||||
assert_eq!(AudioSampleFormat::F32Planar.code(), 0);
|
||||
assert_eq!(AudioSampleFormat::F32Interleaved.code(), 1);
|
||||
assert_eq!(AudioSampleFormat::I16Interleaved.code(), 2);
|
||||
assert_eq!(AudioSampleFormat::Unknown.code(), 3);
|
||||
assert!(AudioSampleFormat::Unknown.code() <= AUDIO_SAMPLE_FORMAT_CODE_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_screen_share_audio_frame_with_bytes_round_trip() {
|
||||
let payload = vec![0xAA_u8; 16];
|
||||
let f = MacScreenShareAudioFrameWithBytes::new(
|
||||
48_000,
|
||||
2,
|
||||
4,
|
||||
999,
|
||||
AudioSampleFormat::F32Planar,
|
||||
payload.clone(),
|
||||
)
|
||||
.expect("valid frame with bytes");
|
||||
assert_eq!(f.sample_rate_hz, 48_000);
|
||||
assert_eq!(f.channels, 2);
|
||||
assert_eq!(f.num_samples_per_channel, 4);
|
||||
assert_eq!(f.pts_us, 999);
|
||||
assert_eq!(f.format, AudioSampleFormat::F32Planar);
|
||||
assert_eq!(f.samples, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_screen_share_audio_frame_with_bytes_rejects_bad_sample_rate() {
|
||||
let err = MacScreenShareAudioFrameWithBytes::new(
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
AudioSampleFormat::F32Planar,
|
||||
Vec::new(),
|
||||
)
|
||||
.expect_err("zero hz invalid");
|
||||
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
|
||||
pub const CAPTURE_PTS_MAP_CAP: usize = RING_SIZE;
|
||||
pub const EXTERNAL_SURFACE_QUEUE_CAP: usize = RING_SIZE;
|
||||
pub const READY_WAIT_TIMEOUT_US_MAX: u64 = 16_667;
|
||||
|
||||
use fluxer_encoder_ring::{
|
||||
EncoderFrameRate, EncoderInputRing, EncoderReady, IoSurfaceSlotHandle,
|
||||
MetalSharedTextureBackend, RING_SIZE, RingError, TextureFormat,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use fluxer_encoder_ring::FillReservation;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use fluxer_encoder_ring::VtPixelTransfer;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::iosurface_pair::{IoSurfaceRaw, iosurface_decrement_use_count};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncoderAttachError {
|
||||
AlreadyAttached,
|
||||
InvalidDimensions { width: u32, height: u32 },
|
||||
RingInitFailed,
|
||||
NotAttached,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EncoderAttachError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AlreadyAttached => write!(f, "encoder ring already attached"),
|
||||
Self::InvalidDimensions { width, height } => {
|
||||
write!(f, "encoder ring invalid dimensions {width}x{height}")
|
||||
}
|
||||
Self::RingInitFailed => write!(f, "encoder ring initialise failed"),
|
||||
Self::NotAttached => write!(f, "encoder ring not attached"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EncoderAttachError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct EncoderAttachStats {
|
||||
pub frames_submitted: u64,
|
||||
pub frames_dropped: u64,
|
||||
pub ring_full_events: u64,
|
||||
pub failed_blits: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub struct ExternalSurfaceFrame {
|
||||
surface: IoSurfaceRaw,
|
||||
sequence: u64,
|
||||
capture_pts_us: i64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe impl Send for ExternalSurfaceFrame {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl ExternalSurfaceFrame {
|
||||
pub fn surface(&self) -> IoSurfaceRaw {
|
||||
assert!(self.sequence > 0, "external frame sequence starts at one");
|
||||
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
|
||||
self.surface
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> u64 {
|
||||
assert!(self.sequence > 0, "external frame sequence starts at one");
|
||||
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
|
||||
self.sequence
|
||||
}
|
||||
|
||||
pub fn capture_pts_us(&self) -> i64 {
|
||||
assert!(self.sequence > 0, "external frame sequence starts at one");
|
||||
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
|
||||
self.capture_pts_us
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for ExternalSurfaceFrame {
|
||||
fn drop(&mut self) {
|
||||
unsafe { iosurface_decrement_use_count(self.surface) };
|
||||
unsafe { CFRelease(self.surface.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct BlitDestination {
|
||||
pixel_buffer: *mut core::ffi::c_void,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe impl Send for BlitDestination {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe impl Sync for BlitDestination {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for BlitDestination {
|
||||
fn drop(&mut self) {
|
||||
unsafe { release_pixel_buffer(self.pixel_buffer) };
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EncoderAttachment {
|
||||
ring: Mutex<EncoderInputRing<MetalSharedTextureBackend>>,
|
||||
ready_condvar: Condvar,
|
||||
attached: AtomicBool,
|
||||
frames_submitted: AtomicU64,
|
||||
frames_dropped: AtomicU64,
|
||||
ring_full_events: AtomicU64,
|
||||
failed_blits: AtomicU64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_rate: EncoderFrameRate,
|
||||
capture_pts_by_sequence: Mutex<VecDeque<(u64, i64)>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_transfer: Mutex<Option<VtPixelTransfer>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
blit_destinations: Vec<BlitDestination>,
|
||||
#[cfg(target_os = "macos")]
|
||||
external_surfaces: Mutex<VecDeque<ExternalSurfaceFrame>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
external_sequence: AtomicU64,
|
||||
}
|
||||
|
||||
impl EncoderAttachment {
|
||||
pub fn try_new(width: u32, height: u32) -> Result<Arc<Self>, EncoderAttachError> {
|
||||
Self::try_new_with_frame_rate(width, height, EncoderFrameRate::default())
|
||||
}
|
||||
|
||||
pub fn try_new_with_frame_rate(
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_rate: EncoderFrameRate,
|
||||
) -> Result<Arc<Self>, EncoderAttachError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(EncoderAttachError::InvalidDimensions { width, height });
|
||||
}
|
||||
assert!(frame_rate.numerator > 0, "frame rate numerator positive");
|
||||
assert!(
|
||||
frame_rate.denominator > 0,
|
||||
"frame rate denominator positive"
|
||||
);
|
||||
let mut ring = EncoderInputRing::new(MetalSharedTextureBackend::new());
|
||||
ring.initialise(width, height, TextureFormat::Nv12)
|
||||
.map_err(|_| EncoderAttachError::RingInitFailed)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let blit_destinations = build_blit_destinations(&mut ring, width, height)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let pixel_transfer = VtPixelTransfer::new().ok();
|
||||
let attachment = Self {
|
||||
ring: Mutex::new(ring),
|
||||
ready_condvar: Condvar::new(),
|
||||
attached: AtomicBool::new(true),
|
||||
frames_submitted: AtomicU64::new(0),
|
||||
frames_dropped: AtomicU64::new(0),
|
||||
ring_full_events: AtomicU64::new(0),
|
||||
failed_blits: AtomicU64::new(0),
|
||||
width,
|
||||
height,
|
||||
frame_rate,
|
||||
capture_pts_by_sequence: Mutex::new(VecDeque::with_capacity(CAPTURE_PTS_MAP_CAP)),
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_transfer: Mutex::new(pixel_transfer),
|
||||
#[cfg(target_os = "macos")]
|
||||
blit_destinations,
|
||||
#[cfg(target_os = "macos")]
|
||||
external_surfaces: Mutex::new(VecDeque::with_capacity(EXTERNAL_SURFACE_QUEUE_CAP)),
|
||||
#[cfg(target_os = "macos")]
|
||||
external_sequence: AtomicU64::new(0),
|
||||
};
|
||||
assert!(
|
||||
attachment.attached.load(Ordering::Acquire),
|
||||
"attachment is attached"
|
||||
);
|
||||
assert!(attachment.width > 0, "attachment width positive");
|
||||
assert!(
|
||||
attachment.frame_rate.numerator > 0,
|
||||
"attachment fps positive"
|
||||
);
|
||||
Ok(Arc::new(attachment))
|
||||
}
|
||||
|
||||
pub fn width(&self) -> u32 {
|
||||
let w = self.width;
|
||||
assert!(w > 0, "attachment width positive");
|
||||
assert!(self.height > 0, "attachment height positive");
|
||||
w
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u32 {
|
||||
let h = self.height;
|
||||
assert!(h > 0, "attachment height positive");
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
h
|
||||
}
|
||||
|
||||
pub fn frame_rate(&self) -> EncoderFrameRate {
|
||||
let rate = self.frame_rate;
|
||||
assert!(rate.numerator > 0, "attachment fps numerator positive");
|
||||
assert!(rate.denominator > 0, "attachment fps denominator positive");
|
||||
rate
|
||||
}
|
||||
|
||||
pub fn is_attached(&self) -> bool {
|
||||
let a = self.attached.load(Ordering::Acquire);
|
||||
assert!(self.width > 0, "width intact while reading attached");
|
||||
assert!(self.height > 0, "height intact while reading attached");
|
||||
a
|
||||
}
|
||||
|
||||
pub fn detach(&self) {
|
||||
self.attached.store(false, Ordering::Release);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let drained = core::mem::take(&mut *self.external_surfaces.lock());
|
||||
assert!(
|
||||
drained.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
|
||||
"external queue bounded at detach"
|
||||
);
|
||||
drop(drained);
|
||||
}
|
||||
self.ready_condvar.notify_all();
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
let cap = RING_SIZE;
|
||||
assert!(cap > 0, "ring capacity positive");
|
||||
assert_eq!(cap, 8, "ring capacity matches RING_SIZE");
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn submit_iosurface_frame(&self, _iosurface_handle: u64) -> Result<(), EncoderAttachError> {
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return Err(EncoderAttachError::NotAttached);
|
||||
}
|
||||
let mut ring = self.ring.lock();
|
||||
let result: Result<(), RingError> = ring.submit_skip_oldest(|_handle| {});
|
||||
drop(ring);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
|
||||
self.ready_condvar.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
Err(RingError::FullDropped { .. }) => {
|
||||
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(EncoderAttachError::RingInitFailed),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn submit_with_blit(
|
||||
&self,
|
||||
source_pixel_buffer: *mut core::ffi::c_void,
|
||||
capture_pts_us: i64,
|
||||
) -> Result<(), EncoderAttachError> {
|
||||
assert!(
|
||||
!source_pixel_buffer.is_null(),
|
||||
"blit source must be non-null"
|
||||
);
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return Err(EncoderAttachError::NotAttached);
|
||||
}
|
||||
let transfer_guard = self.pixel_transfer.lock();
|
||||
let Some(transfer) = transfer_guard.as_ref() else {
|
||||
self.failed_blits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(EncoderAttachError::RingInitFailed);
|
||||
};
|
||||
let Some(reservation) = self.reserve_blit_slot()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let slot_index = reservation.slot_index() as usize;
|
||||
assert!(
|
||||
slot_index < self.blit_destinations.len(),
|
||||
"slot index within cached destinations"
|
||||
);
|
||||
let dest_pb = self.blit_destinations[slot_index].pixel_buffer;
|
||||
assert!(
|
||||
!dest_pb.is_null(),
|
||||
"cached destination pixel buffer non-null"
|
||||
);
|
||||
let blit_ok = unsafe { transfer.transfer(source_pixel_buffer, dest_pb) }.is_ok();
|
||||
if blit_ok {
|
||||
self.commit_blit_slot(reservation, capture_pts_us)
|
||||
} else {
|
||||
let cancelled = self.ring.lock().cancel(reservation);
|
||||
assert!(cancelled.is_ok(), "cancel of filling reservation succeeds");
|
||||
self.failed_blits.fetch_add(1, Ordering::Relaxed);
|
||||
Err(EncoderAttachError::RingInitFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn reserve_blit_slot(
|
||||
&self,
|
||||
) -> Result<Option<FillReservation<IoSurfaceSlotHandle>>, EncoderAttachError> {
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
assert!(self.height > 0, "attachment height positive");
|
||||
let mut ring = self.ring.lock();
|
||||
match ring.reserve_skip_oldest() {
|
||||
Ok(reservation) => {
|
||||
assert!(
|
||||
(reservation.slot_index() as usize) < RING_SIZE,
|
||||
"reserved slot within ring"
|
||||
);
|
||||
Ok(Some(reservation))
|
||||
}
|
||||
Err(RingError::FullDropped { .. }) => {
|
||||
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => Err(EncoderAttachError::RingInitFailed),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn commit_blit_slot(
|
||||
&self,
|
||||
reservation: FillReservation<IoSurfaceSlotHandle>,
|
||||
capture_pts_us: i64,
|
||||
) -> Result<(), EncoderAttachError> {
|
||||
assert!(
|
||||
(reservation.slot_index() as usize) < RING_SIZE,
|
||||
"slot within ring"
|
||||
);
|
||||
let sequence = {
|
||||
let mut ring = self.ring.lock();
|
||||
let sequence = match ring.commit(reservation) {
|
||||
Ok(sequence) => sequence,
|
||||
Err(_) => return Err(EncoderAttachError::RingInitFailed),
|
||||
};
|
||||
self.record_capture_pts(sequence, capture_pts_us);
|
||||
sequence
|
||||
};
|
||||
assert!(sequence > 0, "committed sequence positive");
|
||||
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
|
||||
self.ready_condvar.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn wait_next_ready(&self, timeout: Duration) -> Option<EncoderReady<IoSurfaceSlotHandle>> {
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
let timeout_bound = Duration::from_micros(READY_WAIT_TIMEOUT_US_MAX);
|
||||
let bounded_timeout = timeout.min(timeout_bound);
|
||||
assert!(
|
||||
bounded_timeout <= timeout_bound,
|
||||
"wait bounded to one frame interval"
|
||||
);
|
||||
let mut ring = self.ring.lock();
|
||||
if let Some(ready) = ring.poll_next_ready() {
|
||||
return Some(ready);
|
||||
}
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
let _ = self.ready_condvar.wait_for(&mut ring, bounded_timeout);
|
||||
ring.poll_next_ready()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub unsafe fn submit_external_surface(
|
||||
&self,
|
||||
surface: IoSurfaceRaw,
|
||||
capture_pts_us: i64,
|
||||
) -> Result<(), EncoderAttachError> {
|
||||
assert!(surface.as_ptr() as usize != 0, "external surface non-null");
|
||||
assert!(self.width > 0, "attachment width positive");
|
||||
if !self.attached.load(Ordering::Acquire) {
|
||||
unsafe { CFRelease(surface.as_ptr()) };
|
||||
return Err(EncoderAttachError::NotAttached);
|
||||
}
|
||||
unsafe { crate::iosurface_pair::iosurface_increment_use_count(surface) };
|
||||
let sequence = self.external_sequence.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
assert!(sequence > 0, "external sequence starts at one");
|
||||
let frame = ExternalSurfaceFrame {
|
||||
surface,
|
||||
sequence,
|
||||
capture_pts_us,
|
||||
};
|
||||
let evicted = {
|
||||
let mut queue = self.external_surfaces.lock();
|
||||
let evicted = if queue.len() >= EXTERNAL_SURFACE_QUEUE_CAP {
|
||||
queue.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
queue.push_back(frame);
|
||||
assert!(
|
||||
queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
|
||||
"external queue bounded"
|
||||
);
|
||||
evicted
|
||||
};
|
||||
if let Some(oldest) = evicted {
|
||||
drop(oldest);
|
||||
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn take_external_surface(&self) -> Option<ExternalSurfaceFrame> {
|
||||
let frame = {
|
||||
let mut queue = self.external_surfaces.lock();
|
||||
assert!(
|
||||
queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
|
||||
"external queue bounded"
|
||||
);
|
||||
queue.pop_front()
|
||||
};
|
||||
if let Some(ref taken) = frame {
|
||||
assert!(taken.sequence > 0, "popped frame has real sequence");
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn external_surface_queue_len(&self) -> usize {
|
||||
let len = self.external_surfaces.lock().len();
|
||||
assert!(len <= EXTERNAL_SURFACE_QUEUE_CAP, "external queue bounded");
|
||||
assert!(self.width > 0, "attachment width intact");
|
||||
len
|
||||
}
|
||||
|
||||
fn record_capture_pts(&self, sequence: u64, capture_pts_us: i64) {
|
||||
assert!(sequence > 0, "ring sequences start at one");
|
||||
let mut map = self.capture_pts_by_sequence.lock();
|
||||
while map.len() >= CAPTURE_PTS_MAP_CAP {
|
||||
map.pop_front();
|
||||
}
|
||||
map.push_back((sequence, capture_pts_us));
|
||||
assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded");
|
||||
}
|
||||
|
||||
pub fn capture_pts_us_for_sequence(&self, sequence: u64) -> Option<i64> {
|
||||
let map = self.capture_pts_by_sequence.lock();
|
||||
assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded");
|
||||
map.iter()
|
||||
.find(|(seq, _)| *seq == sequence)
|
||||
.map(|(_, pts)| *pts)
|
||||
}
|
||||
|
||||
pub fn note_ring_full(&self) {
|
||||
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> EncoderAttachStats {
|
||||
let stats = EncoderAttachStats {
|
||||
frames_submitted: self.frames_submitted.load(Ordering::Relaxed),
|
||||
frames_dropped: self.frames_dropped.load(Ordering::Relaxed),
|
||||
ring_full_events: self.ring_full_events.load(Ordering::Relaxed),
|
||||
failed_blits: self.failed_blits.load(Ordering::Relaxed),
|
||||
};
|
||||
assert!(
|
||||
stats.frames_dropped <= stats.ring_full_events + stats.frames_dropped,
|
||||
"drop counter consistent"
|
||||
);
|
||||
assert!(
|
||||
stats.frames_submitted <= u64::MAX / 2,
|
||||
"submitted within plausible bound"
|
||||
);
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_blit_destinations(
|
||||
ring: &mut EncoderInputRing<MetalSharedTextureBackend>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<BlitDestination>, EncoderAttachError> {
|
||||
assert!(width > 0, "blit destination width positive");
|
||||
assert!(height > 0, "blit destination height positive");
|
||||
let mut destinations: Vec<BlitDestination> = Vec::with_capacity(RING_SIZE);
|
||||
for slot_index in 0..RING_SIZE {
|
||||
let surface_ptr = ring
|
||||
.backend_mut()
|
||||
.slot_iosurface_ptr(slot_index as u32)
|
||||
.ok_or(EncoderAttachError::RingInitFailed)?;
|
||||
assert!(!surface_ptr.is_null(), "slot iosurface ptr non-null");
|
||||
let pixel_buffer = unsafe { VtPixelTransfer::wrap_iosurface(surface_ptr, width, height) }
|
||||
.map_err(|_| EncoderAttachError::RingInitFailed)?;
|
||||
destinations.push(BlitDestination { pixel_buffer });
|
||||
}
|
||||
assert_eq!(destinations.len(), RING_SIZE, "one destination per slot");
|
||||
Ok(destinations)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "CoreVideo", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CVPixelBufferRelease(buffer: *mut core::ffi::c_void);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFRelease(cf: *const core::ffi::c_void);
|
||||
fn CFRetain(cf: *const core::ffi::c_void) -> *const core::ffi::c_void;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn release_pixel_buffer(pb: *mut core::ffi::c_void) {
|
||||
if !pb.is_null() {
|
||||
unsafe { CVPixelBufferRelease(pb) };
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EncoderAttachment {
|
||||
fn drop(&mut self) {
|
||||
self.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_dimensions() {
|
||||
let err = EncoderAttachment::try_new(0, 720).err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Some(EncoderAttachError::InvalidDimensions { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn off_macos_init_fails() {
|
||||
let err = EncoderAttachment::try_new(640, 480).err();
|
||||
assert!(matches!(err, Some(EncoderAttachError::RingInitFailed)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_attach_then_detach() {
|
||||
let attach = EncoderAttachment::try_new(640, 480).expect("attach ok");
|
||||
assert!(attach.is_attached());
|
||||
assert_eq!(attach.width(), 640);
|
||||
assert_eq!(attach.height(), 480);
|
||||
assert_eq!(attach.capacity(), 8);
|
||||
attach.detach();
|
||||
assert!(!attach.is_attached());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_submit_records_stats() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
for _ in 0..3 {
|
||||
attach
|
||||
.submit_iosurface_frame(0xdead_beef)
|
||||
.expect("submit ok");
|
||||
}
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 3);
|
||||
assert_eq!(stats.frames_dropped, 0);
|
||||
assert_eq!(stats.ring_full_events, 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn wait_next_ready_returns_submitted_frame() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
attach
|
||||
.submit_iosurface_frame(0xdead_beef)
|
||||
.expect("submit ok");
|
||||
let ready = attach
|
||||
.wait_next_ready(Duration::from_millis(5))
|
||||
.expect("frame ready");
|
||||
assert_eq!(ready.sequence, 1);
|
||||
assert_eq!(ready.duplicate_count, 0);
|
||||
assert!(attach.wait_next_ready(Duration::from_millis(1)).is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn wait_next_ready_timeout_is_capped_at_frame_interval() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let start = std::time::Instant::now();
|
||||
let ready = attach.wait_next_ready(Duration::from_secs(60));
|
||||
assert!(ready.is_none());
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_millis(500),
|
||||
"wait returned within the named frame-interval bound"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn wait_next_ready_wakes_for_concurrent_submit() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let waiter = {
|
||||
let attach = Arc::clone(&attach);
|
||||
std::thread::spawn(move || attach.wait_next_ready(Duration::from_millis(15)))
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(3));
|
||||
attach
|
||||
.submit_iosurface_frame(0xdead_beef)
|
||||
.expect("submit ok");
|
||||
let ready = waiter.join().expect("waiter joins");
|
||||
let sequence = ready.map(|r| r.sequence);
|
||||
assert!(sequence == Some(1) || sequence.is_none());
|
||||
if sequence.is_none() {
|
||||
let retry = attach.wait_next_ready(Duration::from_millis(5));
|
||||
assert_eq!(retry.map(|r| r.sequence), Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn wait_next_ready_returns_none_after_detach() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
attach.detach();
|
||||
assert!(!attach.is_attached());
|
||||
assert!(attach.wait_next_ready(Duration::from_millis(5)).is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_submit_rejected_when_detached() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
attach.detach();
|
||||
let err = attach.submit_iosurface_frame(0xdead_beef).err();
|
||||
assert!(matches!(err, Some(EncoderAttachError::NotAttached)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn submit_encoder_ring_frame_blits_iosurface_to_slot() {
|
||||
use core::ptr::NonNull;
|
||||
use objc2_core_foundation::{CFDictionary, CFRetained};
|
||||
use objc2_core_video::{
|
||||
CVPixelBuffer, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
|
||||
CVPixelBufferGetHeightOfPlane, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags,
|
||||
CVPixelBufferUnlockBaseAddress, kCVPixelBufferIOSurfacePropertiesKey,
|
||||
};
|
||||
|
||||
let attach = EncoderAttachment::try_new(256, 256).expect("attach ok");
|
||||
let mut empty_keys: [*const core::ffi::c_void; 0] = [];
|
||||
let mut empty_vals: [*const core::ffi::c_void; 0] = [];
|
||||
let iosurf_dict: CFRetained<CFDictionary> = unsafe {
|
||||
CFDictionary::new(
|
||||
None,
|
||||
empty_keys.as_mut_ptr(),
|
||||
empty_vals.as_mut_ptr(),
|
||||
0,
|
||||
&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
|
||||
&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
.expect("iosurf empty dict")
|
||||
};
|
||||
let key_ref: &objc2_core_foundation::CFString =
|
||||
unsafe { kCVPixelBufferIOSurfacePropertiesKey };
|
||||
let key_ptr: *const core::ffi::c_void = key_ref as *const _ as *const core::ffi::c_void;
|
||||
let val_ptr: *const core::ffi::c_void =
|
||||
&*iosurf_dict as *const _ as *const core::ffi::c_void;
|
||||
let mut keys = [key_ptr];
|
||||
let mut vals = [val_ptr];
|
||||
let attrs: CFRetained<CFDictionary> = unsafe {
|
||||
CFDictionary::new(
|
||||
None,
|
||||
keys.as_mut_ptr(),
|
||||
vals.as_mut_ptr(),
|
||||
1,
|
||||
&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
|
||||
&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
.expect("attrs dict")
|
||||
};
|
||||
let nv12: u32 = u32::from_be_bytes(*b"420v");
|
||||
let mut pb_out: *mut CVPixelBuffer = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
objc2_core_video::CVPixelBufferCreate(
|
||||
None,
|
||||
256,
|
||||
256,
|
||||
nv12,
|
||||
Some(&attrs),
|
||||
NonNull::new(&mut pb_out).expect("pb_out non-null"),
|
||||
)
|
||||
};
|
||||
assert_eq!(status, 0, "CVPixelBufferCreate ok");
|
||||
let source_pb: CFRetained<CVPixelBuffer> =
|
||||
unsafe { CFRetained::from_raw(NonNull::new(pb_out).expect("pb non-null")) };
|
||||
let lock_flags = CVPixelBufferLockFlags(0);
|
||||
let lock_st = unsafe { CVPixelBufferLockBaseAddress(&source_pb, lock_flags) };
|
||||
assert_eq!(lock_st, 0, "lock ok");
|
||||
let y_ptr = CVPixelBufferGetBaseAddressOfPlane(&source_pb, 0);
|
||||
let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&source_pb, 0);
|
||||
let y_h = CVPixelBufferGetHeightOfPlane(&source_pb, 0);
|
||||
assert!(!y_ptr.is_null(), "Y plane base non-null");
|
||||
assert!(y_stride > 0, "Y stride positive");
|
||||
for row in 0..y_h {
|
||||
for col in 0..y_stride {
|
||||
unsafe {
|
||||
(y_ptr as *mut u8).add(row * y_stride + col).write(0x55);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = unsafe { CVPixelBufferUnlockBaseAddress(&source_pb, lock_flags) };
|
||||
let src_ptr = &*source_pb as *const CVPixelBuffer as *mut core::ffi::c_void;
|
||||
attach
|
||||
.submit_with_blit(src_ptr, 41_500)
|
||||
.expect("submit_with_blit ok");
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 1, "one frame submitted");
|
||||
assert_eq!(stats.failed_blits, 0, "no failed blits");
|
||||
assert_eq!(
|
||||
attach.capture_pts_us_for_sequence(1),
|
||||
Some(41_500),
|
||||
"capture pts recorded for ring sequence"
|
||||
);
|
||||
assert_eq!(attach.capture_pts_us_for_sequence(2), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn external_surface_submit_take_round_trip() {
|
||||
use core::ptr::NonNull;
|
||||
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
|
||||
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface");
|
||||
let raw = NonNull::new(owned.as_ptr()).expect("non-null surface");
|
||||
let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
|
||||
unsafe { CFRetain(raw.as_ptr()) };
|
||||
unsafe { attach.submit_external_surface(raw, 41_500) }.expect("submit ok");
|
||||
assert_eq!(attach.external_surface_queue_len(), 1);
|
||||
let use_during = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
|
||||
assert_eq!(use_during, use_before + 1);
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, 1);
|
||||
assert_eq!(stats.frames_dropped, 0);
|
||||
assert_eq!(stats.ring_full_events, 0);
|
||||
let frame = attach.take_external_surface().expect("frame queued");
|
||||
assert_eq!(frame.surface(), raw);
|
||||
assert_eq!(frame.sequence(), 1);
|
||||
assert_eq!(frame.capture_pts_us(), 41_500);
|
||||
drop(frame);
|
||||
let use_after = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
|
||||
assert_eq!(use_after, use_before);
|
||||
assert_eq!(attach.external_surface_queue_len(), 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn external_surface_queue_evicts_oldest_when_full() {
|
||||
use core::ptr::NonNull;
|
||||
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
|
||||
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let total = EXTERNAL_SURFACE_QUEUE_CAP + 2;
|
||||
let mut owned: Vec<OwnedIoSurface> = Vec::with_capacity(total);
|
||||
for i in 0..total {
|
||||
let surface = OwnedIoSurface::create_nv12(64, 64).expect("surface");
|
||||
let raw = NonNull::new(surface.as_ptr()).expect("non-null surface");
|
||||
unsafe { CFRetain(raw.as_ptr()) };
|
||||
unsafe { attach.submit_external_surface(raw, (i as i64) * 1_000) }.expect("submit ok");
|
||||
owned.push(surface);
|
||||
}
|
||||
assert_eq!(
|
||||
attach.external_surface_queue_len(),
|
||||
EXTERNAL_SURFACE_QUEUE_CAP
|
||||
);
|
||||
let stats = attach.stats();
|
||||
assert_eq!(stats.frames_submitted, total as u64);
|
||||
assert_eq!(stats.frames_dropped, 2);
|
||||
assert_eq!(stats.ring_full_events, 2);
|
||||
let oldest_remaining = attach.take_external_surface().expect("frame queued");
|
||||
assert_eq!(oldest_remaining.sequence(), 3);
|
||||
assert_eq!(oldest_remaining.capture_pts_us(), 2_000);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn detach_drains_external_surface_queue_and_rejects_submit() {
|
||||
use core::ptr::NonNull;
|
||||
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
|
||||
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface");
|
||||
let raw = NonNull::new(owned.as_ptr()).expect("non-null surface");
|
||||
let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
|
||||
unsafe { CFRetain(raw.as_ptr()) };
|
||||
unsafe { attach.submit_external_surface(raw, 7) }.expect("submit ok");
|
||||
assert_eq!(attach.external_surface_queue_len(), 1);
|
||||
attach.detach();
|
||||
assert_eq!(attach.external_surface_queue_len(), 0);
|
||||
assert_eq!(
|
||||
unsafe { crate::iosurface_pair::iosurface_use_count(raw) },
|
||||
use_before
|
||||
);
|
||||
unsafe { CFRetain(raw.as_ptr()) };
|
||||
let err = unsafe { attach.submit_external_surface(raw, 8) }.err();
|
||||
assert!(matches!(err, Some(EncoderAttachError::NotAttached)));
|
||||
assert_eq!(attach.external_surface_queue_len(), 0);
|
||||
assert_eq!(
|
||||
unsafe { crate::iosurface_pair::iosurface_use_count(raw) },
|
||||
use_before
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn capture_pts_map_stays_bounded_and_evicts_oldest() {
|
||||
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
|
||||
let total = (CAPTURE_PTS_MAP_CAP as u64) + 4;
|
||||
for sequence in 1..=total {
|
||||
attach.record_capture_pts(sequence, (sequence as i64) * 1_000);
|
||||
}
|
||||
assert_eq!(
|
||||
attach.capture_pts_us_for_sequence(1),
|
||||
None,
|
||||
"oldest evicted"
|
||||
);
|
||||
assert_eq!(
|
||||
attach.capture_pts_us_for_sequence(4),
|
||||
None,
|
||||
"oldest evicted"
|
||||
);
|
||||
assert_eq!(
|
||||
attach.capture_pts_us_for_sequence(5),
|
||||
Some(5_000),
|
||||
"newest retained"
|
||||
);
|
||||
assert_eq!(
|
||||
attach.capture_pts_us_for_sequence(total),
|
||||
Some((total as i64) * 1_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use objc2_foundation::{NSError, NSProcessInfo};
|
||||
|
||||
pub fn operating_system_version_string() -> String {
|
||||
let info = NSProcessInfo::processInfo();
|
||||
info.operatingSystemVersionString().to_string()
|
||||
}
|
||||
|
||||
pub fn ns_error_localized_description(err: &NSError) -> String {
|
||||
err.localizedDescription().to_string()
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use core::ffi::c_void;
|
||||
use core::ptr::NonNull;
|
||||
|
||||
pub type IoSurfaceRaw = NonNull<c_void>;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "IOSurface", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn IOSurfaceIncrementUseCount(buffer: *mut c_void);
|
||||
fn IOSurfaceDecrementUseCount(buffer: *mut c_void);
|
||||
fn IOSurfaceGetUseCount(buffer: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub unsafe fn iosurface_increment_use_count(surface: IoSurfaceRaw) {
|
||||
unsafe { IOSurfaceIncrementUseCount(surface.as_ptr()) };
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub unsafe fn iosurface_decrement_use_count(surface: IoSurfaceRaw) {
|
||||
unsafe { IOSurfaceDecrementUseCount(surface.as_ptr()) };
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub unsafe fn iosurface_use_count(surface: IoSurfaceRaw) -> i32 {
|
||||
unsafe { IOSurfaceGetUseCount(surface.as_ptr()) }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub unsafe fn iosurface_increment_use_count(_surface: IoSurfaceRaw) {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub unsafe fn iosurface_decrement_use_count(_surface: IoSurfaceRaw) {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub unsafe fn iosurface_use_count(_surface: IoSurfaceRaw) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
pub struct IoSurfacePair {
|
||||
current: Option<IoSurfaceRaw>,
|
||||
prev: Option<IoSurfaceRaw>,
|
||||
}
|
||||
|
||||
unsafe impl Send for IoSurfacePair {}
|
||||
|
||||
impl IoSurfacePair {
|
||||
pub fn new() -> Self {
|
||||
let pair = Self {
|
||||
current: None,
|
||||
prev: None,
|
||||
};
|
||||
assert!(pair.current.is_none());
|
||||
assert!(pair.prev.is_none());
|
||||
pair
|
||||
}
|
||||
|
||||
pub fn has_current(&self) -> bool {
|
||||
let has = self.current.is_some();
|
||||
assert!(has == self.current.is_some());
|
||||
has
|
||||
}
|
||||
|
||||
pub fn has_prev(&self) -> bool {
|
||||
let has = self.prev.is_some();
|
||||
assert!(has == self.prev.is_some());
|
||||
has
|
||||
}
|
||||
|
||||
pub unsafe fn push(&mut self, new: IoSurfaceRaw) {
|
||||
unsafe { iosurface_increment_use_count(new) };
|
||||
let evicted = self.prev.take();
|
||||
let rotated = self.current.take();
|
||||
self.prev = rotated;
|
||||
self.current = Some(new);
|
||||
assert!(self.current.is_some());
|
||||
if let Some(old) = evicted {
|
||||
unsafe { iosurface_decrement_use_count(old) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_current(&mut self) -> Option<IoSurfaceRaw> {
|
||||
let taken = self.current.take();
|
||||
assert!(self.current.is_none());
|
||||
taken
|
||||
}
|
||||
|
||||
pub fn peek_current(&self) -> Option<IoSurfaceRaw> {
|
||||
self.current
|
||||
}
|
||||
|
||||
pub fn peek_prev(&self) -> Option<IoSurfaceRaw> {
|
||||
self.prev
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
let cur = self.current.take();
|
||||
let prev = self.prev.take();
|
||||
assert!(self.current.is_none());
|
||||
assert!(self.prev.is_none());
|
||||
if let Some(s) = cur {
|
||||
unsafe { iosurface_decrement_use_count(s) };
|
||||
}
|
||||
if let Some(s) = prev {
|
||||
unsafe { iosurface_decrement_use_count(s) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IoSurfacePair {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IoSurfacePair {
|
||||
fn drop(&mut self) {
|
||||
let cur = self.current.take();
|
||||
let prev = self.prev.take();
|
||||
if let Some(s) = cur {
|
||||
unsafe { iosurface_decrement_use_count(s) };
|
||||
}
|
||||
if let Some(s) = prev {
|
||||
unsafe { iosurface_decrement_use_count(s) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core::ptr::NonNull;
|
||||
|
||||
fn fake_surface(addr: usize) -> IoSurfaceRaw {
|
||||
assert!(addr != 0);
|
||||
NonNull::new(addr as *mut c_void).expect("non-null fake surface")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pair_is_empty() {
|
||||
let pair = IoSurfacePair::new();
|
||||
assert!(!pair.has_current());
|
||||
assert!(!pair.has_prev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_current_on_empty_returns_none() {
|
||||
let mut pair = IoSurfacePair::new();
|
||||
assert!(pair.take_current().is_none());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn push_rotates_current_to_prev_offplatform() {
|
||||
let mut pair = IoSurfacePair::new();
|
||||
let a = fake_surface(0x1);
|
||||
let b = fake_surface(0x2);
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
}
|
||||
assert_eq!(pair.peek_current(), Some(a));
|
||||
assert!(pair.peek_prev().is_none());
|
||||
unsafe {
|
||||
pair.push(b);
|
||||
}
|
||||
assert_eq!(pair.peek_current(), Some(b));
|
||||
assert_eq!(pair.peek_prev(), Some(a));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn take_current_clears_slot_offplatform() {
|
||||
let mut pair = IoSurfacePair::new();
|
||||
let a = fake_surface(0x10);
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
}
|
||||
let taken = pair.take_current().expect("current is set");
|
||||
assert_eq!(taken, a);
|
||||
assert!(pair.peek_current().is_none());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn drop_pair_is_safe_offplatform() {
|
||||
let mut pair = IoSurfacePair::new();
|
||||
unsafe {
|
||||
pair.push(fake_surface(0x100));
|
||||
pair.push(fake_surface(0x200));
|
||||
}
|
||||
drop(pair);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod macos_tests {
|
||||
use super::*;
|
||||
use core::ffi::c_void;
|
||||
use core::ptr::NonNull;
|
||||
use objc2_core_foundation::{
|
||||
CFDictionary, CFNumber, CFRetained, CFString, kCFAllocatorDefault,
|
||||
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
|
||||
};
|
||||
|
||||
#[link(name = "IOSurface", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn IOSurfaceCreate(properties: *const CFDictionary) -> *mut c_void;
|
||||
}
|
||||
|
||||
fn cf_number_i32(v: i32) -> CFRetained<CFNumber> {
|
||||
CFNumber::new_i32(v)
|
||||
}
|
||||
|
||||
fn cf_string(s: &'static str) -> CFRetained<CFString> {
|
||||
CFString::from_static_str(s)
|
||||
}
|
||||
|
||||
fn make_iosurface() -> IoSurfaceRaw {
|
||||
let width_key = cf_string("IOSurfaceWidth");
|
||||
let height_key = cf_string("IOSurfaceHeight");
|
||||
let bpe_key = cf_string("IOSurfaceBytesPerElement");
|
||||
let pf_key = cf_string("IOSurfacePixelFormat");
|
||||
let width_val = cf_number_i32(32);
|
||||
let height_val = cf_number_i32(32);
|
||||
let bpe_val = cf_number_i32(4);
|
||||
let pf_val = cf_number_i32(i32::from_be_bytes(*b"BGRA"));
|
||||
let keys: [*const c_void; 4] = [
|
||||
CFRetained::as_ptr(&width_key).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&height_key).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&bpe_key).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&pf_key).as_ptr() as *const c_void,
|
||||
];
|
||||
let vals: [*const c_void; 4] = [
|
||||
CFRetained::as_ptr(&width_val).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&height_val).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&bpe_val).as_ptr() as *const c_void,
|
||||
CFRetained::as_ptr(&pf_val).as_ptr() as *const c_void,
|
||||
];
|
||||
let dict_opt = unsafe {
|
||||
CFDictionary::new(
|
||||
kCFAllocatorDefault,
|
||||
keys.as_ptr() as *mut *const c_void,
|
||||
vals.as_ptr() as *mut *const c_void,
|
||||
4,
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
};
|
||||
let dict = dict_opt.expect("CFDictionary::new returned non-null");
|
||||
let dict_ptr = CFRetained::as_ptr(&dict).as_ptr() as *const CFDictionary;
|
||||
let raw = unsafe { IOSurfaceCreate(dict_ptr) };
|
||||
assert!(!raw.is_null(), "IOSurfaceCreate must produce a surface");
|
||||
NonNull::new(raw).expect("non-null IOSurface")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_increments_use_count_and_take_current_does_not_release() {
|
||||
let a = make_iosurface();
|
||||
let before = unsafe { iosurface_use_count(a) };
|
||||
let mut pair = IoSurfacePair::new();
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
}
|
||||
let after_push = unsafe { iosurface_use_count(a) };
|
||||
assert_eq!(after_push, before + 1);
|
||||
let taken = pair.take_current().expect("current set");
|
||||
assert_eq!(taken, a);
|
||||
let after_take = unsafe { iosurface_use_count(a) };
|
||||
assert_eq!(after_take, before + 1);
|
||||
unsafe { iosurface_decrement_use_count(a) };
|
||||
let after_balance = unsafe { iosurface_use_count(a) };
|
||||
assert_eq!(after_balance, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_rotates_and_drop_releases_both_slots() {
|
||||
let a = make_iosurface();
|
||||
let b = make_iosurface();
|
||||
let before_a = unsafe { iosurface_use_count(a) };
|
||||
let before_b = unsafe { iosurface_use_count(b) };
|
||||
let mut pair = IoSurfacePair::new();
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
pair.push(b);
|
||||
}
|
||||
assert_eq!(pair.peek_current(), Some(b));
|
||||
assert_eq!(pair.peek_prev(), Some(a));
|
||||
assert_eq!(unsafe { iosurface_use_count(a) }, before_a + 1);
|
||||
assert_eq!(unsafe { iosurface_use_count(b) }, before_b + 1);
|
||||
drop(pair);
|
||||
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
|
||||
assert_eq!(unsafe { iosurface_use_count(b) }, before_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_push_evicts_oldest_and_releases_it() {
|
||||
let a = make_iosurface();
|
||||
let b = make_iosurface();
|
||||
let c = make_iosurface();
|
||||
let before_a = unsafe { iosurface_use_count(a) };
|
||||
let mut pair = IoSurfacePair::new();
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
pair.push(b);
|
||||
pair.push(c);
|
||||
}
|
||||
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
|
||||
assert_eq!(pair.peek_current(), Some(c));
|
||||
assert_eq!(pair.peek_prev(), Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_releases_both_slots() {
|
||||
let a = make_iosurface();
|
||||
let b = make_iosurface();
|
||||
let before_a = unsafe { iosurface_use_count(a) };
|
||||
let before_b = unsafe { iosurface_use_count(b) };
|
||||
let mut pair = IoSurfacePair::new();
|
||||
unsafe {
|
||||
pair.push(a);
|
||||
pair.push(b);
|
||||
}
|
||||
pair.clear();
|
||||
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
|
||||
assert_eq!(unsafe { iosurface_use_count(b) }, before_b);
|
||||
assert!(pair.peek_current().is_none());
|
||||
assert!(pair.peek_prev().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#![deny(clippy::all)]
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
#![allow(clippy::collapsible_if)]
|
||||
#![allow(clippy::manual_is_multiple_of)]
|
||||
#![allow(clippy::manual_slice_size_calculation)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
#![allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||
#![allow(clippy::missing_transmute_annotations)]
|
||||
#![allow(clippy::missing_const_for_thread_local)]
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
pub mod audio_pool;
|
||||
pub mod config;
|
||||
pub mod encoder_attach;
|
||||
pub mod iosurface_pair;
|
||||
pub mod os_version;
|
||||
|
||||
pub use audio_pool::{
|
||||
MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioError, MacAudioFramePool,
|
||||
MacAudioPoolStats, PooledMacAudioFrame,
|
||||
};
|
||||
pub use config::{
|
||||
AUDIO_CHANNEL_COUNT_DEFAULT, AUDIO_CHANNEL_COUNT_MAX, AUDIO_CHANNEL_COUNT_MIN,
|
||||
AUDIO_SAMPLE_RATE_DEFAULT_HZ, AUDIO_SAMPLE_RATE_MAX_HZ, AUDIO_SAMPLE_RATE_MIN_HZ,
|
||||
AudioSampleFormat, CaptureFailureSurface, FPS_DEFAULT, FPS_MAX, FPS_MIN,
|
||||
MacScreenShareAudioFrame, MacScreenShareAudioFrameWithBytes, QUEUE_DEPTH_DEFAULT,
|
||||
QUEUE_DEPTH_MAX, QUEUE_DEPTH_MIN, SckCaptureConfig, SckCaptureConfigBuilder, SckCaptureFailure,
|
||||
SckColorSpace, SckError, SckPixelFormat,
|
||||
};
|
||||
pub use encoder_attach::{EncoderAttachError, EncoderAttachStats, EncoderAttachment};
|
||||
pub use iosurface_pair::{IoSurfacePair, IoSurfaceRaw};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod foundation;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod sck;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod napi_surface_macos;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod napi_surface_stub;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
use napi_derive::napi;
|
||||
|
||||
fn unsupported() -> Error {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
"@fluxer/mac-screen-capture is only supported on macOS",
|
||||
)
|
||||
}
|
||||
|
||||
#[napi(object, js_name = "MacScreenCaptureSource")]
|
||||
pub struct MacScreenCaptureSource {
|
||||
pub kind: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub app_name: Option<String>,
|
||||
pub bundle_id: Option<String>,
|
||||
pub target_pid: Option<i32>,
|
||||
}
|
||||
|
||||
#[napi(js_name = "listSources")]
|
||||
pub fn list_sources() -> Result<Vec<MacScreenCaptureSource>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[napi(object, js_name = "MacScreenCaptureBackendSckAvailability")]
|
||||
pub struct SckAvailability {
|
||||
pub supported: bool,
|
||||
pub macos_version: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object, js_name = "MacScreenCaptureBackendAvailability")]
|
||||
pub struct BackendAvailability {
|
||||
pub sck: SckAvailability,
|
||||
pub screen_permission: String,
|
||||
}
|
||||
|
||||
#[napi(js_name = "getBackendAvailability")]
|
||||
pub fn get_backend_availability() -> Result<BackendAvailability> {
|
||||
Err(unsupported())
|
||||
}
|
||||
|
||||
#[napi(object, js_name = "MacScreenCaptureBackendInfo")]
|
||||
pub struct MacScreenCaptureBackendInfo {
|
||||
pub backend: String,
|
||||
pub supported: bool,
|
||||
pub reason: String,
|
||||
#[napi(js_name = "minMacosVersion")]
|
||||
pub min_macos_version: String,
|
||||
#[napi(js_name = "detectedMacosVersion")]
|
||||
pub detected_macos_version: Option<String>,
|
||||
#[napi(js_name = "sckAvailable")]
|
||||
pub sck_available: bool,
|
||||
}
|
||||
|
||||
#[napi(js_name = "getBackendInfo")]
|
||||
pub fn get_backend_info() -> MacScreenCaptureBackendInfo {
|
||||
use crate::os_version::{SCK_MIN_MACOS, format_version};
|
||||
MacScreenCaptureBackendInfo {
|
||||
backend: "mac-screen-capture".to_owned(),
|
||||
supported: false,
|
||||
reason: "@fluxer/mac-screen-capture is only supported on macOS".to_owned(),
|
||||
min_macos_version: format_version(SCK_MIN_MACOS),
|
||||
detected_macos_version: None,
|
||||
sck_available: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ScreenCapture;
|
||||
|
||||
#[napi]
|
||||
impl ScreenCapture {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Result<Self> {
|
||||
Err(unsupported())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const SCK_MIN_MACOS: (i64, i64, i64) = (12, 3, 0);
|
||||
|
||||
pub fn meets_floor(version: (i64, i64, i64), floor: (i64, i64, i64)) -> bool {
|
||||
if version.0 != floor.0 {
|
||||
return version.0 > floor.0;
|
||||
}
|
||||
if version.1 != floor.1 {
|
||||
return version.1 > floor.1;
|
||||
}
|
||||
version.2 >= floor.2
|
||||
}
|
||||
|
||||
pub fn format_version(version: (i64, i64, i64)) -> String {
|
||||
if version.2 == 0 {
|
||||
format!("{}.{}", version.0, version.1)
|
||||
} else {
|
||||
format!("{}.{}.{}", version.0, version.1, version.2)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
|
||||
use objc2_foundation::NSProcessInfo;
|
||||
let info = NSProcessInfo::processInfo();
|
||||
let v = info.operatingSystemVersion();
|
||||
Some((
|
||||
v.majorVersion as i64,
|
||||
v.minorVersion as i64,
|
||||
v.patchVersion as i64,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SupportClassification {
|
||||
pub supported: bool,
|
||||
pub sck_available: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn classify_support(detected: Option<(i64, i64, i64)>) -> SupportClassification {
|
||||
let min_sck = format_version(SCK_MIN_MACOS);
|
||||
match detected {
|
||||
None => SupportClassification {
|
||||
supported: false,
|
||||
sck_available: false,
|
||||
reason: "mac-screen-capture could not detect the running macOS version. \
|
||||
Native screen capture unavailable."
|
||||
.to_owned(),
|
||||
},
|
||||
Some(v) => {
|
||||
let detected_str = format_version(v);
|
||||
let sck_ok = meets_floor(v, SCK_MIN_MACOS);
|
||||
let reason = if sck_ok {
|
||||
format!(
|
||||
"mac-screen-capture supported on macOS {detected_str} \
|
||||
(ScreenCaptureKit, requires macOS {min_sck}+)."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"mac-screen-capture requires macOS {min_sck}+ (ScreenCaptureKit). \
|
||||
This Mac is running macOS {detected_str}. Native cursor-hidden \
|
||||
screen capture unavailable; fall back to getDisplayMedia."
|
||||
)
|
||||
};
|
||||
SupportClassification {
|
||||
supported: sck_ok,
|
||||
sck_available: sck_ok,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn meets_floor_exact_match() {
|
||||
assert!(meets_floor((12, 3, 0), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meets_floor_higher_major() {
|
||||
assert!(meets_floor((14, 0, 0), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_below_floor() {
|
||||
assert!(!meets_floor((12, 2, 9), SCK_MIN_MACOS));
|
||||
assert!(!meets_floor((11, 7, 10), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_version_trims_zero_patch() {
|
||||
assert_eq!("12.3", format_version((12, 3, 0)));
|
||||
assert_eq!("14.2.1", format_version((14, 2, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_version_is_unsupported() {
|
||||
let c = classify_support(None);
|
||||
assert!(!c.supported);
|
||||
assert!(!c.sck_available);
|
||||
assert!(c.reason.contains("could not detect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_11_is_unsupported() {
|
||||
let c = classify_support(Some((11, 7, 10)));
|
||||
assert!(!c.supported);
|
||||
assert!(!c.sck_available);
|
||||
assert!(c.reason.contains("macOS 12.3+"));
|
||||
assert!(c.reason.contains("macOS 11.7.10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_12_3_is_supported() {
|
||||
let c = classify_support(Some((12, 3, 0)));
|
||||
assert!(c.supported);
|
||||
assert!(c.sck_available);
|
||||
assert!(c.reason.contains("ScreenCaptureKit"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2_core_foundation::{CFString, CGFloat, CGRect};
|
||||
use objc2_core_media::{CMTime, CMTimeFlags};
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_screen_capture_kit::{
|
||||
SCCaptureDynamicRange, SCContentFilter, SCDisplay, SCRunningApplication, SCStream,
|
||||
SCStreamConfiguration, SCWindow,
|
||||
};
|
||||
|
||||
use crate::config::{SckCaptureConfig, SckColorSpace, SckPixelFormat};
|
||||
|
||||
pub use objc2_screen_capture_kit::SCStreamOutputType;
|
||||
|
||||
pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime {
|
||||
CMTime {
|
||||
value,
|
||||
timescale,
|
||||
flags: CMTimeFlags(1),
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 {
|
||||
unsafe { app.processID() }
|
||||
}
|
||||
|
||||
pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained<NSString> {
|
||||
unsafe { app.bundleIdentifier() }
|
||||
}
|
||||
|
||||
pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained<NSString> {
|
||||
unsafe { app.applicationName() }
|
||||
}
|
||||
|
||||
pub fn sc_display_frame(display: &SCDisplay) -> CGRect {
|
||||
unsafe { display.frame() }
|
||||
}
|
||||
|
||||
pub fn sc_display_display_id(display: &SCDisplay) -> u32 {
|
||||
unsafe { display.displayID() }
|
||||
}
|
||||
|
||||
pub fn sc_display_width(display: &SCDisplay) -> isize {
|
||||
unsafe { display.width() }
|
||||
}
|
||||
|
||||
pub fn sc_display_height(display: &SCDisplay) -> isize {
|
||||
unsafe { display.height() }
|
||||
}
|
||||
|
||||
pub fn sc_window_window_id(win: &SCWindow) -> u32 {
|
||||
unsafe { win.windowID() }
|
||||
}
|
||||
|
||||
pub fn sc_window_owning_application(win: &SCWindow) -> Option<Retained<SCRunningApplication>> {
|
||||
unsafe { win.owningApplication() }
|
||||
}
|
||||
|
||||
pub fn sc_window_frame(win: &SCWindow) -> CGRect {
|
||||
unsafe { win.frame() }
|
||||
}
|
||||
|
||||
pub fn sc_window_title(win: &SCWindow) -> Option<Retained<NSString>> {
|
||||
unsafe { win.title() }
|
||||
}
|
||||
|
||||
pub fn sc_window_is_on_screen(win: &SCWindow) -> bool {
|
||||
unsafe { win.isOnScreen() }
|
||||
}
|
||||
|
||||
pub fn filter_content_rect_if_available(filter: &SCContentFilter) -> Option<CGRect> {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::{msg_send, sel};
|
||||
let obj: &objc2::runtime::NSObject = filter.as_ref();
|
||||
if !obj.respondsToSelector(sel!(contentRect)) {
|
||||
return None;
|
||||
}
|
||||
let rect: CGRect = unsafe { msg_send![obj, contentRect] };
|
||||
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
pub fn filter_point_pixel_scale_if_available(filter: &SCContentFilter) -> Option<f32> {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::{msg_send, sel};
|
||||
let obj: &objc2::runtime::NSObject = filter.as_ref();
|
||||
if !obj.respondsToSelector(sel!(pointPixelScale)) {
|
||||
return None;
|
||||
}
|
||||
let scale: CGFloat = unsafe { msg_send![obj, pointPixelScale] };
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(scale as f32)
|
||||
}
|
||||
|
||||
pub fn cfg_set_scales_to_fit_if_available(cfg: &SCStreamConfiguration, scales_to_fit: bool) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::{msg_send, sel};
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setScalesToFit:)) {
|
||||
unsafe {
|
||||
let _: () = msg_send![obj, setScalesToFit: scales_to_fit];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) {
|
||||
unsafe { cfg.setWidth(v) }
|
||||
}
|
||||
pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) {
|
||||
unsafe { cfg.setHeight(v) }
|
||||
}
|
||||
pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) {
|
||||
unsafe { cfg.setQueueDepth(v) }
|
||||
}
|
||||
pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) {
|
||||
unsafe { cfg.setShowsCursor(v) }
|
||||
}
|
||||
pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) {
|
||||
unsafe { cfg.setMinimumFrameInterval(t) }
|
||||
}
|
||||
pub fn cfg_set_pixel_format(cfg: &SCStreamConfiguration, format: u32) {
|
||||
unsafe { cfg.setPixelFormat(format) }
|
||||
}
|
||||
|
||||
pub fn cfg_set_source_rect_if_available(cfg: &SCStreamConfiguration, rect: CGRect) -> bool {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::{msg_send, sel};
|
||||
assert!(rect.size.width > 0.0);
|
||||
assert!(rect.size.height > 0.0);
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if !obj.respondsToSelector(sel!(setSourceRect:)) {
|
||||
return false;
|
||||
}
|
||||
unsafe {
|
||||
let _: () = msg_send![obj, setSourceRect: rect];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
|
||||
unsafe {
|
||||
cfg.setCaptureDynamicRange(SCCaptureDynamicRange::SDR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_capture_dynamic_range_hdr_if_available(cfg: &SCStreamConfiguration) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
|
||||
unsafe {
|
||||
cfg.setCaptureDynamicRange(SCCaptureDynamicRange::HDRLocalDisplay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_color_space_name_if_available(cfg: &SCStreamConfiguration, name: &str) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
assert!(!name.is_empty());
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setColorSpaceName:)) {
|
||||
let cf = CFString::from_str(name);
|
||||
unsafe {
|
||||
cfg.setColorSpaceName(&cf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn obs_minimum_frame_interval(target_fps: u32) -> CMTime {
|
||||
assert!(target_fps >= crate::config::FPS_MIN);
|
||||
assert!(target_fps <= crate::config::FPS_MAX);
|
||||
CMTime {
|
||||
value: crate::config::FRAME_INTERVAL_FACTOR_NUM as i64,
|
||||
timescale: (crate::config::FRAME_INTERVAL_FACTOR_DEN as i32)
|
||||
.saturating_mul(target_fps as i32),
|
||||
flags: CMTimeFlags(1),
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_capture_config(cfg: &SCStreamConfiguration, capture: &SckCaptureConfig) {
|
||||
assert!(capture.target_fps() >= crate::config::FPS_MIN);
|
||||
assert!(capture.queue_depth() >= crate::config::QUEUE_DEPTH_MIN);
|
||||
cfg_set_queue_depth(cfg, capture.queue_depth() as isize);
|
||||
cfg_set_pixel_format(cfg, capture.pixel_format().as_fourcc());
|
||||
cfg_set_minimum_frame_interval(cfg, obs_minimum_frame_interval(capture.target_fps()));
|
||||
cfg_set_color_space_name_if_available(cfg, capture.color_space().as_cf_name());
|
||||
match capture.pixel_format() {
|
||||
SckPixelFormat::L10rHdr => {
|
||||
assert!(capture.color_space() == SckColorSpace::DisplayP3);
|
||||
cfg_set_capture_dynamic_range_hdr_if_available(cfg);
|
||||
}
|
||||
SckPixelFormat::Bgra8 | SckPixelFormat::Nv12VideoRange | SckPixelFormat::Nv12FullRange => {
|
||||
cfg_set_capture_dynamic_range_sdr_if_available(cfg);
|
||||
}
|
||||
}
|
||||
if capture.captures_audio() {
|
||||
cfg_set_captures_audio_if_available(cfg, true);
|
||||
cfg_set_audio_sample_rate_if_available(cfg, capture.audio_sample_rate_hz());
|
||||
cfg_set_audio_channel_count_if_available(cfg, capture.audio_channels());
|
||||
cfg_set_excludes_current_process_audio_if_available(cfg, true);
|
||||
} else {
|
||||
cfg_set_captures_audio_if_available(cfg, false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_captures_audio_if_available(cfg: &SCStreamConfiguration, captures_audio: bool) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setCapturesAudio:)) {
|
||||
unsafe {
|
||||
cfg.setCapturesAudio(captures_audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_audio_sample_rate_if_available(cfg: &SCStreamConfiguration, sample_rate_hz: u32) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
assert!(sample_rate_hz >= crate::config::AUDIO_SAMPLE_RATE_MIN_HZ);
|
||||
assert!(sample_rate_hz <= crate::config::AUDIO_SAMPLE_RATE_MAX_HZ);
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setSampleRate:)) {
|
||||
unsafe {
|
||||
cfg.setSampleRate(sample_rate_hz as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_audio_channel_count_if_available(cfg: &SCStreamConfiguration, channels: u32) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
assert!(channels >= crate::config::AUDIO_CHANNEL_COUNT_MIN);
|
||||
assert!(channels <= crate::config::AUDIO_CHANNEL_COUNT_MAX);
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setChannelCount:)) {
|
||||
unsafe {
|
||||
cfg.setChannelCount(channels as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_excludes_current_process_audio_if_available(
|
||||
cfg: &SCStreamConfiguration,
|
||||
excludes_self: bool,
|
||||
) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setExcludesCurrentProcessAudio:)) {
|
||||
unsafe {
|
||||
cfg.setExcludesCurrentProcessAudio(excludes_self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSString) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setStreamName:)) {
|
||||
unsafe {
|
||||
cfg.setStreamName(Some(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sc_stream_add_stream_output(
|
||||
stream: &SCStream,
|
||||
output: &objc2::runtime::ProtocolObject<dyn objc2_screen_capture_kit::SCStreamOutput>,
|
||||
kind: SCStreamOutputType,
|
||||
queue: Option<&dispatch2::DispatchQueue>,
|
||||
) -> Result<(), Retained<objc2_foundation::NSError>> {
|
||||
unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::obs_minimum_frame_interval;
|
||||
use crate::config::{FPS_MAX, FPS_MIN, SckCaptureConfig};
|
||||
|
||||
#[test]
|
||||
fn minimum_frame_interval_is_strictly_shorter_than_frame_time() {
|
||||
for fps in [FPS_MIN, 30, 60, FPS_MAX] {
|
||||
let t = obs_minimum_frame_interval(fps);
|
||||
assert!(t.value > 0);
|
||||
assert!(t.timescale > 0);
|
||||
let interval_ns = (t.value as u64) * 1_000_000_000 / (t.timescale as u64);
|
||||
let frame_ns = 1_000_000_000 / (fps as u64);
|
||||
assert!(interval_ns < frame_ns, "fps={fps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_frame_interval_matches_config_minimum_frame_interval_ns() {
|
||||
for fps in [FPS_MIN, 30, 60, FPS_MAX] {
|
||||
let t = obs_minimum_frame_interval(fps);
|
||||
let cm_interval_ns = (t.value as u64) * 1_000_000_000 / (t.timescale as u64);
|
||||
let cfg = SckCaptureConfig::builder()
|
||||
.target_fps(fps)
|
||||
.build()
|
||||
.expect("config builds");
|
||||
let cfg_interval_ns = cfg.minimum_frame_interval_ns();
|
||||
assert!(cm_interval_ns.abs_diff(cfg_interval_ns) <= 1, "fps={fps}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user