Files
streamana/site/streamer.js
2021-10-14 18:07:21 +01:00

500 lines
16 KiB
JavaScript

import { UpdateLimiter } from './update-limiter.js';
import { MuxReceiver } from './mux-receiver.js';
const key_frame_interval = 3;
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 Streamer extends EventTarget {
constructor(stream, audio_context, base_url, config, rotate) {
super();
this.stream = stream;
this.audio_context = audio_context;
this.base_url = base_url;
this.config = config;
if (rotate) {
this.ffmpeg_metadata = ['-metadata:s:v:0', 'rotate=-90'];
} else {
this.ffmpeg_metadata = [];
}
this.update_event = new CustomEvent('update');
this.sending = false;
this.started = false;
}
async start() {
if (this.started) {
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 WebCodecs - this should work on Chrome including Android
await this.webcodecs();
console.log("Using WebCodecs");
} catch (ex) {
console.warn(ex.toString());
await 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;
}
async start_dummy_processor() {
// use a persistent audio generator to trigger updates to avoid setInterval throttling
await this.audio_context.audioWorklet.addModule('./dummy-worklet.js');
this.dummy_processor = new AudioWorkletNode(this.audio_context, 'dummy-processor', {
processorOptions: {
update_rate: this.config.video.framerate
}
});
this.dummy_processor.onerror = onerror;
this.dummy_processor.onprocessorerror = onerror;
this.dummy_processor.port.onmessage = () => this.dispatchEvent(this.update_event);
this.dummy_processor.connect(this.audio_context.destination);
}
stop_dummy_processor() {
this.dummy_processor.port.postMessage({ type: 'stop' });
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) {
const onerror = this.onerror.bind(this);
// set up video recording from the stream
// note we don't start recording until ffmpeg has started (below)
const recorder = new MediaRecorder(this.stream, {
mimeType,
videoBitsPerSecond: this.config.video.bitrate,
audioBitsPerSecond: this.config.audio.bitrate
});
recorder.onerror = onerror;
recorder.onstop = () => {
if (this.receiver) {
this.receiver.end({ force: false });
}
};
// push encoded data into the ffmpeg worker
recorder.ondataavailable = async event => {
if (this.receiver) {
this.receiver.muxed_data(await event.data.arrayBuffer(),
{ name: 'stream1' });
}
};
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
this.receiver = new MuxReceiver();
this.receiver.addEventListener('message', e => {
const msg = e.detail;
switch (msg.type) {
case 'ready':
this.receiver.start(this.receiver_args(video_codec, audio_codec));
break;
case 'error':
onerror(msg.detail);
break;
case 'start-stream':
// start recording; produce data every second, we'll be chunking it anyway
recorder.start(1000);
this.dispatchEvent(new CustomEvent('start'));
break;
case 'sending':
this.sending = true;
break;
case 'exit':
this.receiver = null;
if (recorder.state !== 'inactive') {
recorder.stop();
}
this.stop_dummy_processor();
if ((msg.code === 'force-end') && !this.sending) {
msg.code = 0;
}
this.dispatchEvent(new CustomEvent(msg.type, { detail: { code: msg.code } }));
break;
}
});
}
async webcodecs() {
const onerror = this.onerror.bind(this);
const video_track = this.stream.getVideoTracks()[0];
const video_readable = (new MediaStreamTrackProcessor(video_track)).readable;
const video_settings = video_track.getSettings();
const audio_track = this.stream.getAudioTracks()[0];
const audio_readable = (new MediaStreamTrackProcessor(audio_track)).readable;
const audio_settings = audio_track.getSettings();
await this.start_dummy_processor();
let num_exits = 0;
const relay_data = ev => {
const msg = ev.data;
switch (msg.type) {
case 'error':
onerror(msg.detail);
break;
case 'exit':
if (++num_exits === 2) {
this.worker.postMessage({
type: 'end'
});
}
break;
case 'audio-data':
case 'video-data':
this.worker.postMessage(msg, [msg.data]);
break;
}
};
const video_worker = new Worker('./encoder-worker.js');
video_worker.onerror = onerror;
video_worker.onmessage = relay_data;
const audio_worker = new Worker('./encoder-worker.js');
audio_worker.onerror = onerror;
audio_worker.onmessage = relay_data;
this.worker = new Worker('./webm-worker.js');
this.worker.onerror = onerror;
this.worker.onmessage = e => {
const msg = e.data;
switch (msg.type) {
case 'start-stream':
video_worker.postMessage({
type: 'start',
readable: video_readable,
key_frame_interval,
config: {
...this.config.video,
...this.config.webcodecs.video,
latencyMode: 'realtime',
width: video_settings.width,
height: video_settings.height,
},
}, [video_readable]);
audio_worker.postMessage({
type: 'start',
audio: true,
readable: audio_readable,
config: {
...this.config.audio,
...this.config.webcodecs.audio,
sampleRate: audio_settings.sampleRate,
numberOfChannels: audio_settings.channelCount,
},
}, [audio_readable]);
this.dispatchEvent(new CustomEvent('start'));
break;
case 'error':
onerror(msg.detail);
break;
case 'sending':
this.sending = true;
break;
case 'exit':
this.worker.terminate();
this.worker = null;
video_worker.terminate();
audio_worker.terminate();
this.stop_dummy_processor();
if ((msg.code === 'force-end') && this.was_not_sending) {
msg.code = 0;
}
this.dispatchEvent(new CustomEvent(msg.type, { detail: { code: msg.code } }));
break;
}
};
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({
type: 'start',
webm_metadata: {
max_segment_duration: BigInt(1000000000),
video: {
width: video_settings.width,
height: video_settings.height,
frame_rate: this.config.video.framerate,
codec_id: this.config.webcodecs.webm_muxer.video.codec
},
audio: {
sample_rate: audio_settings.sampleRate,
channels: audio_settings.channelCount,
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_data: { name: 'stream1' },
...this.receiver_args(video_codec, audio_codec)
});
}
end(force) {
this.was_not_sending = !this.sending;
force = force || this.was_not_sending;
if (force) {
if (this.receiver) {
this.receiver.end({ force });
} else if (this.worker) {
this.worker.postMessage({
type: 'end',
force
});
}
} else {
for (let track of this.stream.getTracks()) {
track.stop();
}
}
}
onerror(e) {
if (this.receiver || this.worker) {
this.dispatchEvent(new CustomEvent('error', { detail: e }));
}
};
}