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
11 changes: 8 additions & 3 deletions apps/client/src/renderer/core/webrtc/BrowserScreenSfu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type NativeScreenVideoProfile, type SfuConsumedPayload, type SfuProducerClosedPayload,
type SfuProducersListPayload, type SfuRouterRtpCapabilitiesPayload, type SfuWebRtcTransportCreatedPayload,
} from '@monky/shared';
import { BrowserScreenCodecError, av1ReceiveLevel, h264MaxReceiveLevel, supportsBrowserAv1Level } from './browserScreenCodecs';
import { BrowserScreenCodecError, av1ReceiveLevel, browserReceivesH264Main, h264MaxReceiveLevel, supportsBrowserAv1Level } from './browserScreenCodecs';

export type BrowserScreenRpc = <T>(method: NativeScreenRpcMethod, payload: Record<string, unknown>) => Promise<T>;
type RouterResponse = Omit<SfuRouterRtpCapabilitiesPayload, 'rtpCapabilities'> & { rtpCapabilities: SfuTypes.RtpCapabilities };
Expand Down Expand Up @@ -169,10 +169,15 @@ export class BrowserScreenSfu {
? h264MaxReceiveLevel(codec.parameters?.['profile-level-id'], this.options.profile) : null;
if (maxReceiveLevel) codec.parameters = { ...codec.parameters, 'max-recv-level': maxReceiveLevel };
}
// A screen nobody here can decode is refused when consuming: by the server (no common profile) or by
// the browser's own description. Without an H.264 Main receiver that is the codec, not the network.
const codecRefusal = (error: unknown): unknown => producer.kind === 'video' && !browserReceivesH264Main()
&& !(error instanceof DOMException)
? new BrowserScreenCodecError(`This browser cannot consume the ${selectedMime} screen: ${String(error)}`) : error;
const consumed = await rpc<ConsumerResponse | SfuProducerClosedPayload>(MessageType.SFU_CONSUME, {
channelId: call.channelId, transportId: transport.id, producerId: producer.producerId,
rtpCapabilities: capabilities,
});
}).catch((error: unknown) => { throw codecRefusal(error); });
if (!('id' in consumed)) {
if (consumed.channelId !== call.channelId || consumed.producerId !== producer.producerId)
throw new Error('Screen consumption ended another producer.');
Expand Down Expand Up @@ -205,7 +210,7 @@ export class BrowserScreenSfu {
id: consumed.id, producerId: consumed.producerId, kind: consumed.kind,
rtpParameters: consumed.rtpParameters, appData: metadata.appData,
...(consumed.kind === 'audio' ? { codecOptions: { opusStereo: true } } : {}),
});
}).catch((error: unknown) => { throw codecRefusal(error); });
if (!selected()) { consumer.close(); await this.closeConsumer(consumed.id); return; }
entry.consumer = consumer;
consumer.on('trackended', () => {
Expand Down
40 changes: 35 additions & 5 deletions apps/client/src/renderer/core/webrtc/browserScreenCodecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,53 @@ export function av1ReceiveLevel(parameters: Readonly<Record<string, unknown>>, v
return av1 !== null && av1.level < minimum ? minimum : null;
}

/** Whether this browser's WebRTC receiver lists the codec of Monky screens at all: H.264 Main or AV1 profile 0. */
const h264Receiver = (codec: { mimeType: string; sdpFmtpLine?: string }, profile: RegExp): boolean =>
codec.mimeType.toLowerCase() === 'video/h264'
&& profile.test(/(?:^|;)\s*profile-level-id=([0-9a-f]{6})(?:;|$)/i.exec(codec.sdpFmtpLine ?? '')?.[1] ?? '')
&& /(?:^|;)\s*packetization-mode=1(?:;|$)/.test(codec.sdpFmtpLine ?? '');

/** Whether this browser receives H.264 Main, what the desktop app and the web's better format send. */
export function browserReceivesH264Main(): boolean {
return RTCRtpReceiver.getCapabilities('video')?.codecs.some(codec => h264Receiver(codec, /^4d/i)) ?? false;
}

/**
* Whether this browser's WebRTC receiver lists the codec of Monky screens at all: AV1 profile 0, or H.264 as
* Main or as the Constrained Baseline the web shares by default (Firefox and Safari list only the latter).
*/
export function browserReceivesScreenCodec(selected: ScreenCodec = 'h264'): boolean {
return RTCRtpReceiver.getCapabilities('video')?.codecs.some(codec =>
selected === 'av1' ? codec.mimeType.toLowerCase() === 'video/av1'
&& av1Parameters(fmtpParameters(codec.sdpFmtpLine ?? ''))?.profile === 0
: codec.mimeType.toLowerCase() === 'video/h264'
&& /(?:^|;)\s*profile-level-id=4d[0-9a-f]{4}(?:;|$)/i.test(codec.sdpFmtpLine ?? '')
&& /(?:^|;)\s*packetization-mode=1(?:;|$)/.test(codec.sdpFmtpLine ?? '')) ?? false;
: h264Receiver(codec, /^(4d|42e0)/i)) ?? false;
}

/**
* What to tell a browser about a screen in `codec` it could not decode: `main`, an H.264 Main share it lacks
* (Firefox, Safari); `switch`, another codec the publisher can change to the H.264 it receives; `codec`, a format
* it does not decode with nothing to switch to (Firefox and AV1); `null`, nothing codec-specific.
*/
export function browserScreenRefusal(codec: ScreenCodec): 'main' | 'switch' | 'codec' | null {
const main = browserReceivesH264Main();
if (codec === 'h264' && !main && browserReceivesScreenCodec('h264')) return 'main';
if (codec !== 'h264' && main) return 'switch';
if (!browserReceivesScreenCodec(codec) || !main) return 'codec';
return null;
}

export async function supportsBrowserScreenCodec(
profile: Readonly<NativeScreenVideoProfile>, selected: ScreenCodec = 'h264',
): Promise<boolean> {
// For AV1 the advertised level is only a default: the decoder probe below decides.
if (!browserReceivesScreenCodec(selected) || !navigator.mediaCapabilities?.decodingInfo) return false;
// A browser without Main is probed with the Constrained Baseline it receives, at the same level.
const h264 = getScreenH264ProfileLevelId(profile);
const profileLevelId = browserReceivesH264Main() ? h264 : `42e0${h264.slice(4)}`;
const capability = await navigator.mediaCapabilities.decodingInfo({
type: 'webrtc',
video: {
contentType: selected === 'av1' ? `video/AV1;profile=0;level-idx=${getScreenAv1MinimumLevelIndex(profile)};tier=0`
: `video/H264;profile-level-id=${getScreenH264ProfileLevelId(profile)};packetization-mode=1`,
: `video/H264;profile-level-id=${profileLevelId};packetization-mode=1`,
width: profile.width, height: profile.height, framerate: profile.fps, bitrate: profile.maxBitrateKbps * 1000,
},
});
Expand All @@ -73,6 +100,9 @@ export function assertBrowserScreenCodec(
sdp: string, selected: ScreenCodec, profile?: Readonly<NativeScreenVideoProfile>,
): void {
const videoSections = sdp.split(/(?=^m=)/m).filter(section => section.startsWith('m=video '));
// A receiver that shares no codec with the screen answers its video rejected (port 0): that is the codec.
if (videoSections.length && videoSections.every(section => section.split(' ')[1] === '0'))
throw new BrowserScreenCodecError(`This browser receives no ${selected.toUpperCase()} profile the screen offers.`);
for (const section of videoSections) {
const [media, ...lines] = section.split(/\r?\n/);
if (media.split(' ')[1] === '0') continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { emitOutsideRouting } from '../../sessionRouting';
import { t } from '../../../i18n';
import { videoService, type NativeScreenCapture } from '../../VideoService';
import { settingsStore } from '../../../stores/settingsStore';
import { platformSupports } from '../../../platform/capabilities';
import { customVideoFpsLimit } from '../../../utils/qualityProfileLimits';
import { acceptScreenEncoding } from '../../screenEncoding';
import {
Expand All @@ -17,6 +18,9 @@ import {
/** Why viewers see a frozen picture of a local source: its window is minimized or hidden, or a browser stopped drawing it. */
export type NativeScreenSourceWarning = 'hidden' | 'covered';

/** The format a browser publisher encodes in (#858); the desktop chooses its codec through its own settings. */
const browserFormat = () => platformSupports('browserScreenPicker') ? { browserFormat: settingsStore.browserScreenFormat } : {};

export type SourceInput = Omit<NativeScreenCapture, 'source'> & {
shareId: string; video: NativeScreenVideoProfile; audio: boolean;
replacesAudioShareIds?: readonly string[];
Expand Down Expand Up @@ -151,7 +155,7 @@ export class LocalScreenSources {
desktopSourceId: input.desktopSourceId, video: input.video, audio: input.audio, audioBitrateKbps: input.audioBitrateKbps,
preserveAspectRatio: input.preserveAspectRatio ?? true,
encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec,
encodingStrategy: settingsStore.screenEncodingStrategy,
encodingStrategy: settingsStore.screenEncodingStrategy, ...browserFormat(),
...(input.replacesAudioShareIds?.length ? { replacesAudioShareIds: [...input.replacesAudioShareIds] } : {}),
...(input.captureKind ? { captureKind: input.captureKind } : {}),
}).then(result => {
Expand Down Expand Up @@ -221,7 +225,7 @@ export class LocalScreenSources {
preserveAspectRatio: capture.preserveAspectRatio ?? true,
video, audio, audioBitrateKbps,
encodingMode: settingsStore.screenEncodingMode, codec: settingsStore.preferredScreenCodec,
encodingStrategy: settingsStore.screenEncodingStrategy,
encodingStrategy: settingsStore.screenEncodingStrategy, ...browserFormat(),
});
this.calls.current(call);
if (!isCurrentCapture() || previous.removing || call.sources.get(shareId) !== previous) {
Expand Down
8 changes: 7 additions & 1 deletion apps/client/src/renderer/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,9 +959,15 @@ export const en: TranslationMap = {
'screenShare.chromiumReceiverSettingsHint': 'You are using the Chromium receiver, which cannot receive AV1 at high-resolution profiles. Select Native in Settings → Quality & sharing → Screen reception and try watching again, or ask the publisher to use H.264.',
'screenShare.browserReceiverHint': 'Ask the publisher to use H.264 and try watching again.',
'screenShare.browserCannotReceive': 'This browser cannot receive {codec} screens, the format of this share. Watch it in Chrome, Edge or the desktop app.',
'screenShare.browserCannotReceiveMain': 'This browser cannot receive this share: it uses H.264 Main, from the desktop app or the web\'s better quality option. Watch it in Chrome, Edge or the desktop app.',
'screenShare.browserFormat': 'Video format',
'screenShare.browserFormatCompatible': 'Compatible',
'screenShare.browserFormatCompatibleDescription': 'H.264 everyone can watch: Chrome, Edge, Firefox, Safari and the desktop app. Recommended.',
'screenShare.browserFormatMain': 'Better quality',
'screenShare.browserFormatMainDescription': 'H.264 Main: a better picture with the same bandwidth. Firefox and Safari cannot watch it.',
'desktopOnly.badge': 'Desktop',
'desktopOnly.tooltip': 'Available in the desktop app',
'desktopOnly.reason.screenShare': 'This browser cannot share screens in Monky\'s format (H.264). Use Chrome or Edge, or the desktop app.',
'desktopOnly.reason.screenShare': 'This browser cannot share screens to Monky. Use Chrome, Edge or Firefox, or the desktop app.',
'desktopOnly.title': 'Available in the desktop app',
'desktopOnly.message': '{feature}: this feature only works in the Monky desktop app. Download the app to use it.',
'desktopOnly.panelNotice': '{feature}: available only in the Monky desktop app. Here you can only look at the options.',
Expand Down
8 changes: 7 additions & 1 deletion apps/client/src/renderer/i18n/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,9 +921,15 @@ export const ptBR = {
'screenShare.chromiumReceiverSettingsHint': 'Você está usando o receptor Chromium, que não recebe AV1 em perfis de alta resolução. Selecione Nativo em Configurações → Qualidade e compartilhamento → Recepção de tela e tente assistir novamente, ou peça ao transmissor para usar H.264.',
'screenShare.browserReceiverHint': 'Peça a quem está transmitindo para usar H.264 e tente assistir novamente.',
'screenShare.browserCannotReceive': 'Este navegador não recebe telas em {codec}, o formato desta transmissão. Assista pelo Chrome, pelo Edge ou pelo app desktop.',
'screenShare.browserCannotReceiveMain': 'Este navegador não recebe esta transmissão: ela usa H.264 Main, do app desktop ou da opção de melhor qualidade da web. Assista pelo Chrome, pelo Edge ou pelo app desktop.',
'screenShare.browserFormat': 'Formato do vídeo',
'screenShare.browserFormatCompatible': 'Compatível',
'screenShare.browserFormatCompatibleDescription': 'H.264 que todos assistem: Chrome, Edge, Firefox, Safari e o app desktop. Recomendado.',
'screenShare.browserFormatMain': 'Melhor qualidade',
'screenShare.browserFormatMainDescription': 'H.264 Main: imagem melhor com a mesma banda. O Firefox e o Safari não conseguem assistir.',
'desktopOnly.badge': 'Desktop',
'desktopOnly.tooltip': 'Disponível no app desktop',
'desktopOnly.reason.screenShare': 'Este navegador não transmite tela no formato do Monky (H.264). Use o Chrome ou o Edge, ou o app desktop.',
'desktopOnly.reason.screenShare': 'Este navegador não consegue transmitir tela para o Monky. Use o Chrome, o Edge ou o Firefox, ou o app desktop.',
'desktopOnly.title': 'Disponível no app desktop',
'desktopOnly.message': '{feature}: este recurso funciona só no app do Monky para computador. Baixe o app para usar.',
'desktopOnly.panelNotice': '{feature}: disponível só no app do Monky para computador. Aqui você só pode ver as opções.',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { NativeScreenCall } from '@monky/shared';
import type { BrowserScreenFormat, NativeScreenCall } from '@monky/shared';
import { screenSenderCodecs } from './browserScreenProfile';
import { sentMediaBinding, withScreenStreamId, type ScreenMediaBinding } from './screenSdp';
import type { ScreenIceCandidate, ScreenMediaKind, ScreenPeerPort } from './screenP2pPublisherPeer';

export interface BrowserScreenPeerOptions {
readonly iceServers: NativeScreenCall['iceServers'];
/** The only video codecs this connection may send (#858). */
readonly format: BrowserScreenFormat;
/** The screen's instance: the stream id viewers group its video and sound by. */
readonly streamId: string;
readonly video: MediaStreamTrack;
Expand Down Expand Up @@ -33,10 +35,10 @@ export class BrowserScreenPeer implements ScreenPeerPort {
direction: 'sendonly', streams: [this.stream],
sendEncodings: [{ active: false, maxBitrate: options.maxBitrateBps, maxFramerate: options.maxFramerate }],
});
const codecs = screenSenderCodecs(RTCRtpSender.getCapabilities('video')?.codecs ?? []);
const codecs = screenSenderCodecs(RTCRtpSender.getCapabilities('video')?.codecs ?? [], options.format);
if (!codecs.length) {
this.peer.close();
throw new Error('This browser cannot send the H.264 Main video screen viewers receive.');
throw new Error(`This browser cannot send screens in the ${options.format} format.`);
}
video.setCodecPreferences(codecs);
this.transceivers.set('video', video);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getScreenShareProfile, nativeScreenVideoProfileSchema, type NativeScreenVideoProfile } from '@monky/shared';
import {
browserScreenFormatSchema, getScreenShareProfile, nativeScreenVideoProfileSchema,
type BrowserScreenFormat, type NativeScreenVideoProfile,
} from '@monky/shared';

/** What a captured video track reports of itself (`MediaStreamTrack.getSettings()`). */
export interface CapturedVideoSettings {
Expand Down Expand Up @@ -38,18 +41,27 @@ export interface CodecCapability {
const fmtp = (codec: CodecCapability, key: string): string | undefined =>
codec.sdpFmtpLine?.split(';').map(part => part.trim().split('=')).find(([name]) => name === key)?.[1];

/** H.264 Main in non-interleaved mode: what every screen receiver, native or browser, decodes. */
export function isScreenH264(codec: CodecCapability): boolean {
return codec.mimeType.toLowerCase() === 'video/h264' && /^4d[0-9a-f]{4}$/i.test(fmtp(codec, 'profile-level-id') ?? '')
/**
* Whether a sender codec encodes a browser format (#858): H.264 in non-interleaved mode, as Constrained
* Baseline (42e0..) for `compatible`, which every receiver decodes, or as Main (4d..) for `main`.
*/
export function isScreenCodec(codec: CodecCapability, format: BrowserScreenFormat): boolean {
const profile = format === 'main' ? /^4d[0-9a-f]{4}$/i : /^42e0[0-9a-f]{2}$/i;
return codec.mimeType.toLowerCase() === 'video/h264' && profile.test(fmtp(codec, 'profile-level-id') ?? '')
&& fmtp(codec, 'packetization-mode') === '1';
}

/**
* The video codecs a browser screen sender offers, in order: H.264 Main and its retransmission. Viewers
* accept nothing else for a source announced as H.264, so an empty list means this browser cannot share.
* The video codecs a browser screen sender offers for `format`, in order: the format's codec and its
* retransmission. An empty list means this browser cannot share in that format.
*/
export function screenSenderCodecs<T extends CodecCapability>(codecs: readonly T[]): T[] {
const h264 = codecs.filter(isScreenH264);
if (!h264.length) return [];
return [...h264, ...codecs.filter(codec => codec.mimeType.toLowerCase() === 'video/rtx')];
export function screenSenderCodecs<T extends CodecCapability>(codecs: readonly T[], format: BrowserScreenFormat): T[] {
const matching = codecs.filter(codec => isScreenCodec(codec, format));
if (!matching.length) return [];
return [...matching, ...codecs.filter(codec => codec.mimeType.toLowerCase() === 'video/rtx')];
}

/** The formats this browser can share in, the compatible one first. */
export function browserScreenFormats(codecs: readonly CodecCapability[]): BrowserScreenFormat[] {
return browserScreenFormatSchema.options.filter(format => screenSenderCodecs(codecs, format).length > 0);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
getScreenShareProfile, nativeScreenFailureReason, nativeScreenSignalSchema, screenShareProfileKey,
type NativeScreenCall, type NativeScreenFailure, type NativeScreenSignalPayload, type NativeScreenSource,
type NativeScreenVideoProfile, type ScreenShareQuality,
type NativeScreenVideoProfile, type ScreenShareQuality, type BrowserScreenFormat,
} from '@monky/shared';
import { BrowserScreenPeer } from './browserScreenPeer';
import type { DisplayCapture } from './displayCaptures';
Expand Down Expand Up @@ -48,6 +48,8 @@ const capacity = (): Error => Object.assign(new Error('The screen profile has re
export interface WebScreenPublisherOptions {
readonly call: NativeScreenCall;
readonly source: NativeScreenSource;
/** How this screen is encoded; its codec is the source's. */
readonly format: BrowserScreenFormat;
readonly capture: DisplayCapture;
readonly audioBitrateKbps: number;
readonly signal: (signal: NativeScreenSignalPayload) => Promise<void>;
Expand Down Expand Up @@ -160,7 +162,7 @@ export class WebScreenPublisher {
const { call, source, audioBitrateKbps } = this.options;
let peer: ScreenP2pPublisherPeer | null = null;
const port = new BrowserScreenPeer({
iceServers: call.iceServers, streamId: source.instanceId, video: pipeline.video, audio: pipeline.audio,
iceServers: call.iceServers, format: this.options.format, streamId: source.instanceId, video: pipeline.video, audio: pipeline.audio,
maxBitrateBps: pipeline.profile.maxBitrateKbps * 1000, maxFramerate: pipeline.profile.fps,
audioBitrateBps: audioBitrateKbps * 1000,
onCandidate: candidate => peer?.localCandidate(candidate),
Expand All @@ -186,7 +188,7 @@ export class WebScreenPublisher {

private async pipelineFor(viewer: Viewer): Promise<Pipeline> {
const { source, capture, call } = this.options;
const profile = getScreenShareProfile(source.video, viewer.quality, 'h264');
const profile = getScreenShareProfile(source.video, viewer.quality, source.codec);
const key = screenShareProfileKey(profile);
const existing = this.pipelines.get(key);
if (existing?.closing) {
Expand All @@ -200,7 +202,7 @@ export class WebScreenPublisher {
pipeline.viewers.set(viewer.sessionId, viewer);
if (!existing && call.mode === 'sfu') {
const sfu = new WebScreenSfuRendition({
channelId: call.channelId, shareId: source.shareId,
channelId: call.channelId, shareId: source.shareId, format: this.options.format,
rendition: { sourceInstanceId: source.instanceId, pipelineId: pipeline.id, video: profile },
video: pipeline.video, audio: pipeline.audio, audioBitrateBps: this.options.audioBitrateKbps * 1000,
rpc: this.options.rpc, onError: error => { void this.track(this.failPipeline(pipeline, error)); },
Expand Down
Loading
Loading