Bring over changes from dash branch

This commit is contained in:
David Halls
2021-10-14 18:07:21 +01:00
parent 649ea53bc4
commit 4e23f98a0f
6 changed files with 406 additions and 135 deletions

View File

@@ -3,7 +3,7 @@
== Description == Description
Streamana is a Web page which streams your camera and microphone to YouTube Live Streamana is a Web page which streams your camera and microphone to YouTube Live
(or any other HLS receiver). It uses https://github.com/davedoesdev/webm-muxer.js[webm-muxer.js] and (or any other HLS or DASH receiver). It uses https://github.com/davedoesdev/webm-muxer.js[webm-muxer.js] and
https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js]. https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js].
== Demo == Demo
@@ -15,7 +15,9 @@ Use Chrome 95 or later.
.. Click _CREATE_ and then select _Go Live_ from the drop-down menu. .. Click _CREATE_ and then select _Go Live_ from the drop-down menu.
.. Under _Select stream key_, select _Create new stream key_. .. Under _Select stream key_, select _Create new stream key_.
.. Give your key a name. .. Give your key a name.
.. You must select _HLS_ as the streaming protocol. .. You must select _HLS_ as the streaming protocol. Note: YouTube DASH ingestion is only available
by using the Youtube API. See https://developers.google.com/youtube/v3/live/guides/encoding-with-dash#url-structure[here]
for more details.
.. Click _CREATE_. .. Click _CREATE_.
.. Make sure the key you created is selected. .. Make sure the key you created is selected.
.. Click _COPY_ next to _Stream URL_. .. Click _COPY_ next to _Stream URL_.
@@ -34,7 +36,7 @@ You can also change various options:
** Lock the camera to portrait mode (where available, e.g. mobile phones). ** Lock the camera to portrait mode (where available, e.g. mobile phones).
** Zoom the camera to fill the page. ** Zoom the camera to fill the page.
** Select a different version of https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js] to perform ** Select a different version of https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js] to perform
the HLS encoding. the HLS or DASH encoding.
== Customisation == Customisation
@@ -46,16 +48,16 @@ so you can change this to add video effects or overlays. The shader already hand
resizing and rotating the video in `main()`. The optional greyscale conversion is in resizing and rotating the video in `main()`. The optional greyscale conversion is in
the `tpix()` function. the `tpix()` function.
The page's functionality is defined in link:site/streamana.js[] and link:site/hls.js[]. The page's functionality is defined in link:site/streamana.js[] and link:site/streamer.js[].
link:site/hls.js[] exports a class, `HLS`, which does the heavy lifting: link:site/streamer.js[] exports a class, `Streamer`, which does the heavy lifting:
* The constructor takes the following arguments: * The constructor takes the following arguments:
** The https://developer.mozilla.org/en-US/docs/Web/API/MediaStream[`MediaStream`] ** The https://developer.mozilla.org/en-US/docs/Web/API/MediaStream[`MediaStream`]
containing your video and audio tracks. Note that link:site/streamana.js[] supplies containing your video and audio tracks. Note that link:site/streamana.js[] supplies
blank video when the camera is hidden and silent audio when the microphone is muted. blank video when the camera is hidden and silent audio when the microphone is muted.
** The ingestion URL. ** The ingestion URL.
** The URL of `ffmpeg-worker-hls.js` in https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js]. ** The URL of `ffmpeg-worker-hls.js` or `ffmpeg-worker-dash.js` in https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js].
This allows your application (or the end user if required) to supply its own version, This allows your application (or the end user if required) to supply its own version,
in accordance with LGPL. in accordance with LGPL.
** The desired video frame rate. ** The desired video frame rate.
@@ -63,7 +65,7 @@ link:site/hls.js[] exports a class, `HLS`, which does the heavy lifting:
* Call the `async start()` function to start streaming. * Call the `async start()` function to start streaming.
* Call the `end()` function to stop streaming. * Call the `end()` function to stop streaming.
`HLS` extends from https://developer.mozilla.org/en-US/docs/Web/API/EventTarget[`EventTarget`] `Streamer` extends from https://developer.mozilla.org/en-US/docs/Web/API/EventTarget[`EventTarget`]
and dispatches the following events: and dispatches the following events:
* `start` when streaming has started. * `start` when streaming has started.
@@ -80,7 +82,7 @@ Note that https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js] is licensed under
Streamana runs it inside a Web Worker and communicates with it via message passing. Streamana runs it inside a Web Worker and communicates with it via message passing.
The end user can replace the version used by changing the URL in the user interface. The end user can replace the version used by changing the URL in the user interface.
Note also that the https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js] HLS distribution Note also that the https://github.com/davedoesdev/ffmpeg.js[ffmpeg.js] HLS and DASH
contains no H.264 or MP4 code. All encoding is done by the browser using distributions contain no H.264 or MP4 code. All encoding is done by the browser using
https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder[`MediaRecorder`] or https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder[`MediaRecorder`] or
https://www.w3.org/TR/webcodecs/[WebCodecs]. https://www.w3.org/TR/webcodecs/[WebCodecs].

