2020/lib/mse/msutil.js (629 lines of code) (raw):

/** * @license * Copyright 2018 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; (function() { var DLOG_LEVEL = 3; // Log a debug message. Only logs if the given level is less than the current // value of the global variable DLOG_LEVEL. window.dlog = function(level) { if (typeof(level) !== 'number') throw 'level has to be an non-negative integer!'; // Comment this to prevent debug output if (arguments.length > 1 && level <= DLOG_LEVEL) { var args = []; for (var i = 1; i < arguments.length; ++i) args.push(arguments[i]); if (window.LOG) window.LOG.apply(null, args); else console.log(args); } }; var ensureUID = (function() { var uid = 0; return function(sb) { if (!sb.uid) sb.uid = uid++; }; })(); var elementInBody = function(element) { while (element && element !== document.body) element = element.parentNode; return Boolean(element); }; window.createMimeTypeStr = function( mimeType, codecs, width, height, framerate, spherical, suffix) { var mimeTypeStr = mimeType; if (!!(codecs)) mimeTypeStr += '; codecs="' + codecs + '"'; if (!!(width)) mimeTypeStr += '; width=' + width; if (!!(height)) mimeTypeStr += '; height=' + height; if (!!(framerate)) mimeTypeStr += '; framerate=' + framerate; if (!!(spherical)) mimeTypeStr += '; decode-to-texture=' + spherical; if (!!(suffix)) mimeTypeStr += '; ' + suffix; return mimeTypeStr; }; window.isTypeSupported = function(stream) { return MediaSource.isTypeSupported(createMimeTypeStr( stream.mimetype, null, stream.get("width"), stream.get("height"), stream.get("fps"), stream.get("spherical"))); }; // A version of 'SourceBuffer.append()' that automatically handles EOS // (indicated by the 'null' values. Returns true if append succeeded, // false if EOS. window.safeAppend = function(sb, buf) { ensureUID(sb); if (!buf) dlog(2, 'EOS appended to ' + sb.uid); else sb.appendBuffer(buf); return Boolean(buf); }; // Convert a 4-byte array into a signed 32-bit int. window.btoi = function(data, offset) { offset = offset || 0; var result = data[offset] >>> 0; result = (result << 8) + (data[offset + 1] >>> 0); result = (result << 8) + (data[offset + 2] >>> 0); result = (result << 8) + (data[offset + 3] >>> 0); return result; } // Convert a 4-byte array into a fourcc. window.btofourcc = function(data, offset) { offset = offset || 0; return String.fromCharCode(data[offset], data[offset + 1], data[offset + 2], data[offset + 3]); } // Convert a signed 32-bit int into a 4-byte array. window.itob = function(value) { return [value >>> 24, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; } // Given a BufferedRange object, find the one that contains the given time // 't'. Returns the end time of the buffered range. If a suitable buffered // range is not found, returns 'null'. function findBufferedRangeEndForTime(sb, t) { var buf = sb.buffered; ensureUID(sb); for (var i = 0; i < buf.length; ++i) { var s = buf.start(i), e = buf.end(i); dlog(4, 'findBuf: uid=' + sb.uid + ' index=' + i + ' time=' + t + ' start=' + s + ' end=' + e); if (t >= s && t <= e) return e; } return null; } // Removes segments from all 'buffers' to satisfy 'duration'. Once complete // 'ms.duration' is set and 'cb' is called. window.setDuration = function(duration, ms, buffers, cb) { buffers = buffers instanceof Array ? buffers : [buffers]; if (buffers.length == 0) { ms.duration = duration; cb(); return; } var buffer = buffers.pop(); for (var rangeIdx = 0; rangeIdx < buffer.buffered.length; rangeIdx++) { var bufferedEnd = buffer.buffered.end(rangeIdx); if (bufferedEnd > duration) { var buf = buffer; buffer.addEventListener('update', function onDurationChange() { buf.removeEventListener('update', onDurationChange); setDuration(duration, ms, buffers, cb); }); buffer.remove(duration, bufferedEnd); return; } } setDuration(duration, ms, buffers, cb); } // This part defines the... source, for the, erm... media. But it's not the // Media Source. No. No way. // // Let's call it "source chain" instead. // // At the end of a source chain is a file source. File sources implement the // following methods: // // init(t, cb): Gets the (cached) initialization segment buffer for t. // Current position is not affected. If cb is null, it will return the init // segment, otherwise it will call cb with the asynchronously received init // segment. If will throw is init segment is not ready and cb is null. // // seek(t): Sets the maximum time of the next segment to be appended. Will // likely round down to the nearest segment start time. (To reset a source // after EOF, seek to 0.) // // pull(cb): Call the cb with the next media segment. // return value of EOS('null') indicates that the chain has been exhausted. // // Most source chain elements will return entire media segments, and many will // expect incoming data to begin on a media segment boundary. Those elements // that either do not require this property, or return output that doesn't // follow it, will be noted. // // All source chain elements will forward messages that are not handled to the // upstream element until they reach the file source. // Produces a FileSource table. window.FileSource = function( path, xhrManager, timeoutManager, startIndex, endIndex, forceSize) { this.path = path; this.startIndex = startIndex; this.endIndex = endIndex; this.forceSize = forceSize; this.segs = null; this.segIndex = 0; this.initBuf = null; this.init = function(t, cb) { this.initLength = forceSize || 32 * 1024; if (!cb) { if (!this.initBuf) throw 'Calling init synchronusly when the init seg is not ready'; return this.initBuf; } if (this.initBuf) { timeoutManager.setTimeout(cb.bind(this, this.initBuf), 1); } else { var self = this; var fileExtPattern = /\.(webm|mp4)$/; var extResult = fileExtPattern.exec(this.path)[1]; if (extResult !== 'mp4' && extResult !== 'webm') { throw 'File extension "' + extResult + '" not supported!'; } var xhr = xhrManager.createRequest(this.path, function(e) { self.segs = null; var response = this.getResponseData(); if (extResult === 'mp4') { self.segs = parseMp4(response); } else { if (!!self.forceSize) { self.segs = parseWebM(response.buffer, self.forceSize); } else { self.segs = parseWebM(response.buffer); } } self.startIndex = self.startIndex || 0; self.endIndex = self.endIndex || self.segs.length - 1; self.endIndex = Math.min(self.endIndex, self.segs.length - 1); self.startIndex = Math.min(self.startIndex, self.endIndex); self.segIndex = self.startIndex; xhr = xhrManager.createRequest(self.path, function(e) { self.initBuf = this.getResponseData(); cb.call(self, self.initBuf); }, 0, self.segs[0].offset); xhr.send(); }, 0, self.initLength); xhr.send(); } }; this.seek = function(t, sb) { if (!this.initBuf) throw 'Seek must be called after init'; if (sb) sb.abort(); else if (t !== 0) throw 'You can only seek to the beginning without providing a sb'; t += this.segs[this.startIndex].time; var i = this.startIndex; while (i <= this.endIndex && this.segs[i].time <= t) ++i; this.segIndex = i - 1; dlog(2, 'Seeking to segment index=' + this.segIndex + ' time=' + t + ' start=' + this.segs[this.segIndex].time + ' length=' + this.segs[this.segIndex].duration); }; this.pull = function(cb) { if (this.segIndex > this.endIndex) { timeoutManager.setTimeout(cb.bind(this, null), 1); return; } var seg = this.segs[this.segIndex]; ++this.segIndex; var self = this; var xhr = xhrManager.createRequest(this.path, function(e) { cb.call(self, this.getResponseData()); }, seg.offset, seg.size); xhr.send(); }; this.duration = function() { var last = this.segs[this.segs.length - 1]; return last.time + last.duration; }; this.currSegDuration = function() { if (!this.segs || !this.segs[this.segIndex]) return 0; return this.segs[this.segIndex].duration; }; }; function attachChain(downstream, upstream) { downstream.upstream = upstream; downstream.init = function(t, cb) { return upstream.init(t, cb); }; downstream.seek = function(t, sb) { return upstream.seek(t, sb); }; downstream.pull = function(cb) { return upstream.pull(cb); }; downstream.duration = function() { return upstream.duration(); }; downstream.currSegDuration = function() { return upstream.currSegDuration(); }; } window.ResetInit = function(upstream) { this.init_sent = false; attachChain(this, upstream); this.init = function(t, cb) { this.init_sent = true; return this.upstream.init(t, cb); }; this.seek = function(t, sb) { this.init_sent = false; return this.upstream.seek(t, sb); }; this.pull = function(cb) { if (!this.init_sent) { this.init_sent = true; this.upstream.init(0, function(init_seg) { cb(init_seg); }); return; } var self = this; this.upstream.pull(function(rsp) { if (!rsp) self.init_sent = false; cb(rsp); }); }; }; // This function _blindly_ parses the mdhd header in the segment to find the // timescale. It doesn't take any box hierarchy into account. function parseTimeScale(data) { for (var i = 0; i < data.length - 3; ++i) { if (btofourcc(data, i) !== 'mdhd') continue; var off = i + 16; if (data[i + 4] != 0) off = i + 28; return btoi(data, off); } throw 'Failed to find mdhd box in the segment provided'; } function replaceTFDT(data, tfdt) { for (var i = 0; i < data.length - 3; ++i) { if (btofourcc(data, i) !== 'tfdt') continue; tfdt = itob(tfdt); // convert it into array var off = i + 8; if (data[i + 4] === 0) { data[off] = tfdt[0]; data[off + 1] = tfdt[1]; data[off + 2] = tfdt[2]; data[off + 3] = tfdt[3]; } else { data[off] = 0; data[off + 1] = 0; data[off + 2] = 0; data[off + 3] = 0; data[off + 4] = tfdt[0]; data[off + 5] = tfdt[1]; data[off + 6] = tfdt[2]; data[off + 7] = tfdt[3]; } return true; } // the init segment doesn't have tfdt box. return false; } // It will repeat a normal stream to turn it into an infinite stream. // This type of stream cannot be seeked. window.InfiniteStream = function(upstream) { this.upstream = upstream; this.timescale = null; this.elapsed = 0; attachChain(this, upstream); this.seek = function(t, sb) { throw 'InfiniteStream cannot be seeked'; }; this.pull = function(cb) { var self = this; var currSegDuration = self.upstream.currSegDuration(); function onPull(buf) { if (!buf) { self.upstream.seek(0, null); self.upstream.pull(onPull); return; } if (!self.timescale) { var initBuf = self.upstream.init(0); self.timescale = parseTimeScale(initBuf); } var tfdt = Math.floor(self.timescale * self.elapsed); if (tfdt === 1) tfdt = 0; dlog(3, 'TFDT: time=' + self.elapsed + ' timescale=' + self.timescale + ' tfdt=' + tfdt); if (replaceTFDT(buf, tfdt)) self.elapsed = self.elapsed + currSegDuration; cb(buf); } this.upstream.pull(onPull); }; return this; }; // Pull 'len' bytes from upstream chain element 'elem'. 'cache' // is a temporary buffer of bytes left over from the last pull. // // This function will send exactly 0 or 1 pull messages upstream. If 'len' is // greater than the number of bytes in the combined values of 'cache' and the // pulled buffer, it will be capped to the available bytes. This avoids a // number of nasty edge cases. // // Returns 'rsp, new_cache'. 'new_cache' should be passed as 'cache' to the // next invocation. function pullBytes(elem, len, cache, cb) { if (!cache) { // always return EOS if cache is EOS, the caller should call seek before // reusing the source chain. cb(cache, null); return; } if (len <= cache.length) { var buf = cache.subarray(0, len); cache = cache.subarray(len); cb(buf, cache); return; } elem.pull(function(buf) { if (!buf) { // EOS cb(cache, buf); return; } var new_cache = new Uint8Array(cache.length + buf.length); new_cache.set(cache); new_cache.set(buf, cache.length); cache = new_cache; if (cache.length <= len) { cb(cache, new Uint8Array(0)); } else { buf = cache.subarray(0, len); cache = cache.subarray(len); cb(buf, cache); } }); } window.FixedAppendSize = function(upstream, size) { this.cache = new Uint8Array(0); attachChain(this, upstream); this.appendSize = function() { return size || 512 * 1024; }; this.seek = function(t, sb) { this.cache = new Uint8Array(0); return this.upstream.seek(t, sb); }; this.pull = function(cb) { var len = this.appendSize(); var self = this; pullBytes(this.upstream, len, this.cache, function(buf, cache) { self.cache = cache; cb(buf); }); }; }; window.RandomAppendSize = function(upstream, min, max) { FixedAppendSize.apply(this, arguments); this.appendSize = function() { min = min || 100; max = max || 10000; return Math.floor(Math.random() * (max - min + 1) + min); }; }; window.RandomAppendSize.prototype = new window.FixedAppendSize; window.RandomAppendSize.prototype.constructor = window.RandomAppendSize; // This function appends the init segment to media source window.appendInit = function(mp, sb, chain, t, cb) { chain.init(t, function(init_seg) { sb.addEventListener('update', function appendedCb() { sb.removeEventListener('update', appendedCb); cb(); }); sb.appendBuffer(init_seg); }); }; // This is a simple append loop. It pulls data from 'chain' and appends it to // 'sb' until the end of the buffered range contains time 't'. // It starts from the current playback location. window.appendUntil = function(timeoutManager, mp, sb, chain, t, cb) { if (!elementInBody(mp)) { cb(); return; } var started = sb.buffered.length !== 0; var current = mp.currentTime; var buffered_end = findBufferedRangeEndForTime(sb, current); if (buffered_end) { buffered_end = buffered_end + 0.1; } else { buffered_end = 0; if (started) { chain.seek(0, sb); } } var appendHandler = (function(sb) { var totalAppends = 0; var appendCbs = 0; var shouldCallCb = false; var postAppendBufferCb = null; function startedAppendBuffer(cb) { totalAppends++; postAppendBufferCb = cb; } function appendBufferFinished() { appendCbs++; buffered_end = findBufferedRangeEndForTime(sb, buffered_end); if (buffered_end) { buffered_end = buffered_end + 0.1; } else { buffered_end = 0; } if (shouldCallCb && (appendCbs === totalAppends)) { done(); } else { postAppendBufferCb(); } } function done() { // calls the actual callback (cb) function when all the appends are done if (totalAppends === appendCbs) { sb.removeEventListener('update', appendBufferFinished); cb(); } // Looks like there are outstanding append cbs; let those "append cbs" // call the real cb. shouldCallCb = true; } sb.addEventListener('update', appendBufferFinished); return { 'startedAppendBuffer': startedAppendBuffer, 'appendBufferFinished': appendBufferFinished, 'done': done }; })(sb); (function loop(buffer) { if (!elementInBody(mp)) { appendHandler.done(); return; } if (buffer) { if (!safeAppend(sb, buffer)) { appendHandler.done(); return; } appendHandler.startedAppendBuffer(loop); //timeoutManager.setTimeout(loop, 0); } else { if (t >= buffered_end && !mp.error) chain.pull(loop); else appendHandler.done(); } })(); }; // This is a simple append loop. It pulls data from 'chain' and appends it to // 'sb' until the end of the buffered range that contains time 't' is at // least 'gap' seconds beyond 't'. If 't' is not currently in a buffered // range, it first seeks to a time before 't' and appends until 't' is // covered. window.appendAt = function(timeoutManager, mp, sb, chain, t, gap, cb) { if (!elementInBody(mp)) { cb(); return; } gap = gap || 3; var buffered_end = findBufferedRangeEndForTime(sb, t); (function loop(buffer) { if (!elementInBody(mp)) { cb(); return; } if (buffer) { if (sb.updating) { timeoutManager.setTimeout(function() { loop(buffer); }, 0); } else { if (!safeAppend(sb, buffer)) return; timeoutManager.setTimeout(loop, 0); } } else { buffered_end = findBufferedRangeEndForTime(sb, t); if (t + gap >= (buffered_end || 0) && !mp.error) { chain.pull(loop); } else { cb(); } } })(); }; // Append data from chains 'f1' and 'f2' to source buffers 's1' and 's2', // maintaining 'lead' seconds of time between current playback time and end of // current buffered range. Continue to do this until the current playback time // reaches 'endTime'. // It supports play one stream, where 's2' and 'f2' are null. // // 'lead' may be small or negative, which usually triggers some interesting // fireworks with regard to the network buffer level state machine. // // TODO: catch transition to HAVE_CURRENT_DATA or lower and append enough to // resume in that case window.playThrough = function(timeoutManager, mp, lead, endTime, s1, f1, s2, f2, cb) { var yieldTime = 0.03; function loop() { if (!elementInBody(mp)) return; if (mp.currentTime <= endTime && !mp.error) timeoutManager.setTimeout( playThrough.bind( null, timeoutManager, mp, lead, endTime, s1, f1, s2, f2, cb), yieldTime * 1000); else cb(); }; appendAt(timeoutManager, mp, s1, f1, mp.currentTime, yieldTime + lead, function() { if (s2) appendAt(timeoutManager, mp, s2, f2, mp.currentTime, yieldTime + lead, loop); else loop(); }); }; window.waitUntil = function(timeouts, media, target, cb) { var initTime = media.currentTime; var lastTime = lastTime; var check = function() { if (media.currentTime === initTime) { timeouts.setTimeout(check, 500); } else if (media.currentTime === lastTime || media.currentTime > target) { cb(); } else { lastTime = media.currentTime; timeouts.setTimeout(check, 500); } }; timeouts.setTimeout(check, 500); }; window.callAfterLoadedMetaData = function(media, testFunc) { var onLoadedMetadata = function() { LOG('onLoadedMetadata called'); media.removeEventListener('loadedmetadata', onLoadedMetadata); testFunc(); }; if (media.readyState >= media.HAVE_METADATA) { LOG('onLoadedMetadata bypassed'); testFunc(); } else { media.addEventListener('loadedmetadata', onLoadedMetadata); } }; window.setupMse = function(video, runner, videoStreams, audioStreams, maxSegments) { videoStreams = videoStreams instanceof Array ? videoStreams : [videoStreams]; audioStreams = audioStreams instanceof Array ? audioStreams : [audioStreams]; var ms = new MediaSource(); var videoSbs = []; var audioSbs = []; function onError(e) { switch (e.target.error.code) { case e.target.error.MEDIA_ERR_ABORTED: runner.fail('Test failure: You aborted the video playback.'); break; case e.target.error.MEDIA_ERR_NETWORK: runner.fail('Test failure: A network error caused the video' + ' download to fail part-way.'); break; case e.target.error.MEDIA_ERR_DECODE: runner.fail('Test failure: The video playback was aborted due to' + ' a corruption problem or because the video used' + ' features your browser did not support.'); break; case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: runner.fail('Test failure: The video could not be loaded, either' + ' because the server or network failed or because the' + ' format is not supported.'); break; default: runner.fail('Test failure: An unknown error occurred.'); break; } } function fetchStream(stream, cb, start, end) { var xhr = runner.XHRManager.createRequest(stream.src, cb, start, end); xhr.send(); } function appendLoop(stream, sb) { var parsedData; var segmentIdx = 0; if (!maxSegments) { maxSegments = 4; } fetchStream(stream, function() { if (['H264', 'AV1', 'AAC'].includes(stream.codec)) { parsedData = parseMp4(this.getResponseData()); } else if (['VP9', 'Opus'].includes(stream.codec)) { parsedData = parseWebM(this.getResponseData().buffer); } else { runner.fail('Unsupported codec in appendLoop.'); } fetchStream(stream, function() { sb.addEventListener('updateend', function append() { if (maxSegments - segmentIdx <= 0) { sb.removeEventListener('updateend', append); return; } fetchStream(stream, function() { sb.appendBuffer(this.getResponseData()); segmentIdx += 1; }, parsedData[segmentIdx].offset, parsedData[segmentIdx].size); }); sb.appendBuffer(this.getResponseData()); segmentIdx += 1; }, 0, parsedData[0].size + parsedData[0].offset); }, 0, 32 * 1024); } function onSourceOpen(e) { for (var audioStreamIdx in audioStreams) { var audioStream = audioStreams[audioStreamIdx]; if (audioStream != null) { audioSbs.push(ms.addSourceBuffer(audioStream.mimetype)); appendLoop(audioStream, audioSbs[audioSbs.length - 1]); } } for (var videoStreamIdx in videoStreams) { var videoStream = videoStreams[videoStreamIdx]; if (videoStream != null) { videoSbs.push(ms.addSourceBuffer(videoStream.mimetype)); appendLoop(videoStream, videoSbs[videoSbs.length - 1]); } } } ms.addEventListener('sourceopen', onSourceOpen); video.addEventListener('error', onError); video.src = window.URL.createObjectURL(ms); video.load(); }; })();