From da31feba15e282ff401a8f29f5496ccb00840b79 Mon Sep 17 00:00:00 2001 From: Muaz Ahmad Date: Thu, 24 Aug 2023 13:28:30 +0500 Subject: [PATCH] comments --- hls-player.js | 34 +++++++++++++++++++++++++++++--- mp4-tree.js | 54 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/hls-player.js b/hls-player.js index fff6863..08ee5ff 100644 --- a/hls-player.js +++ b/hls-player.js @@ -1,5 +1,7 @@ +// handles loading the playlist file class PlaylistLoader { constructor() { + // to work with the stream-server impl this.playlist_src = '/list/' + window.location.pathname.slice(6); this.last_segment = null; this.refresh_interval = null; @@ -9,23 +11,32 @@ class PlaylistLoader { async fetch_playlist() { const response = await fetch(this.playlist_src); + // if returns a 404, means stream ended and playlist was deleted by cleanup + // if success, parse the file and fetch playlist again after n ms if (response.status == 200) { this.parse_playlist(await response.text()); setTimeout(this.fetch_playlist.bind(this), this.refresh_interval * 1000); } } + // degraded m3u8 parsing + // impl only cares about segment uris, the target duration length + // assumes this is not the main manifest file so no switching + // assumes init fragment is init.mp4 parse_playlist(playlist_content) { let lines = playlist_content.split('\n'); let segments = []; - let segment_block_flag = this.last_segment === null ? false : true; + let segment_block_flag = this.last_segment === null ? false : true; // for first passthrough for (let i = 0; i < lines.length; i++) { + // sets segment fetch frequency if (lines[i].startsWith("#")) { if (lines[i].startsWith("EXT-X-TARGETDURATION", 1)) { this.refresh_interval = parseFloat(lines[i].split(':')[1]); } } else { if (segment_block_flag || lines[i] == '') { + // if the current segment line contains the last "acked" segment + // "ack" any following segments if (lines[i] == this.last_segment) { segment_block_flag = false; } @@ -34,16 +45,24 @@ class PlaylistLoader { } } } + // add any segments acked to the segment fetch queue this.new_segments = this.new_segments.concat(segments); if (segments.length != 0) { + // update last acked segment to last newly acked segment if any this.last_segment = segments.at(-1); } } } +// handles the video fetching and source buffering class VideoLoader { + // entry point, should just be able to use this by adding this script: + // constructor(vid_tag_id) { + // only relevant to uri construction + // jank to match the stream-server impl this.stream_key = window.location.pathname.slice(6); + // init playlist loading as a check to see if playlist is even live this.playlist_loader = new PlaylistLoader(); this.player = document.getElementById(vid_tag_id); this.media_source = new MediaSource(); @@ -52,6 +71,8 @@ class VideoLoader { } async prepare_buffer(_) { + // fetch init.mp4 fragment and parse its mimetype + // codec info is needed for MediaSource compat let init_frag = await this.fetch_video('/vid/' + this.stream_key + '/init.mp4'); let mime = (new MP4Tree(init_frag)).get_mime(); if (!MediaSource.isTypeSupported(mime)) { @@ -60,15 +81,18 @@ class VideoLoader { this.media_buffer = this.media_source.addSourceBuffer(mime); this.media_buffer.mode = 'segments'; this.media_buffer.appendBuffer(init_frag); - await this.fetch_new_segments(); + await this.fetch_new_segments(); // next functions rely on some data being bufferred already this.resync_video(); this.cleanup_old(); } async cleanup_old() { + // remove old segments every 1 min let timeout = 60000; + // if removal cannot happen since buffer is busy, try again in a bit if (!this.media_buffer.updating) { - this.media_buffer.remove(0, this.media_buffer.buffered.end(0) - 10); + // remove segments that are older than 1min behind the current end of buffer + this.media_buffer.remove(0, this.media_buffer.buffered.end(0) - 60); } else { timeout = 250; } @@ -78,6 +102,8 @@ class VideoLoader { async resync_video() { if (!this.media_buffer.updating) { const buffer_end = this.media_buffer.buffered.end(0); + // if video is over 15s behind, seek to 5s behind current end. + // not called on loop, only once on start if (buffer_end - this.player.currentTime > 15) { this.player.currentTime = buffer_end - 5; } @@ -85,12 +111,14 @@ class VideoLoader { } async fetch_new_segments() { + // for each video in the new segments queue, fetch and append it while (this.playlist_loader.new_segments.length > 0) { if (!this.media_buffer.updating) { let segment_uri = this.playlist_loader.new_segments.shift(); let segment_stream = await this.fetch_video(segment_uri); this.media_buffer.appendBuffer(segment_stream); } + // wait between each append try to let processing finish await new Promise(r => setTimeout(r, 250)); } setTimeout(this.fetch_new_segments.bind(this), this.playlist_loader.refresh_interval * 1000); diff --git a/mp4-tree.js b/mp4-tree.js index 18ef1aa..a19fb80 100644 --- a/mp4-tree.js +++ b/mp4-tree.js @@ -1,3 +1,4 @@ +// add a getString to make reading box headers easier DataView.prototype.getString = function(offset, length) { text = ''; let end = offset + length; @@ -7,19 +8,43 @@ DataView.prototype.getString = function(offset, length) { return text; } - +// mp4 tree traverser, as is only good for getting avc1 + mp4a codec info. +// relevant tree parts: +// root +// |-moov +// |-trak +// |-mdia +// |-minf +// |-vmhd +// |-stbl +// |-stsd +// |-avc1 +// |-avcC // has video codec profile +// |-trak +// |-mdia +// |-minf +// |-smhd +// |-stbl +// |-stsd +// |-mp4a +// |-esds // has audio codec profile +// +// headers are 4byte len + 4byte name string +// most details taken from https://web.archive.org/web/20180219054429/http://l.web.umkc.edu/lizhu/teaching/2016sp.video-communication/ref/mp4.pdf +// avc1 and mp4a specific header offsets from https://xhelmboyx.tripod.com/formats/mp4-layout.txt class MP4Tree { constructor(data) { this.data = new DataView(data); this.codecs = []; - this.idx = 0; + this.idx = 0; // current byte index in data this.parse_codecs(); } parse_codecs() { this.parse_into('moov', 8); + // loop over all traks (can handle pure audio, pure video, both, multiple) while (this.idx < this.data.byteLength) { - if (!this.parse_until('trak')) { + if (!this.parse_until('trak')) { // if no more tracks in file break; } const curr_track_end = this.idx + this.read_next_head().len; @@ -37,21 +62,27 @@ class MP4Tree { this.parse_mp4a(); } } - this.idx = curr_track_end; + this.idx = curr_track_end; // always skip to end of current processed track } } parse_avc1() { - this.parse_into('avc1', 86); + this.parse_into('avc1', 86); // avc1 has 78 bytes of frame metadata to skip this.parse_into('avcC', 8); - const avc_profile = (this.data.getUint32(this.idx) & 0xffffff).toString(16); + // ex, 01 64 00 1f + // 01 is avc version (always 1, bit-and it off) + // rest is video codec profile, rep as hex + const avc_profile = (this.data.getUint32(this.idx) & 0xffffff).toString(16); this.codecs.push('avc1.' + avc_profile); } parse_mp4a() { - this.parse_into('mp4a', 36); + this.parse_into('mp4a', 36); // 28 bytes of subboxes with unclear offsets this.parse_into('esds', 12); - const oti = this.data.getUint8(this.idx + 13).toString(16); + // mp4a is as mp4a.40.2 + // oti has defined position, rep as hex + // aot as a subtype is defined as 5 bits of the fetched byte, rep as dec + const oti = this.data.getUint8(this.idx + 13).toString(16); const aot = (this.data.getUint8(this.idx + 31) >> 3).toString(10); this.codecs.push(["mp4a", oti, aot].join('.')); } @@ -63,12 +94,16 @@ class MP4Tree { get_codec_string() { return this.codecs.join(', '); } - + + // find the given box, also descend into its child box depth + // offset is almost always 8, sometimes 12 or 16, defined in spec not file parse_into(name, header_offset) { this.parse_until(name); this.idx += header_offset; } + // find the box with the given name at the current box depth + // acts as though the current box extends to EOF. parse_until(name) { let curr_head = null; while (this.idx + 8 < this.data.byteLength) { @@ -82,6 +117,7 @@ class MP4Tree { return false; } + // read the mp4 box header as if the idx is positioned there read_next_head() { return {"len": this.data.getUint32(this.idx), "name": this.data.getString(this.idx + 4, 4)}; }