Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/client/native/screen-share/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type { NativeScreenAudioPreferences } from '@monky/shared';
export type NativeScreenCaptureEncoder = 'auto' | 'h264_texture_amf' | 'obs_nvenc_h264_tex'
| 'obs_x264' | 'av1_texture_amf' | 'obs_nvenc_av1_tex' | 'monky_aom_av1'
| 'apple_vt_h264' | 'apple_vt_h264_software';
/** The H.264 profile a capture encodes: Main, or the Constrained Baseline of the compatible format (#858). */
export type NativeScreenH264Profile = 'main' | 'constrained-baseline';
export type NativeScreenCaptureTarget =
| MacCaptureTarget
| { platform?: never; kind: 'window' | 'game'; hwnd: number; expectedProcessId: number; expectedProcessCreationTime100ns: string }
Expand Down Expand Up @@ -68,6 +70,8 @@ export interface NativeScreenCaptureProbeOptions {
runDirectory: string;
video: { width: number; height: number; fps: number; bitrateKbps: number; scaleMode?: 'stretch' | 'fit' };
encoder?: NativeScreenCaptureEncoder;
/** H.264 encoders only; Main when absent. */
h264Profile?: NativeScreenH264Profile;
}

export interface NativeScreenCaptureProbeResult extends NativeScreenCaptureCapability {
Expand Down Expand Up @@ -189,6 +193,8 @@ export interface NativeScreenEndpointOptions {
audio?: NativeScreenAudioOptions;
target?: NativeScreenCaptureTarget | LegacyNativeWindowCaptureTarget;
captureEncoder?: NativeScreenCaptureEncoder;
/** The H.264 profile an H.264 publisher encodes and negotiates; Main when absent. */
h264Profile?: NativeScreenH264Profile;
preserveAspectRatio?: boolean;
captureDirectory?: string;
isSourcePaused?: () => boolean;
Expand Down
6 changes: 5 additions & 1 deletion apps/client/native/screen-share/runtime/captureBridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class CaptureBridge extends ObsHostBridge {
assert.equal(path.basename(runDirectory), `monky-screen-capture-${runId}`);
const video = Object.freeze(protocol.normalizedVideo(options.video));
const encoder = protocol.validateEncoder(options.encoder ?? 'auto');
const h264Profile = options.h264Profile ?? 'main';
assert.ok(h264Profile === 'main' || (h264Profile === 'constrained-baseline' && encoder !== 'auto'
&& protocol.ENCODERS[encoder].codec === 'h264'), 'Constrained Baseline needs an explicit H.264 encoder.');
const bitrateCeilingKbps = options.bitrateCeilingKbps ?? 80000;
assert.ok(Number.isSafeInteger(bitrateCeilingKbps) && bitrateCeilingKbps >= video.bitrateKbps
&& bitrateCeilingKbps <= 80000 && bitrateCeilingKbps % 50 === 0, 'Invalid capture bitrate ceiling.');
Expand All @@ -22,7 +25,7 @@ class CaptureBridge extends ObsHostBridge {
super({
...options,
protocol: { ...protocol, maxLineBytes: protocol.MAX_LINE_BYTES, allowUnpreparedStop: true,
validateMessage: (message, expected) => protocol.validateMessage(message, { ...expected, video, encoder }),
validateMessage: (message, expected) => protocol.validateMessage(message, { ...expected, video, encoder, h264Profile }),
errorDetails: message => message.error },
argumentsForSource: (source, id) => [
`--runtime=${runtime.stockDirectory}`, `--run-directory=${runDirectory}`, `--run-id=${id}`,
Expand All @@ -31,6 +34,7 @@ class CaptureBridge extends ObsHostBridge {
`--height=${video.height}`, `--fps=${video.fps}`, `--bitrate=${video.bitrateKbps}`,
`--scale-mode=${video.scaleMode}`,
...(encoder === 'obs_nvenc_av1_tex' ? [`--bitrate-ceiling=${bitrateCeilingKbps}`] : []),
...(h264Profile === 'constrained-baseline' ? ['--h264-profile=constrained-baseline'] : []),
],
validateRetirement: bridge => {
const terminal = bridge.stopped ?? bridge.failure;
Expand Down
16 changes: 12 additions & 4 deletions apps/client/native/screen-share/runtime/captureProtocol.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,18 @@ function normalizedVideo(video) {
return { ...video, scaleMode: video.scaleMode ?? 'stretch' };
}

function configuration(video, encoder = 'h264_texture_amf', kind = 'window') {
/** The H.264 profile a capture encodes (#858); AV1 keeps the legacy `main` label. */
function validateH264Profile(profile, encoder) {
assert.ok(profile === 'main' || profile === 'constrained-baseline', 'Unknown H.264 capture profile.');
assert.ok(profile === 'main' || ENCODERS[encoder]?.codec === 'h264', 'Only H.264 encoders have a Constrained Baseline.');
return profile;
}

function configuration(video, encoder = 'h264_texture_amf', kind = 'window', h264Profile = 'main') {
validateVideo(video);
assert.ok(typeof encoder === 'string' && Object.hasOwn(ENCODERS, encoder));
assert.ok(['window', 'monitor', 'game'].includes(kind));
return { ...CONFIGURATION, codec: ENCODERS[encoder].codec, scaleMode: video.scaleMode ?? 'stretch', encoderId: encoder, rateControl: ENCODERS[encoder].rateControl,
return { ...CONFIGURATION, profile: validateH264Profile(h264Profile, encoder), codec: ENCODERS[encoder].codec, scaleMode: video.scaleMode ?? 'stretch', encoderId: encoder, rateControl: ENCODERS[encoder].rateControl,
method: kind === 'game' ? 'game-hook' : 'wgc', width: video.width, height: video.height,
fpsNumerator: video.fps, initialBitrateKbps: video.bitrateKbps };
}
Expand Down Expand Up @@ -226,13 +233,14 @@ function validateMessage(message, expected) {
}
const encoder = validateEncoder(expected.encoder ?? 'auto');
if (encoder !== 'auto') assert.equal(message.configuration.encoderId, encoder);
assert.deepEqual(message.configuration, configuration(expected.video, message.configuration.encoderId, kind));
assert.deepEqual(message.configuration, configuration(expected.video, message.configuration.encoderId, kind,
expected.h264Profile ?? 'main'));
}
const selected = message.configuration;
assert.deepEqual(selected, configuration({
width: selected?.width, height: selected?.height, fps: selected?.fpsNumerator, bitrateKbps: selected?.initialBitrateKbps,
scaleMode: selected?.scaleMode,
}, selected?.encoderId, kind));
}, selected?.encoderId, kind, selected?.profile));
if (!extended) assert.equal(selected.encoderId, CONFIGURATION.encoderId, 'Legacy protocol cannot claim NVENC support.');
if (extended) {
if (message.capability === null) assert.ok(message.type === 'error' || message.type === 'stopped');
Expand Down
5 changes: 3 additions & 2 deletions apps/client/native/screen-share/runtime/mac/capture.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ const { normalizedVideo } = require('../captureProtocol.cjs');
const { notifyObserver } = require('../asyncTools.cjs');

class MacVideoCapture {
constructor({ target, video, mode, codec = 'h264', onPacket, onError, directory }, dependencies = {}) {
constructor({ target, video, mode, codec = 'h264', h264Profile = 'main', onPacket, onError, directory }, dependencies = {}) {
assert.ok(mode === 'hardware' || mode === 'software');
assert.ok(codec === 'h264' || codec === 'av1' && mode === 'software');
assert.equal(typeof onPacket, 'function');
assert.equal(typeof onError, 'function');
this.target = validateMacTarget(target);
this.video = Object.freeze({ ...normalizedVideo(video), mode, codec });
assert.ok(h264Profile === 'main' || (h264Profile === 'constrained-baseline' && codec === 'h264'));
this.video = Object.freeze({ ...normalizedVideo(video), mode, codec, ...(h264Profile === 'main' ? {} : { h264Profile }) });
this.errors = [];
this.onError = onError;
this.firstFrame = new Promise((resolve, reject) => {
Expand Down
23 changes: 17 additions & 6 deletions apps/client/native/screen-share/runtime/mac/captureBridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ function encoderProfile(encoder) {
assert.ok(Object.hasOwn(ENCODERS, encoder), 'The macOS backend requires an explicit native encoder.');
return ENCODERS[encoder];
}
async function probe(host, video, encoder, signal) {
/** The video a macOS capture encodes: the compatible format (#858) names its Constrained Baseline. */
function encodedVideo(video, mode, codec, h264Profile = 'main') {
assert.ok(h264Profile === 'main' || (h264Profile === 'constrained-baseline' && codec === 'h264'),
'Only H.264 has a Constrained Baseline.');
return { ...normalizedVideo(video), mode, codec, ...(h264Profile === 'main' ? {} : { h264Profile }) };
}
async function probe(host, video, encoder, signal, h264Profile = 'main') {
const { mode, codec } = encoderProfile(encoder);
const { value } = await host.request('media.probe', { video: { ...normalizedVideo(video), mode, codec } }, signal);
const { value } = await host.request('media.probe', { video: encodedVideo(video, mode, codec, h264Profile) }, signal);
assert.equal(value.captureStarted, false);
assert.equal(value.nativeClosed, true);
assert.equal(value.hardwareExecutionObserved, null);
Expand All @@ -27,7 +33,10 @@ async function probe(host, video, encoder, signal) {
if (codec === 'av1') {
assert.equal(value.profileLevelId, null);
assert.equal(value.sequenceVerified, true);
} else assert.match(value.profileLevelId, /^4d[0-9a-f]{4}$/u);
} else if (h264Profile === 'main') assert.match(value.profileLevelId, /^4d[0-9a-f]{4}$/u);
// Constrained Baseline: profile 66 with constraint_set1 (H.264 A.2.1).
else assert.ok(/^42[0-9a-f]{4}$/u.test(value.profileLevelId) && (Number.parseInt(value.profileLevelId.slice(2, 4), 16) & 0x40),
'The macOS encoder did not produce Constrained Baseline.');
return Object.freeze({ encoderId: encoder, codec, mode, probe: codec === 'av1' ? 'libaom-session' : 'videotoolbox-session',
probeVerified: true, textureInput: codec === 'h264', dynamicBitrate: true, hardwareSessionConfirmed: false,
hardwareQualified: false });
Expand Down Expand Up @@ -59,6 +68,8 @@ class MacCaptureBridge {
this.dependencies = dependencies;
this.mode = encoderProfile(options.encoder).mode;
this.codec = encoderProfile(options.encoder).codec;
this.h264Profile = options.h264Profile ?? 'main';
encodedVideo(options.video, this.mode, this.codec, this.h264Profile);
this.video = Object.freeze(normalizedVideo(options.video));
this.ceiling = options.bitrateCeilingKbps ?? 80000;
this.sequence = 0;
Expand All @@ -69,11 +80,11 @@ class MacCaptureBridge {
assert.ok(!this.#capture && !this.stopping, 'A retired or prepared macOS capture owner cannot be reused.');
this.target = Object.freeze(structuredClone(validateMacTarget(target)));
this.#capture = new MacVideoCapture({ target: this.target, video: this.video, mode: this.mode, codec: this.codec,
onPacket: this.options.onPacket, onError: this.options.onError },
h264Profile: this.h264Profile, onPacket: this.options.onPacket, onError: this.options.onError },
{ ...this.dependencies, runtime: this.options.host });
const { value } = await this.#capture.host.request('resolve', { target: this.target }, signal);
assert.deepEqual(value.target, this.target);
this.capability = await probe(this.#capture.host, this.video, this.options.encoder, signal);
this.capability = await probe(this.#capture.host, this.video, this.options.encoder, signal, this.h264Profile);
return this.capability;
}
async start(target, signal) {
Expand Down Expand Up @@ -125,4 +136,4 @@ class MacCaptureBridge {
}

const assertMacCaptureBridgeClosed = Function.prototype.call.bind(MacCaptureBridge.prototype.assertClosed);
module.exports = { MacCaptureBridge, probeCaptureCapabilities, encoderProfile, assertMacCaptureBridgeClosed };
module.exports = { MacCaptureBridge, probeCaptureCapabilities, encoderProfile, encodedVideo, assertMacCaptureBridgeClosed };
10 changes: 9 additions & 1 deletion apps/client/native/screen-share/runtime/nativeEndpoint.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ class NativeScreenEndpoint {
assert.equal(captureEncoderProfile(options.captureEncoder ?? 'auto', target).codec, source.codec ?? 'h264',
'The selected capture encoder must match the announced screen codec.');
if (options.preserveAspectRatio !== undefined) assert.equal(typeof options.preserveAspectRatio, 'boolean');
if (options.h264Profile !== undefined) {
assert.ok(['main', 'constrained-baseline'].includes(options.h264Profile), 'Unknown H.264 screen profile.');
assert.notEqual(source.codec, 'av1', 'An AV1 screen has no H.264 profile.');
}
assert.ok(path.isAbsolute(captureDirectory));
} else assert.ok(destination && typeof destination.frame?.isDestroyed === 'function');
Object.assign(this, { runtime, role, mode, sessionId, publisherSessionId, channelId, pipelineId, source, profile,
Expand All @@ -81,6 +85,8 @@ class NativeScreenEndpoint {
this.audioApplied = true;
this.target = target ? cloneSource(target) : null;
this.captureEncoder = options.captureEncoder ?? 'auto';
// The compatible format's Constrained Baseline (#858): its encoder and its engine negotiate only that profile.
this.h264Profile = options.h264Profile ?? 'main';
this.preserveAspectRatio = options.preserveAspectRatio ?? true;
this.isSourcePaused = options.isSourcePaused ?? (() => false);
this.assertSourceCurrent = options.assertSourceCurrent ?? (() => {});
Expand Down Expand Up @@ -113,7 +119,8 @@ class NativeScreenEndpoint {
maxResources: 64, maxDecodedFrames: 16,
maximumH264Level: role === 'publish' ? Number.parseInt(getScreenH264ProfileLevelId(profile).slice(-2), 16) : 60,
requireAudio: source.audio,
videoInput: role === 'publish' ? (source.codec === 'av1' ? 'encoded-av1' : 'encoded-h264') : 'nv12',
videoInput: role !== 'publish' ? 'nv12' : source.codec === 'av1' ? 'encoded-av1'
: options.h264Profile === 'constrained-baseline' ? 'encoded-h264-cb' : 'encoded-h264',
}, event => {
if (!this.transport) { this.early.push(event); return; }
this.track(this.dispatch(event));
Expand Down Expand Up @@ -498,6 +505,7 @@ class NativeScreenEndpoint {
this.host = createCaptureBridge({
host: this.runtime.host, runtime: this.runtime.obs, runId, runDirectory: this.runDirectory,
encoder: this.captureEncoder,
...(this.source.codec === 'av1' ? {} : { h264Profile: this.h264Profile }),
bitrateCeilingKbps: maxBitrateKbps,
video: { width, height, fps, bitrateKbps: Math.min(5000, maxBitrateKbps),
scaleMode: this.preserveAspectRatio ? 'fit' : 'stretch' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"peer.configureVideoPlayout": "target=peerId; {minimumDelayMs:integer[0,1000]}, only once before any receivers exist. Applies the public SetJitterBufferMinimumDelay API to video receivers; audio is unchanged. Returns {minimumDelayMs,actualDelayMeasured:false}; a request is not an end-to-end latency measurement."
},
"node": {
"options": "Revision6 options plus videoInput:'nv12'|'encoded-h264'|'encoded-av1' (default nv12). 'nv12' engines only receive and export decoded NV12; encoded engines publish one source of their own codec. Encoded source and engine modes must match.",
"options": "Revision6 options plus videoInput:'nv12'|'encoded-h264'|'encoded-h264-cb'|'encoded-av1' (default nv12; encoded-h264-cb is the compatible format's Constrained Baseline). 'nv12' engines only receive and export decoded NV12; encoded engines publish one source of their own codec. Encoded source and engine modes must match.",
"submitEncodedFrame": "engine.submitEncodedFrame(sourceId,{frameId,data:Buffer,keyframe,timestampUs,durationUs,ntpTimeMs,pts,dts,timebaseNumerator,timebaseDenominator}) -> {sourceId,frameId,inputBytes,copied:true,networkDeliveryConfirmed:false}; synchronous bounded native copy, not a JS-memory loan, retirement Promise or network delivery proof. PTS/DTS are original canonical signed int64 decimal strings."
},
"events": {
Expand Down
Loading
Loading