View File

@@ -29,11 +29,12 @@ export class GlCanvas extends Canvas {
} }
}), options); }), options);
this.update_limiter = new UpdateLimiter(); this.update_limiter = new UpdateLimiter();
this.destroyed = false;
} }
// Allow rendering loop to be driven externally (e.g. by the audio encoder) // Allow rendering loop to be driven externally (e.g. by the audio encoder)
// to avoid requestAnimationFrame (or indeed setInterval) throttling. // to avoid requestAnimationFrame (or indeed setInterval) throttling.
onLoop() { onLoop() {
if (this.update_limiter.check()) { if (this.update_limiter.check() && !this.destroyed) {
const now = Date.now(); const now = Date.now();
this.checkRender(); this.checkRender();
// Make sure we don't hog the main thread. Software rendering will take // Make sure we don't hog the main thread. Software rendering will take
@@ -50,14 +51,25 @@ export class GlCanvas extends Canvas {
} }
// Prevent errors after destruction // Prevent errors after destruction
destroy() { destroy() {
this.destroyed = true;
if (this.gl) {
super.destroy(); super.destroy();
}
this.uniforms = { this.uniforms = {
createTexture() { createTexture() {
return {}; return {};
}, },
create() {} create() {},
update() {}
};
this.textures = {
createOrUpdate() {
return {
then() {}
};
},
values: {}
}; };
this.textures = {};
this.buffers = { this.buffers = {
values: {} values: {}
}; };

View File

@@ -8,7 +8,7 @@ export class MuxReceiver extends EventTarget {
}, 0); }, 0);
} }
start({ ffmpeg_lib_url, ffmpeg_args, base_url }) { start({ ffmpeg_lib_url, ffmpeg_args, base_url, protocol }) {
this.worker = new Worker(ffmpeg_lib_url); this.worker = new Worker(ffmpeg_lib_url);
this.worker.onerror = this.onerror.bind(this); this.worker.onerror = this.onerror.bind(this);
this.worker.onmessage = e => { this.worker.onmessage = e => {
@@ -48,7 +48,8 @@ export class MuxReceiver extends EventTarget {
case 'start-stream': case 'start-stream':
this.worker.postMessage({ this.worker.postMessage({
type: 'base-url', type: 'base-url',
data: base_url data: base_url,
protocol
}); });
// falls through // falls through
case 'sending': case 'sending':

View File

@@ -65,9 +65,22 @@
<input id="zoom-video" class="form-check-input" type="checkbox"> <input id="zoom-video" class="form-check-input" type="checkbox">
<label for="zoom-video" class="form-check-label">Minimize vertical bars in local video display</label> <label for="zoom-video" class="form-check-label">Minimize vertical bars in local video display</label>
</div> </div>
<div class="pt-4">
<label class="form-label">Ingestion protocol</label>
<div>
<div class="form-check form-check-inline">
<input id="protocol-hls" name="protocol" class="form-check-input" type="radio" value="ffmpeg-worker-hls.js">
<label for="protocol-hls" class="form-check-label">HLS (H.264)</label>
</div>
<div class="form-check form-check-inline">
<input id="protocol-dash" name="protocol" class="form-check-input" type="radio" value="ffmpeg-worker-dash.js">
<label for="protocol-dash" class="form-check-label">DASH (VP9)</label>
</div>
</div>
</div>
<div class="pt-4"> <div class="pt-4">
<label for="ffmpeg-lib-url" class="form-label">FFmpeg library URL</label> <label for="ffmpeg-lib-url" class="form-label">FFmpeg library URL</label>
<input id="ffmpeg-lib-url" class="form-control" placeholder="ffmpeg-worker-hls.js" type="text"> <input id="ffmpeg-lib-url" class="form-control" type="text">
</div> </div>
</div> </div>
<div id="error-alert" class="alert alert-danger alert-dismissible fade mb-0 flex-grow-0" role="alert"> <div id="error-alert" class="alert alert-danger alert-dismissible fade mb-0 flex-grow-0" role="alert">

View File

@@ -1,17 +1,14 @@
import { GlCanvas } from './gl-canvas.js'; import { GlCanvas } from './gl-canvas.js';
import { import {
HLS, get_default_config_from_url,
video_encoder_codec, Streamer
videoBitsPerSecond } from './streamer.js';
} from './hls.js';
import shader from './shader.js'; import shader from './shader.js';
import { import {
supported_video_configs, supported_video_configs,
max_video_config, max_video_config,
} from './resolution.js'; } from './resolution.js';
const target_frame_rate = 30;
const ingestion_url_el = document.getElementById('ingestion-url'); const ingestion_url_el = document.getElementById('ingestion-url');
ingestion_url_el.value = localStorage.getItem('streamana-ingestion-url'); ingestion_url_el.value = localStorage.getItem('streamana-ingestion-url');
@@ -39,25 +36,34 @@ const initial_ffmpeg_lib_url = (localStorage.getItem('streamana-ffmpeg-lib-url')
if (initial_ffmpeg_lib_url) { if (initial_ffmpeg_lib_url) {
ffmpeg_lib_url_el.value = initial_ffmpeg_lib_url; ffmpeg_lib_url_el.value = initial_ffmpeg_lib_url;
} }
ffmpeg_lib_url_el.addEventListener('input', function () { ffmpeg_lib_url_el.addEventListener('change', function () {
localStorage.setItem('streamana-ffmpeg-lib-url', this.value); const value = this.value.trim();
localStorage.setItem('streamana-ffmpeg-lib-url', value);
if (value) {
protocol_hls_el.disabled = true;
protocol_dash_el.disabled = true;
} else {
protocol_hls_el.disabled = false;
protocol_dash_el.disabled = false;
}
set_ingestion();
}); });
const zoom_video_el = document.getElementById('zoom-video'); const zoom_video_el = document.getElementById('zoom-video');
zoom_video_el.checked = !!localStorage.getItem('streamana-zoom-video'); zoom_video_el.checked = !!localStorage.getItem('streamana-zoom-video');
zoom_video_el.addEventListener('input', function () { zoom_video_el.addEventListener('change', function () {
localStorage.setItem('streamana-zoom-video', this.checked ? 'true' : ''); localStorage.setItem('streamana-zoom-video', this.checked ? 'true' : '');
}); });
const lock_portrait_el = document.getElementById('lock-portrait'); const lock_portrait_el = document.getElementById('lock-portrait');
lock_portrait_el.checked = !!localStorage.getItem('streamana-lock-portrait'); lock_portrait_el.checked = !!localStorage.getItem('streamana-lock-portrait');
lock_portrait_el.addEventListener('input', function () { lock_portrait_el.addEventListener('change', function () {
localStorage.setItem('streamana-lock-portrait', this.checked ? 'true' : ''); localStorage.setItem('streamana-lock-portrait', this.checked ? 'true' : '');
}); });
const greyscale_el = document.getElementById('greyscale'); const greyscale_el = document.getElementById('greyscale');
greyscale_el.checked = !!localStorage.getItem('streamana-greyscale'); greyscale_el.checked = !!localStorage.getItem('streamana-greyscale');
greyscale_el.addEventListener('input', function () { greyscale_el.addEventListener('change', function () {
localStorage.setItem('streamana-greyscale', this.checked ? 'true' : ''); localStorage.setItem('streamana-greyscale', this.checked ? 'true' : '');
}); });
@@ -106,37 +112,82 @@ camera_el.addEventListener('click', camera_save);
const camera_swap_el = document.getElementById('camera-swap'); const camera_swap_el = document.getElementById('camera-swap');
const protocol_hls_el = document.getElementById('protocol-hls');
const protocol_dash_el = document.getElementById('protocol-dash');
const resolution_el = document.getElementById('resolution');
let streamer_config;
let video_config; let video_config;
let preferred_resolution = localStorage.getItem('streamana-resolution'); const video_configs = new Map();
if (preferred_resolution) {
function set_ingestion_protocol(protocol) {
if (protocol === 'dash') {
protocol_hls_el.checked = false;
protocol_dash_el.checked = true;
ffmpeg_lib_url_el.placeholder = protocol_dash_el.value;
} else {
protocol_hls_el.checked = true;
protocol_dash_el.checked = false;
ffmpeg_lib_url_el.placeholder = protocol_hls_el.value;
}
}
set_ingestion_protocol(localStorage.getItem('streamana-ingestion-protocol'));
async function set_ingestion() {
const ffmpeg_lib_url = ffmpeg_lib_url_el.value.trim() ||
ffmpeg_lib_url_el.placeholder.trim();
streamer_config = get_default_config_from_url(ffmpeg_lib_url);
set_ingestion_protocol(streamer_config.protocol);
localStorage.setItem('streamana-ingestion-protocol', streamer_config.protocol);
video_config = null;
let preferred_resolution = localStorage.getItem('streamana-resolution');
if (preferred_resolution) {
video_config = await max_video_config({ video_config = await max_video_config({
...JSON.parse(preferred_resolution), ...JSON.parse(preferred_resolution),
codec: video_encoder_codec, ...streamer_config.video,
bitrate: videoBitsPerSecond ...streamer_config.webcodecs.video
}, true); }, true);
} }
if (!video_config) { if (!video_config) {
video_config = await max_video_config({ video_config = await max_video_config({
width: 1280, width: 1280,
height: 720, height: 720,
ratio: 16/9, ratio: 16/9,
codec: video_encoder_codec, ...streamer_config.video,
bitrate: videoBitsPerSecond ...streamer_config.webcodecs.video
}, true); }, true);
} }
const resolution_el = document.getElementById('resolution');
const video_configs = new Map(); resolution_el.innerHTML = '';
for (let config of (await supported_video_configs({ for (let config of (await supported_video_configs({
codec: video_encoder_codec, ...streamer_config.video,
bitrate: videoBitsPerSecond ...streamer_config.webcodecs.video
}, true)).filter(c => c.ratio >= 1)) { }, true)).filter(c => c.ratio >= 1)) {
const option = document.createElement('option'); const option = document.createElement('option');
option.innerHTML = `${config.width}x${config.height} &mdash; ${config.label}`; option.innerHTML = `${config.width}x${config.height} &mdash; ${config.label}`;
option.selected = config.label === video_config.label; option.selected = config.label === video_config.label;
resolution_el.appendChild(option); resolution_el.appendChild(option);
video_configs.set(option.innerText, config); video_configs.set(option.innerText, config);
}
} }
resolution_el.addEventListener('change', function (ev) {
await set_ingestion();
protocol_hls_el.addEventListener('change', function () {
ffmpeg_lib_url_el.placeholder = protocol_hls_el.value;
set_ingestion();
});
protocol_dash_el.addEventListener('change', function () {
ffmpeg_lib_url_el.placeholder = protocol_dash_el.value;
set_ingestion();
});
resolution_el.addEventListener('change', function () {
video_config = video_configs.get(this.value); video_config = video_configs.get(this.value);
localStorage.setItem('streamana-resolution', JSON.stringify({ localStorage.setItem('streamana-resolution', JSON.stringify({
width: video_config.width, width: video_config.width,
@@ -145,7 +196,7 @@ resolution_el.addEventListener('change', function (ev) {
})); }));
}); });
let hls; let streamer;
async function start() { async function start() {
const ingestion_url = ingestion_url_el.value.trim(); const ingestion_url = ingestion_url_el.value.trim();
@@ -172,6 +223,8 @@ async function start() {
lock_portrait_el.disabled = true; lock_portrait_el.disabled = true;
zoom_video_el.disabled = true; zoom_video_el.disabled = true;
resolution_el.disabled = true; resolution_el.disabled = true;
protocol_hls_el.disabled = true;
protocol_dash_el.disabled = true;
waiting_el.classList.remove('d-none'); waiting_el.classList.remove('d-none');
mic_el.removeEventListener('click', mic_save); mic_el.removeEventListener('click', mic_save);
camera_el.removeEventListener('click', camera_save); camera_el.removeEventListener('click', camera_save);
@@ -218,7 +271,7 @@ async function start() {
camera_icon_el.classList.add('off'); camera_icon_el.classList.add('off');
} }
camera_el.addEventListener('click', camera_save); camera_el.addEventListener('click', camera_save);
greyscale_el.removeEventListener('input', greyscale); greyscale_el.removeEventListener('change', greyscale);
camera_swap_el.classList.add('d-none'); camera_swap_el.classList.add('d-none');
camera_swap_el.removeEventListener('click', about_face); camera_swap_el.removeEventListener('click', about_face);
canvas_el_parent.classList.add('mx-auto'); canvas_el_parent.classList.add('mx-auto');
@@ -267,8 +320,9 @@ async function start() {
track.stop(); track.stop();
} }
} }
if (hls) { if (streamer) {
hls.end(!!err); streamer.end(!!err);
streamer = null;
} }
go_live_el.checked = false; go_live_el.checked = false;
@@ -279,6 +333,8 @@ async function start() {
lock_portrait_el.disabled = false; lock_portrait_el.disabled = false;
zoom_video_el.disabled = false; zoom_video_el.disabled = false;
resolution_el.disabled = false; resolution_el.disabled = false;
protocol_hls_el.disabled = ffmpeg_lib_url_el.value.trim();
protocol_dash_el.disabled = ffmpeg_lib_url_el.value.trim();;
waiting_el.classList.add('d-none'); waiting_el.classList.add('d-none');
canvas_el.classList.add('d-none'); canvas_el.classList.add('d-none');
} }
@@ -356,7 +412,7 @@ async function start() {
camera_swap_el.removeEventListener('click', about_face); camera_swap_el.removeEventListener('click', about_face);
async function finish() { async function finish() {
await hls.start(); await streamer.start();
mic_el.addEventListener('click', media_toggle); mic_el.addEventListener('click', media_toggle);
camera_el.addEventListener('click', media_toggle); camera_el.addEventListener('click', media_toggle);
camera_swap_el.addEventListener('click', about_face); camera_swap_el.addEventListener('click', about_face);
@@ -375,8 +431,8 @@ async function start() {
width: video_config.width, width: video_config.width,
height: video_config.height, height: video_config.height,
frameRate: { frameRate: {
ideal: target_frame_rate, ideal: streamer_config.video.framerate,
max: target_frame_rate max: streamer_config.video.framerate
}, },
facingMode: requested_facing_mode facingMode: requested_facing_mode
}; };
@@ -534,7 +590,7 @@ async function start() {
// tell shader whether to greyscale // tell shader whether to greyscale
gl_canvas.setUniform('u_greyscale', greyscale_el.checked); gl_canvas.setUniform('u_greyscale', greyscale_el.checked);
greyscale_el.addEventListener('input', greyscale); greyscale_el.addEventListener('change', greyscale);
// tell shader camera hasn't started // tell shader camera hasn't started
gl_canvas.setUniform('u_active', false); gl_canvas.setUniform('u_active', false);
@@ -572,7 +628,7 @@ async function start() {
// capture video from the canvas // capture video from the canvas
// Note: Safari on iOS doesn't get any data, might be related to // Note: Safari on iOS doesn't get any data, might be related to
// https://bugs.webkit.org/show_bug.cgi?id=181663 // https://bugs.webkit.org/show_bug.cgi?id=181663
canvas_stream = canvas_el.captureStream(target_frame_rate); canvas_stream = canvas_el.captureStream(streamer_config.video.framerate);
// add audio to canvas stream // add audio to canvas stream
audio_dest = audio_context.createMediaStreamDestination(); audio_dest = audio_context.createMediaStreamDestination();
@@ -593,11 +649,15 @@ async function start() {
audio_source = silence; audio_source = silence;
audio_source.connect(audio_dest); audio_source.connect(audio_dest);
// HLS from the canvas stream to the ingestion URL // Stream from the canvas stream to the ingestion URL
hls = new HLS(canvas_stream, audio_context, ingestion_url, ffmpeg_lib_url, target_frame_rate, lock_portrait); streamer = new Streamer(canvas_stream,
hls.addEventListener('run', () => console.log('HLS running')); audio_context,
hls.addEventListener('exit', ev => { ingestion_url,
const msg = `HLS exited with status ${ev.detail.code}`; streamer_config,
lock_portrait);
streamer.addEventListener('run', () => console.log('Streamer running'));
streamer.addEventListener('exit', ev => {
const msg = `Streamer exited with status ${ev.detail.code}`;
if (ev.detail.code === 0) { if (ev.detail.code === 0) {
console.log(msg); console.log(msg);
cleanup(); cleanup();
@@ -605,8 +665,8 @@ async function start() {
cleanup(msg); cleanup(msg);
} }
}); });
hls.addEventListener('error', cleanup); streamer.addEventListener('error', cleanup);
hls.addEventListener('start', function () { streamer.addEventListener('start', function () {
if (done) { if (done) {
this.end(true); this.end(true);
} }
@@ -616,7 +676,7 @@ async function start() {
go_live_el.disabled = false; go_live_el.disabled = false;
update(); update();
}); });
hls.addEventListener('update', update); streamer.addEventListener('update', update);
await start_media(facing_mode); await start_media(facing_mode);
} catch (ex) { } catch (ex) {
@@ -626,5 +686,5 @@ async function start() {
function stop() { function stop() {
go_live_el.disabled = true; go_live_el.disabled = true;
hls.end(); streamer.end();
} }

View File

@@ -1,20 +1,71 @@
import { UpdateLimiter } from './update-limiter.js'; import { UpdateLimiter } from './update-limiter.js';
import { MuxReceiver } from './mux-receiver.js'; import { MuxReceiver } from './mux-receiver.js';
const audioBitsPerSecond = 128 * 1000;
export const videoBitsPerSecond = 2500 * 1000;
const key_frame_interval = 3; const key_frame_interval = 3;
export const video_encoder_codec = 'avc1.42E01E' /*'avc1.42001E'*/; export function get_default_config_from_url(ffmpeg_lib_url) {
const protocol = ffmpeg_lib_url.indexOf('worker-dash') >= 0 ? 'dash' : 'hls';
return {
ffmpeg_lib_url,
protocol,
video: {
bitrate: 2500 * 1000,
framerate: 30
},
audio: {
bitrate: 128 * 1000
},
media_recorder: {
video: {
codec: protocol === 'dash' ? 'vp9' : 'H264',
},
audio: {
codec: 'opus'
},
webm: true,
mp4: false // requires ffmpeg-worker-hls.js or ffmpeg-worker-dash.js
// to be configured with MP4 support (which is not the default)
},
webcodecs: {
video: {
...(protocol === 'dash' ? {
codec: 'vp09.00.10.08.01'
} : {
codec: 'avc1.42E01E' /*'avc1.42001E'*/,
avc: { format: 'annexb' }
})
},
audio: {
codec: 'opus' /*'pcm'*/,
},
webm_muxer: {
video: {
codec: protocol === 'dash' ? 'V_VP9' : 'V_MPEG4/ISO/AVC'
},
audio: {
codec: 'A_OPUS',
bit_depth: 0 // 32 for pcm */
}
}
},
ffmpeg: {
video: {
codec: protocol === 'dash' ? 'libvpx-vp9' : 'libx264'
},
audio: {
codec: protocol === 'dash' ? 'libopus' : 'aac'
}
}
};
}
export class HLS extends EventTarget { export class Streamer extends EventTarget {
constructor(stream, audio_context, base_url, ffmpeg_lib_url, frame_rate, rotate) { constructor(stream, audio_context, base_url, config, rotate) {
super(); super();
this.stream = stream; this.stream = stream;
this.audio_context = audio_context; this.audio_context = audio_context;
this.base_url = base_url; this.base_url = base_url;
this.ffmpeg_lib_url = ffmpeg_lib_url; this.config = config;
this.frame_rate = frame_rate;
if (rotate) { if (rotate) {
this.ffmpeg_metadata = ['-metadata:s:v:0', 'rotate=-90']; this.ffmpeg_metadata = ['-metadata:s:v:0', 'rotate=-90'];
} else { } else {
@@ -30,25 +81,47 @@ export class HLS extends EventTarget {
return; return;
} }
const mrcfg = this.config.media_recorder;
const mp4 = async () => {
if (mfcrg.mp4) {
// try MediaRecorder MP4 - this should work on Safari MacOS and iOS,
// producing H.264 video and AAC audio
await this.media_recorder('video/mp4');
console.log("Using MediaRecorder MP4 (H264,aac)");
} else {
throw new Error('no supported encoding methods');
}
};
const webcodecs = async () => {
const wccfg = this.config.webcodecs;
if (wccfg) {
try { try {
// first try WebM/H264 MediaRecorder - this should work on Chrome Linux and Windows // try WebCodecs - this should work on Chrome including Android
await this.media_recorder('video/webm;codecs=H264'); await this.webcodecs();
console.log("Using MediaRecorder WebM/h264");
} catch (ex) {
console.warn(ex.toString());
try {
// next try WebCodecs - this should work on Chrome including Android
await this.webcodecs(video_encoder_codec,
'opus' /*'pcm'*/,
{ avc: { format: 'annexb' } });
console.log("Using WebCodecs"); console.log("Using WebCodecs");
} catch (ex) { } catch (ex) {
console.warn(ex.toString()); console.warn(ex.toString());
// finally try MP4 - this should work on Safari MacOS and iOS, producing H264 await mp4();
// this assumes ffmpeg-hls.js has been configured with MP4 support
await this.media_recorder('video/mp4');
console.log("Using MediaRecorder MP4");
} }
} else {
await mp4();
}
};
if (mrcfg.webm) {
try {
// try MediaRecorder WebM - this should work on Chrome Linux and Windows
const codecs = `${mrcfg.video.codec},${mrcfg.audio.codec}`;
await this.media_recorder(`video/webm;codecs=${codecs}`);
console.log(`Using MediaRecorder WebM (${codecs})`);
} catch (ex) {
console.warn(ex.toString());
await webcodecs();
}
} else {
await webcodecs();
} }
this.started = true; this.started = true;
@@ -59,7 +132,7 @@ export class HLS extends EventTarget {
await this.audio_context.audioWorklet.addModule('./dummy-worklet.js'); await this.audio_context.audioWorklet.addModule('./dummy-worklet.js');
this.dummy_processor = new AudioWorkletNode(this.audio_context, 'dummy-processor', { this.dummy_processor = new AudioWorkletNode(this.audio_context, 'dummy-processor', {
processorOptions: { processorOptions: {
update_rate: this.frame_rate update_rate: this.config.video.framerate
} }
}); });
this.dummy_processor.onerror = onerror; this.dummy_processor.onerror = onerror;
@@ -73,6 +146,30 @@ export class HLS extends EventTarget {
this.dummy_processor.disconnect(); this.dummy_processor.disconnect();
} }
receiver_args(video_codec, audio_codec) {
return {
ffmpeg_lib_url: this.config.ffmpeg_lib_url,
ffmpeg_args: [
'-i', '/work/stream1',
'-map', '0:v',
'-map', '0:a',
...(video_codec === this.config.ffmpeg.video.codec ||
video_codec === 'copy' ?
['-c:v', 'copy'] : // pass through the video data (no decoding or encoding)
['-c:v', this.config.ffmpeg.video.codec, // re-encode video
'-b:v', this.config.video.bitrate.toString()]), // set video bitrate
...this.ffmpeg_metadata,
...(audio_codec === this.config.ffmpeg.audio.codec ||
audio_codec === 'copy' ?
['-c:a', 'copy'] : // pass through the audio data
['-c:a', this.config.ffmpeg.audio.codec, // re-encode audio
'-b:a', this.config.audio.bitrate.toString()]) // set audio bitrate
],
base_url: this.base_url,
protocol: this.config.protocol
};
}
async media_recorder(mimeType) { async media_recorder(mimeType) {
const onerror = this.onerror.bind(this); const onerror = this.onerror.bind(this);
@@ -80,8 +177,8 @@ export class HLS extends EventTarget {
// note we don't start recording until ffmpeg has started (below) // note we don't start recording until ffmpeg has started (below)
const recorder = new MediaRecorder(this.stream, { const recorder = new MediaRecorder(this.stream, {
mimeType, mimeType,
audioBitsPerSecond, videoBitsPerSecond: this.config.video.bitrate,
videoBitsPerSecond audioBitsPerSecond: this.config.audio.bitrate
}); });
recorder.onerror = onerror; recorder.onerror = onerror;
@@ -101,27 +198,71 @@ export class HLS extends EventTarget {
await this.start_dummy_processor(); await this.start_dummy_processor();
let video_codec, audio_codec;
if (recorder.mimeType === 'video/mp4') {
video_codec = 'libx264';
audio_codec = 'aac';
} else {
switch (this.config.media_recorder.video.codec.toLowerCase()) {
case 'av1':
video_codec = 'libaom-av1';
break;
case 'h264':
video_codec = 'libx264';
break;
case 'vp8':
video_codec = 'libvpx';
break;
case 'vp9':
video_codec = 'libvpx-vp9';
break;
default:
video_codec = null;
break;
}
switch (this.config.media_recorder.audio.codec.toLowerCase()) {
case 'flac':
audio_codec = 'flac';
break;
case 'mp3':
audio_codec = 'libmp3lame';
break;
case 'opus':
audio_codec = 'libopus';
break;
case 'vorbis':
audio_codec = 'libvorbis';
break;
case 'pcm':
audio_codec = 'f32le';
break;
default:
if (audio_codes.startsWith('mp4a')) {
audio_codec = 'aac';
} else {
audio_codec = null;
}
break;
}
}
// start the ffmpeg worker // start the ffmpeg worker
this.receiver = new MuxReceiver(); this.receiver = new MuxReceiver();
this.receiver.addEventListener('message', e => { this.receiver.addEventListener('message', e => {
const msg = e.detail; const msg = e.detail;
switch (msg.type) { switch (msg.type) {
case 'ready': case 'ready':
this.receiver.start({ this.receiver.start(this.receiver_args(video_codec, audio_codec));
ffmpeg_lib_url: this.ffmpeg_lib_url,
ffmpeg_args: [
'-i', '/work/stream1',
'-map', '0:v',
'-map', '0:a',
'-c:v', 'copy', // pass through the video data (h264, no decoding or encoding)
...this.ffmpeg_metadata,
...(recorder.mimeType === 'video/mp4' ?
['-c:a', 'copy'] : // assume already AAC
['-c:a', 'aac', // re-encode audio as AAC-LC
'-b:a', audioBitsPerSecond.toString()]) // set audio bitrate
],
base_url: this.base_url
});
break; break;
case 'error': case 'error':
@@ -153,7 +294,7 @@ export class HLS extends EventTarget {
}); });
} }
async webcodecs(video_codec, audio_codec, video_config, audio_config) { async webcodecs() {
const onerror = this.onerror.bind(this); const onerror = this.onerror.bind(this);
const video_track = this.stream.getVideoTracks()[0]; const video_track = this.stream.getVideoTracks()[0];
@@ -209,13 +350,11 @@ export class HLS extends EventTarget {
readable: video_readable, readable: video_readable,
key_frame_interval, key_frame_interval,
config: { config: {
codec: video_codec, ...this.config.video,
bitrate: videoBitsPerSecond, ...this.config.webcodecs.video,
framerate: this.frame_rate,
latencyMode: 'realtime', latencyMode: 'realtime',
width: video_settings.width, width: video_settings.width,
height: video_settings.height, height: video_settings.height,
...video_config
}, },
}, [video_readable]); }, [video_readable]);
@@ -224,11 +363,10 @@ export class HLS extends EventTarget {
audio: true, audio: true,
readable: audio_readable, readable: audio_readable,
config: { config: {
codec: audio_codec, ...this.config.audio,
bitrate: audioBitsPerSecond, ...this.config.webcodecs.audio,
sampleRate: audio_settings.sampleRate, sampleRate: audio_settings.sampleRate,
numberOfChannels: audio_settings.channelCount, numberOfChannels: audio_settings.channelCount,
...audio_config
}, },
}, [audio_readable]); }, [audio_readable]);
@@ -257,6 +395,60 @@ export class HLS extends EventTarget {
} }
}; };
let video_codec;
switch (this.config.webcodecs.video.codec) {
case 'V_AV1':
video_codec = 'libaom-av1';
break;
case 'V_MPEG4/ISO/AVC':
video_codec = 'libx264';
break;
case 'V_VP8':
video_codec = 'libvpx';
break;
case 'V_VP9':
video_codec = 'libvpx-vp9';
break;
default:
video_codec = null;
break;
}
let audio_codec;
switch (this.config.webcodecs.audio.codec) {
case 'A_FLAC':
audio_codec = 'flac';
break;
case 'A_MPEG/L3':
audio_codec = 'libmp3lame';
break;
case 'A_OPUS':
audio_codec = 'libopus';
break;
case 'A_VORBIS':
audio_codec = 'libvorbis';
break;
case 'A_PCM/FLOAT/IEEE':
audio_codec = 'f32le';
break;
default:
if (audio_codes.startsWith('A_AAC')) {
audio_codec = 'aac';
} else {
audio_codec = null;
}
break;
}
this.worker.postMessage({ this.worker.postMessage({
type: 'start', type: 'start',
webm_metadata: { webm_metadata: {
@@ -264,28 +456,19 @@ export class HLS extends EventTarget {
video: { video: {
width: video_settings.width, width: video_settings.width,
height: video_settings.height, height: video_settings.height,
frame_rate: this.frame_rate, frame_rate: this.config.video.framerate,
codec_id: 'V_MPEG4/ISO/AVC' codec_id: this.config.webcodecs.webm_muxer.video.codec
}, },
audio: { audio: {
sample_rate: audio_settings.sampleRate, sample_rate: audio_settings.sampleRate,
channels: audio_settings.channelCount, channels: audio_settings.channelCount,
codec_id: 'A_OPUS' bit_depth: this.config.webcodecs.webm_muxer.audio.bit_depth,
codec_id: this.config.webcodecs.webm_muxer.audio.codec
} }
}, },
webm_receiver: './mux-receiver.js', webm_receiver: './mux-receiver.js',
webm_receiver_data: { name: 'stream1' }, webm_receiver_data: { name: 'stream1' },
ffmpeg_lib_url: this.ffmpeg_lib_url, ...this.receiver_args(video_codec, audio_codec)
base_url: this.base_url,
ffmpeg_args: [
'-i', '/work/stream1',
'-map', '0:v',
'-map', '0:a',
'-c:v', 'copy', // pass through the video data (h264, no decoding or encoding)
...this.ffmpeg_metadata,
'-c:a', 'aac', // re-encode audio as AAC-LC
'-b:a', audioBitsPerSecond.toString() // set audio bitrate
]
}); });
} }