Skip to content

Commit

Permalink
🐛 Fix problem with Chrome 127
Browse files Browse the repository at this point in the history
  • Loading branch information
vthibault committed Aug 13, 2024
1 parent 968b808 commit 600a35b
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 224 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
"player",
"streaming"
],
"dependencies": {},
"dependencies": {
"@types/sdp-transform": "^2.4.9",
"sdp-transform": "^2.14.2"
},
"devDependencies": {
"@babel/cli": "^7.7.5",
"@babel/core": "^7.7.5",
Expand Down
340 changes: 117 additions & 223 deletions src/webrtc/SDPEnhancer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import * as sdpTransform from 'sdp-transform';
import { TVideoConfigs, TAudioConfigs } from '../../typings/wowza-types';

// Code adapted from https://github.com/WowzaMediaSystems/webrtc-examples/blob/master/src/lib/WowzaMungeSDP.js
import { browser } from '../utils/browser';

const SUPPORTED_VIDEO_FORMATS = ['vp9', 'vp8', 'h264', 'red', 'ulpfec', 'rtx'];
const SUPPORTED_AUDIO_FORMATS = ['opus', 'isac', 'g722', 'pcmu', 'pcma', 'cn'];

type TSection = 'm=audio' | 'm=video' | null;
type TCodecConfig = TVideoConfigs | TAudioConfigs | null;
const SUPPORTED_VIDEO_FORMATS = [
'vp9',
'vp8',
'h264',
'red',
'ulpfec',
'rtx',
'av1',
];

export class SDPEnhancer {
private audioIndex = -1;
private videoIndex = -1;

constructor(
private videoOptions: TVideoConfigs,
private audioOptions: TAudioConfigs
Expand All @@ -28,249 +31,140 @@ export class SDPEnhancer {
return description;
}

const resource = sdpTransform.parse(description.sdp);

// The profile-level-id string has three parts: XXYYZZ, where
// XX: 42 baseline, 4D main, 64 high
// YY: constraint
// ZZ: level ID
// Look for codecs higher than baseline and force downward.
description.sdp = description.sdp.replace(
/profile-level-id=(\w+)/gi,
(_, $0) => {
const profileId = parseInt($0, 16);
let profile = (profileId >> 16) & 0xff;
let constraint = (profileId >> 8) & 0xff;
let level = profileId & 0xff;
resource.media.forEach((media) => {
media.fmtp.forEach((fmtp) => {
fmtp.config = fmtp.config.replace(
/profile-level-id=(\w+)/gi,
(_, $0) => {
const profileId = parseInt($0, 16);
let profile = (profileId >> 16) & 0xff;
let constraint = (profileId >> 8) & 0xff;
let level = profileId & 0xff;

if (profile > 0x42) {
profile = 0x42;
constraint = 0xe0;
level = 0x1f;
} else if (constraint === 0x00) {
constraint = 0xe0;
}

return `profile-level-id=${(
(profile << 16) |
(constraint << 8) |
level
).toString(16)}`;
}
);
});
});

description.sdp = sdpTransform.write(resource);

if (profile > 0x42) {
profile = 0x42;
constraint = 0xe0;
level = 0x1f;
} else if (constraint === 0x00) {
constraint = 0xe0;
}
return `profile-level-id=${((profile << 16) | (constraint << 8) | level).toString(16)}`;
}
);
return description;
}

public transformPublish(
description: RTCSessionDescriptionInit
): RTCSessionDescriptionInit {
const lines = this.prepareSDP(description);
const video = this.videoOptions;
const audio = this.audioOptions;

let sdpSection: TSection = null;

const getSectionConfig = (): TCodecConfig =>
sdpSection === 'm=audio'
? audio
: sdpSection === 'm=video'
? video
: null;

const sdp =
lines
.filter(Boolean)
.map((line) => {
const [header] = line.split(/\s|:/, 1);

switch (header) {
case 'm=audio':
case 'm=video': {
const offset =
header === 'm=audio' ? this.audioIndex : this.videoIndex;

if (offset !== -1 && browser === 'chrome') {
const [header, port, proto /*, fmt*/] = line.split(' ');
return `${header} ${port} ${proto} ${offset}`;
}

sdpSection = header;
break;
}

case 'a=rtpmap': {
const matches = /^a=rtpmap:(\d+)\s+(\w+)\/(\d+)/.exec(line);
if (!matches || browser !== 'chrome') {
break;
}

const format = matches[2].toLowerCase();

if (video.bitRate && SUPPORTED_VIDEO_FORMATS.includes(format)) {
line += `\r\na=fmtp:${matches[1]} x-google-min-bitrate=${video.bitRate};x-google-max-bitrate=${video.bitRate}`;
}
if (audio.bitRate && SUPPORTED_AUDIO_FORMATS.includes(format)) {
line += `\r\na=fmtp:${matches[1]} x-google-min-bitrate=${audio.bitRate};x-google-max-bitrate=${audio.bitRate}`;
}
break;
}

case 'c=IN': {
const config = getSectionConfig();

if (config?.bitRate && ['firefox', 'safari'].includes(browser)) {
const bitRate = config.bitRate * 1000;
const bitRateTIAS = bitRate * 0.95 - 50 * 40 * 8;

line += `\r\nb=TIAS:${bitRateTIAS}`;
line += `\r\nb=AS:${bitRate}`;
line += `\r\nb=CT:${bitRate}`;
}
break;
}

case 'a=mid': {
const config = getSectionConfig();

if (config && browser === 'chrome') {
if (config.bitRate) {
line += `\r\nb=CT:${config.bitRate}`;
line += `\r\nb=AS:${config.bitRate}`;

if ('frameRate' in config && config.frameRate) {
line += `\r\na=framerate:${config.frameRate.toFixed(2)}`;
}
}
sdpSection = null;
}
break;
}
}

return line;
})
.join('\r\n') + '\r\n';
if (!description.sdp) {
return description;
}

return {
type: description.type,
sdp,
const resource = sdpTransform.parse(description.sdp);
const params = {
audio: {
config: this.audioOptions,
supportedFormats: SUPPORTED_AUDIO_FORMATS,
},
video: {
config: this.videoOptions,
supportedFormats: SUPPORTED_VIDEO_FORMATS,
},
};
}

private checkLine(line: string, tmp: Map<number, string[]>): boolean {
if (/^a=(rtpmap|rtcp-fb|fmtp)/.test(line)) {
const res = line.split(':');

if (res.length > 1) {
const [index, data] = res[1].split(' ');
resource.media.forEach((media) => {
const options =
media.type === 'video'
? params.video
: media.type === 'audio'
? params.audio
: null;

if (!data.startsWith('http') && !data.startsWith('ur')) {
const position = parseInt(index, 10);
const list = tmp.get(position) || [];
list.push(line);
tmp.set(position, list);
return false;
}
if (!options) {
return;
}
}

return true;
}

private deliverCheckLine(
profile: string,
type: 'audio' | 'video',
tmp: Map<number, string[]>
): string[] {
const entry = Array.from(tmp).find(([, lines]) =>
lines.join('\r\n').includes(profile)
);

if (!entry) {
return [];
}

const [index, lines] = entry;

if (type === 'audio') {
this.audioIndex = index;
} else {
this.videoIndex = index;
}

return profile !== 'VP8' && profile !== 'VP9'
? lines
: lines.filter(
(transport) =>
!transport.includes('transport-cc') &&
!transport.includes('goog-remb') &&
!transport.includes('nack')
);
}

private addAudio(lines: string[], tmp: Map<number, string[]>): string[] {
let sdpSection = '';

for (let i = 0, count = lines.length; i < count; ++i) {
const line = lines[i];

if (line.startsWith('m=audio')) {
sdpSection = 'audio';
} else if (line.startsWith('m=video')) {
sdpSection = 'video';
} else if (line === 'a=rtcp-mux' && sdpSection === 'audio') {
const audioAddedBuffer = this.deliverCheckLine(
this.audioOptions.codec,
'audio',
tmp
);

lines.splice(i + 1, 0, ...audioAddedBuffer);
break;
const config = options.config;

if (config.bitRate && ['firefox', 'safari'].includes(browser)) {
media.bandwidth = (media.bandwidth || [])
.filter((data) => !['TIAS', 'AS', 'CT'].includes(data.type))
.concat(
{
type: 'TIAS',
limit: config.bitRate * 1000 * 0.95 - 50 * 40 * 8,
},
{
type: 'AS',
limit: config.bitRate * 1000,
},
{
type: 'CT',
limit: config.bitRate * 1000,
}
);
}
}

return lines;
}

private addVideo(lines: string[], tmp: Map<number, string[]>): string[] {
const rtcpSize = lines.includes('a=rtcp-rsize');
const rtcMux = lines.includes('a=rtcp-mux');
let done = false;

if (!rtcpSize && !rtcMux) {
return lines;
}
if ('frameRate' in config) {
media.framerate = config.frameRate;
}

const videoAddedBuffer = this.deliverCheckLine(
this.videoOptions.codec,
'video',
tmp
);
for (let i = 0; i < media.rtp.length; ) {
const rtp = media.rtp[i];
const supported = options.supportedFormats.includes(
rtp.codec.toLowerCase()
);

return lines.map((line) => {
if (rtcpSize) {
if (!done && line === 'a=rtcp-rsize') {
done = true;
return [line].concat(videoAddedBuffer).join('\r\n');
// Remove unsupported medias
if (!supported) {
media.rtp.splice(i, 1);
media.fmtp = media.fmtp.filter(
(fmtp) => fmtp.payload !== rtp.payload
);
media.payloads = media.rtp.map((rtp) => rtp.payload).join(' ');
media.rtcpFb = media.rtcpFb?.filter(
(rtcpFb) => rtcpFb.payload !== rtp.payload
);
continue;
}
} else if (line === 'a=rtcp-mux') {
if (done) {
return [line].concat(videoAddedBuffer).join('\r\n');

// Add bandwidth constraints to the codec.
if (config.bitRate && browser === 'chrome') {
media.fmtp
.filter((fmtp) => fmtp.payload === rtp.payload)
.forEach((fmtp) => {
fmtp.config = fmtp.config.replace(
/;?x-google-(min|max)-bitrate=(\d+)/g,
''
);
fmtp.config += `;x-google-min-bitrate=${config.bitRate};x-google-max-bitrate=${config.bitRate}`;
});
}
done = true;
}

return line;
i++;
}
});
}

private flattenLines(lines: string[]): string[] {
return lines.join('\r\n').split('\r\n');
}

private prepareSDP(description: RTCSessionDescriptionInit): string[] {
const tmp = new Map<number, string[]>();
const sdp = description.sdp || '';
description.sdp = sdpTransform.write(resource);

let lines = sdp.split(/\r\n/);
lines = lines.filter((line) => line && this.checkLine(line, tmp));
lines = this.flattenLines(this.addAudio(lines, tmp));
lines = this.flattenLines(this.addVideo(lines, tmp));

return lines;
return description;
}
}

0 comments on commit 600a35b

Please sign in to comment.