613 lines
21 KiB
HTML
613 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>JSDoc: Source: flash-source-buffer.js</title>
|
|
|
|
<script src="scripts/prettify/prettify.js"> </script>
|
|
<script src="scripts/prettify/lang-css.js"> </script>
|
|
<!--[if lt IE 9]>
|
|
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
|
<![endif]-->
|
|
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
|
|
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="main">
|
|
|
|
<h1 class="page-title">Source: flash-source-buffer.js</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
|
<article>
|
|
<pre class="prettyprint source linenums"><code>/**
|
|
* @file flash-source-buffer.js
|
|
*/
|
|
import window from 'global/window';
|
|
import videojs from 'video.js';
|
|
import flv from 'mux.js/lib/flv';
|
|
import removeCuesFromTrack from './remove-cues-from-track';
|
|
import createTextTracksIfNecessary from './create-text-tracks-if-necessary';
|
|
import {addTextTrackData} from './add-text-track-data';
|
|
import transmuxWorker from './flash-transmuxer-worker';
|
|
import work from 'webworkify';
|
|
import FlashConstants from './flash-constants';
|
|
|
|
/**
|
|
* A wrapper around the setTimeout function that uses
|
|
* the flash constant time between ticks value.
|
|
*
|
|
* @param {Function} func the function callback to run
|
|
* @private
|
|
*/
|
|
const scheduleTick = function(func) {
|
|
// Chrome doesn't invoke requestAnimationFrame callbacks
|
|
// in background tabs, so use setTimeout.
|
|
window.setTimeout(func, FlashConstants.TIME_BETWEEN_CHUNKS);
|
|
};
|
|
|
|
/**
|
|
* Generates a random string of max length 6
|
|
*
|
|
* @return {String} the randomly generated string
|
|
* @function generateRandomString
|
|
* @private
|
|
*/
|
|
const generateRandomString = function() {
|
|
return (Math.random().toString(36)).slice(2, 8);
|
|
};
|
|
|
|
/**
|
|
* Round a number to a specified number of places much like
|
|
* toFixed but return a number instead of a string representation.
|
|
*
|
|
* @param {Number} num A number
|
|
* @param {Number} places The number of decimal places which to
|
|
* round
|
|
* @private
|
|
*/
|
|
const toDecimalPlaces = function(num, places) {
|
|
if (typeof places !== 'number' || places < 0) {
|
|
places = 0;
|
|
}
|
|
|
|
let scale = Math.pow(10, places);
|
|
|
|
return Math.round(num * scale) / scale;
|
|
};
|
|
|
|
/**
|
|
* A SourceBuffer implementation for Flash rather than HTML.
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/API/MediaSource
|
|
* @param {Object} mediaSource the flash media source
|
|
* @class FlashSourceBuffer
|
|
* @extends videojs.EventTarget
|
|
*/
|
|
export default class FlashSourceBuffer extends videojs.EventTarget {
|
|
constructor(mediaSource) {
|
|
super();
|
|
let encodedHeader;
|
|
|
|
// Start off using the globally defined value but refine
|
|
// as we append data into flash
|
|
this.chunkSize_ = FlashConstants.BYTES_PER_CHUNK;
|
|
|
|
// byte arrays queued to be appended
|
|
this.buffer_ = [];
|
|
|
|
// the total number of queued bytes
|
|
this.bufferSize_ = 0;
|
|
|
|
// to be able to determine the correct position to seek to, we
|
|
// need to retain information about the mapping between the
|
|
// media timeline and PTS values
|
|
this.basePtsOffset_ = NaN;
|
|
|
|
this.mediaSource_ = mediaSource;
|
|
|
|
this.audioBufferEnd_ = NaN;
|
|
this.videoBufferEnd_ = NaN;
|
|
|
|
// indicates whether the asynchronous continuation of an operation
|
|
// is still being processed
|
|
// see https://w3c.github.io/media-source/#widl-SourceBuffer-updating
|
|
this.updating = false;
|
|
this.timestampOffset_ = 0;
|
|
|
|
encodedHeader = window.btoa(
|
|
String.fromCharCode.apply(
|
|
null,
|
|
Array.prototype.slice.call(
|
|
flv.getFlvHeader()
|
|
)
|
|
)
|
|
);
|
|
|
|
// create function names with added randomness for the global callbacks flash will use
|
|
// to get data from javascript into the swf. Random strings are added as a safety
|
|
// measure for pages with multiple players since these functions will be global
|
|
// instead of per instance. When making a call to the swf, the browser generates a
|
|
// try catch code snippet, but just takes the function name and writes out an unquoted
|
|
// call to that function. If the player id has any special characters, this will result
|
|
// in an error, so safePlayerId replaces all special characters to '_'
|
|
const safePlayerId = this.mediaSource_.player_.id().replace(/[^a-zA-Z0-9]/g, '_');
|
|
|
|
this.flashEncodedHeaderName_ = 'vjs_flashEncodedHeader_' +
|
|
safePlayerId +
|
|
generateRandomString();
|
|
this.flashEncodedDataName_ = 'vjs_flashEncodedData_' +
|
|
safePlayerId +
|
|
generateRandomString();
|
|
|
|
window[this.flashEncodedHeaderName_] = () => {
|
|
delete window[this.flashEncodedHeaderName_];
|
|
return encodedHeader;
|
|
};
|
|
|
|
this.mediaSource_.swfObj.vjs_appendChunkReady(this.flashEncodedHeaderName_);
|
|
|
|
this.transmuxer_ = work(transmuxWorker);
|
|
this.transmuxer_.postMessage({ action: 'init', options: {} });
|
|
this.transmuxer_.onmessage = (event) => {
|
|
if (event.data.action === 'data') {
|
|
this.receiveBuffer_(event.data.segment);
|
|
}
|
|
};
|
|
|
|
this.one('updateend', () => {
|
|
this.mediaSource_.tech_.trigger('loadedmetadata');
|
|
});
|
|
|
|
Object.defineProperty(this, 'timestampOffset', {
|
|
get() {
|
|
return this.timestampOffset_;
|
|
},
|
|
set(val) {
|
|
if (typeof val === 'number' && val >= 0) {
|
|
this.timestampOffset_ = val;
|
|
// We have to tell flash to expect a discontinuity
|
|
this.mediaSource_.swfObj.vjs_discontinuity();
|
|
// the media <-> PTS mapping must be re-established after
|
|
// the discontinuity
|
|
this.basePtsOffset_ = NaN;
|
|
this.audioBufferEnd_ = NaN;
|
|
this.videoBufferEnd_ = NaN;
|
|
|
|
this.transmuxer_.postMessage({ action: 'reset' });
|
|
}
|
|
}
|
|
});
|
|
|
|
Object.defineProperty(this, 'buffered', {
|
|
get() {
|
|
if (!this.mediaSource_ ||
|
|
!this.mediaSource_.swfObj ||
|
|
!('vjs_getProperty' in this.mediaSource_.swfObj)) {
|
|
return videojs.createTimeRange();
|
|
}
|
|
|
|
let buffered = this.mediaSource_.swfObj.vjs_getProperty('buffered');
|
|
|
|
if (buffered && buffered.length) {
|
|
buffered[0][0] = toDecimalPlaces(buffered[0][0], 3);
|
|
buffered[0][1] = toDecimalPlaces(buffered[0][1], 3);
|
|
}
|
|
return videojs.createTimeRanges(buffered);
|
|
}
|
|
});
|
|
|
|
// On a seek we remove all text track data since flash has no concept
|
|
// of a buffered-range and everything else is reset on seek
|
|
this.mediaSource_.player_.on('seeked', () => {
|
|
removeCuesFromTrack(0, Infinity, this.metadataTrack_);
|
|
if (this.inbandTextTracks_) {
|
|
for (let track in this.inbandTextTracks_) {
|
|
removeCuesFromTrack(0, Infinity, this.inbandTextTracks_[track]);
|
|
}
|
|
}
|
|
});
|
|
|
|
let onHlsReset = this.onHlsReset_.bind(this);
|
|
|
|
// hls-reset is fired by videojs.Hls on to the tech after the main SegmentLoader
|
|
// resets its state and flushes the buffer
|
|
this.mediaSource_.player_.tech_.on('hls-reset', onHlsReset);
|
|
|
|
this.mediaSource_.player_.tech_.hls.on('dispose', () => {
|
|
this.transmuxer_.terminate();
|
|
this.mediaSource_.player_.tech_.off('hls-reset', onHlsReset);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append bytes to the sourcebuffers buffer, in this case we
|
|
* have to append it to swf object.
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/appendBuffer
|
|
* @param {Array} bytes
|
|
*/
|
|
appendBuffer(bytes) {
|
|
let error;
|
|
|
|
if (this.updating) {
|
|
error = new Error('SourceBuffer.append() cannot be called ' +
|
|
'while an update is in progress');
|
|
error.name = 'InvalidStateError';
|
|
error.code = 11;
|
|
throw error;
|
|
}
|
|
this.updating = true;
|
|
this.mediaSource_.readyState = 'open';
|
|
this.trigger({ type: 'update' });
|
|
|
|
this.transmuxer_.postMessage({
|
|
action: 'push',
|
|
data: bytes.buffer,
|
|
byteOffset: bytes.byteOffset,
|
|
byteLength: bytes.byteLength
|
|
}, [bytes.buffer]);
|
|
this.transmuxer_.postMessage({action: 'flush'});
|
|
}
|
|
|
|
/**
|
|
* Reset the parser and remove any data queued to be sent to the SWF.
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/abort
|
|
*/
|
|
abort() {
|
|
this.buffer_ = [];
|
|
this.bufferSize_ = 0;
|
|
this.mediaSource_.swfObj.vjs_abort();
|
|
|
|
// report any outstanding updates have ended
|
|
if (this.updating) {
|
|
this.updating = false;
|
|
this.trigger({ type: 'updateend' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flash cannot remove ranges already buffered in the NetStream
|
|
* but seeking clears the buffer entirely. For most purposes,
|
|
* having this operation act as a no-op is acceptable.
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/remove
|
|
* @param {Double} start start of the section to remove
|
|
* @param {Double} end end of the section to remove
|
|
*/
|
|
remove(start, end) {
|
|
removeCuesFromTrack(start, end, this.metadataTrack_);
|
|
if (this.inbandTextTracks_) {
|
|
for (let track in this.inbandTextTracks_) {
|
|
removeCuesFromTrack(start, end, this.inbandTextTracks_[track]);
|
|
}
|
|
}
|
|
this.trigger({ type: 'update' });
|
|
this.trigger({ type: 'updateend' });
|
|
}
|
|
|
|
/**
|
|
* Receive a buffer from the flv.
|
|
*
|
|
* @param {Object} segment
|
|
* @private
|
|
*/
|
|
receiveBuffer_(segment) {
|
|
// create an in-band caption track if one is present in the segment
|
|
createTextTracksIfNecessary(this, this.mediaSource_, segment);
|
|
addTextTrackData(this, segment.captions, segment.metadata);
|
|
|
|
// Do this asynchronously since convertTagsToData_ can be time consuming
|
|
scheduleTick(() => {
|
|
let flvBytes = this.convertTagsToData_(segment);
|
|
|
|
if (this.buffer_.length === 0) {
|
|
scheduleTick(this.processBuffer_.bind(this));
|
|
}
|
|
|
|
if (flvBytes) {
|
|
this.buffer_.push(flvBytes);
|
|
this.bufferSize_ += flvBytes.byteLength;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append a portion of the current buffer to the SWF.
|
|
*
|
|
* @private
|
|
*/
|
|
processBuffer_() {
|
|
let chunkSize = FlashConstants.BYTES_PER_CHUNK;
|
|
|
|
if (!this.buffer_.length) {
|
|
if (this.updating !== false) {
|
|
this.updating = false;
|
|
this.trigger({ type: 'updateend' });
|
|
}
|
|
// do nothing if the buffer is empty
|
|
return;
|
|
}
|
|
|
|
// concatenate appends up to the max append size
|
|
let chunk = this.buffer_[0].subarray(0, chunkSize);
|
|
|
|
// requeue any bytes that won't make it this round
|
|
if (chunk.byteLength < chunkSize ||
|
|
this.buffer_[0].byteLength === chunkSize) {
|
|
this.buffer_.shift();
|
|
} else {
|
|
this.buffer_[0] = this.buffer_[0].subarray(chunkSize);
|
|
}
|
|
|
|
this.bufferSize_ -= chunk.byteLength;
|
|
|
|
// base64 encode the bytes
|
|
let binary = [];
|
|
let length = chunk.byteLength;
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
binary.push(String.fromCharCode(chunk[i]));
|
|
}
|
|
let b64str = window.btoa(binary.join(''));
|
|
|
|
window[this.flashEncodedDataName_] = () => {
|
|
// schedule another processBuffer to process any left over data or to
|
|
// trigger updateend
|
|
scheduleTick(this.processBuffer_.bind(this));
|
|
delete window[this.flashEncodedDataName_];
|
|
return b64str;
|
|
};
|
|
|
|
// Notify the swf that segment data is ready to be appended
|
|
this.mediaSource_.swfObj.vjs_appendChunkReady(this.flashEncodedDataName_);
|
|
}
|
|
|
|
/**
|
|
* Turns an array of flv tags into a Uint8Array representing the
|
|
* flv data. Also removes any tags that are before the current
|
|
* time so that playback begins at or slightly after the right
|
|
* place on a seek
|
|
*
|
|
* @private
|
|
* @param {Object} segmentData object of segment data
|
|
*/
|
|
convertTagsToData_(segmentData) {
|
|
let segmentByteLength = 0;
|
|
let tech = this.mediaSource_.tech_;
|
|
let videoTargetPts = 0;
|
|
let segment;
|
|
let videoTags = segmentData.tags.videoTags;
|
|
let audioTags = segmentData.tags.audioTags;
|
|
|
|
// Establish the media timeline to PTS translation if we don't
|
|
// have one already
|
|
if (isNaN(this.basePtsOffset_) && (videoTags.length || audioTags.length)) {
|
|
// We know there is at least one video or audio tag, but since we may not have both,
|
|
// we use pts: Infinity for the missing tag. The will force the following Math.min
|
|
// call will to use the proper pts value since it will always be less than Infinity
|
|
const firstVideoTag = videoTags[0] || { pts: Infinity };
|
|
const firstAudioTag = audioTags[0] || { pts: Infinity };
|
|
|
|
this.basePtsOffset_ = Math.min(firstAudioTag.pts, firstVideoTag.pts);
|
|
}
|
|
|
|
if (tech.seeking()) {
|
|
// Do not use previously saved buffer end values while seeking since buffer
|
|
// is cleared on all seeks
|
|
this.videoBufferEnd_ = NaN;
|
|
this.audioBufferEnd_ = NaN;
|
|
}
|
|
|
|
if (isNaN(this.videoBufferEnd_)) {
|
|
if (tech.buffered().length) {
|
|
videoTargetPts = tech.buffered().end(0) - this.timestampOffset;
|
|
}
|
|
|
|
// Trim to currentTime if seeking
|
|
if (tech.seeking()) {
|
|
videoTargetPts = Math.max(videoTargetPts, tech.currentTime() - this.timestampOffset);
|
|
}
|
|
|
|
// PTS values are represented in milliseconds
|
|
videoTargetPts *= 1e3;
|
|
videoTargetPts += this.basePtsOffset_;
|
|
} else {
|
|
// Add a fudge factor of 0.1 to the last video pts appended since a rendition change
|
|
// could append an overlapping segment, in which case there is a high likelyhood
|
|
// a tag could have a matching pts to videoBufferEnd_, which would cause
|
|
// that tag to get appended by the tag.pts >= targetPts check below even though it
|
|
// is a duplicate of what was previously appended
|
|
videoTargetPts = this.videoBufferEnd_ + 0.1;
|
|
}
|
|
|
|
// filter complete GOPs with a presentation time less than the seek target/end of buffer
|
|
let currentIndex = videoTags.length;
|
|
|
|
// if the last tag is beyond videoTargetPts, then do not search the list for a GOP
|
|
// since our videoTargetPts lies in a future segment
|
|
if (currentIndex && videoTags[currentIndex - 1].pts >= videoTargetPts) {
|
|
// Start by walking backwards from the end of the list until we reach a tag that
|
|
// is equal to or less than videoTargetPts
|
|
while (--currentIndex) {
|
|
const currentTag = videoTags[currentIndex];
|
|
|
|
if (currentTag.pts > videoTargetPts) {
|
|
continue;
|
|
}
|
|
|
|
// if we see a keyFrame or metadata tag once we've gone below videoTargetPts,
|
|
// exit the loop as this is the start of the GOP that we want to append
|
|
if (currentTag.keyFrame || currentTag.metaDataTag) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// We need to check if there are any metadata tags that come before currentIndex
|
|
// as those will be metadata tags associated with the GOP we are appending
|
|
// There could be 0 to 2 metadata tags that come before the currentIndex depending
|
|
// on what videoTargetPts is and whether the transmuxer prepended metadata tags to this
|
|
// key frame
|
|
while (currentIndex) {
|
|
const nextTag = videoTags[currentIndex - 1];
|
|
|
|
if (!nextTag.metaDataTag) {
|
|
break;
|
|
}
|
|
|
|
currentIndex--;
|
|
}
|
|
}
|
|
|
|
const filteredVideoTags = videoTags.slice(currentIndex);
|
|
|
|
let audioTargetPts;
|
|
|
|
if (isNaN(this.audioBufferEnd_)) {
|
|
audioTargetPts = videoTargetPts;
|
|
} else {
|
|
// Add a fudge factor of 0.1 to the last video pts appended since a rendition change
|
|
// could append an overlapping segment, in which case there is a high likelyhood
|
|
// a tag could have a matching pts to videoBufferEnd_, which would cause
|
|
// that tag to get appended by the tag.pts >= targetPts check below even though it
|
|
// is a duplicate of what was previously appended
|
|
audioTargetPts = this.audioBufferEnd_ + 0.1;
|
|
}
|
|
|
|
if (filteredVideoTags.length) {
|
|
// If targetPts intersects a GOP and we appended the tags for the GOP that came
|
|
// before targetPts, we want to make sure to trim audio tags at the pts
|
|
// of the first video tag to avoid brief moments of silence
|
|
audioTargetPts = Math.min(audioTargetPts, filteredVideoTags[0].pts);
|
|
}
|
|
|
|
// skip tags with a presentation time less than the seek target/end of buffer
|
|
currentIndex = 0;
|
|
|
|
while (currentIndex < audioTags.length) {
|
|
if (audioTags[currentIndex].pts >= audioTargetPts) {
|
|
break;
|
|
}
|
|
|
|
currentIndex++;
|
|
}
|
|
|
|
const filteredAudioTags = audioTags.slice(currentIndex);
|
|
|
|
// update the audio and video buffer ends
|
|
if (filteredAudioTags.length) {
|
|
this.audioBufferEnd_ = filteredAudioTags[filteredAudioTags.length - 1].pts;
|
|
}
|
|
if (filteredVideoTags.length) {
|
|
this.videoBufferEnd_ = filteredVideoTags[filteredVideoTags.length - 1].pts;
|
|
}
|
|
|
|
let tags = this.getOrderedTags_(filteredVideoTags, filteredAudioTags);
|
|
|
|
if (tags.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// If we are appending data that comes before our target pts, we want to tell
|
|
// the swf to adjust its notion of current time to account for the extra tags
|
|
// we are appending to complete the GOP that intersects with targetPts
|
|
if (tags[0].pts < videoTargetPts && tech.seeking()) {
|
|
const fudgeFactor = 1 / 30;
|
|
const currentTime = tech.currentTime();
|
|
const diff = (videoTargetPts - tags[0].pts) / 1e3;
|
|
let adjustedTime = currentTime - diff;
|
|
|
|
if (adjustedTime < fudgeFactor) {
|
|
adjustedTime = 0;
|
|
}
|
|
|
|
try {
|
|
this.mediaSource_.swfObj.vjs_adjustCurrentTime(adjustedTime);
|
|
} catch (e) {
|
|
// no-op for backwards compatability of swf. If adjustCurrentTime fails,
|
|
// the swf may incorrectly report currentTime and buffered ranges
|
|
// but should not affect playback over than the time displayed on the
|
|
// progress bar is inaccurate
|
|
}
|
|
}
|
|
|
|
// concatenate the bytes into a single segment
|
|
for (let i = 0; i < tags.length; i++) {
|
|
segmentByteLength += tags[i].bytes.byteLength;
|
|
}
|
|
segment = new Uint8Array(segmentByteLength);
|
|
for (let i = 0, j = 0; i < tags.length; i++) {
|
|
segment.set(tags[i].bytes, j);
|
|
j += tags[i].bytes.byteLength;
|
|
}
|
|
|
|
return segment;
|
|
}
|
|
|
|
/**
|
|
* Assemble the FLV tags in decoder order.
|
|
*
|
|
* @private
|
|
* @param {Array} videoTags list of video tags
|
|
* @param {Array} audioTags list of audio tags
|
|
*/
|
|
getOrderedTags_(videoTags, audioTags) {
|
|
let tag;
|
|
let tags = [];
|
|
|
|
while (videoTags.length || audioTags.length) {
|
|
if (!videoTags.length) {
|
|
// only audio tags remain
|
|
tag = audioTags.shift();
|
|
} else if (!audioTags.length) {
|
|
// only video tags remain
|
|
tag = videoTags.shift();
|
|
} else if (audioTags[0].dts < videoTags[0].dts) {
|
|
// audio should be decoded next
|
|
tag = audioTags.shift();
|
|
} else {
|
|
// video should be decoded next
|
|
tag = videoTags.shift();
|
|
}
|
|
|
|
tags.push(tag);
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
onHlsReset_() {
|
|
this.transmuxer_.postMessage({action: 'resetCaptions'});
|
|
}
|
|
}
|
|
</code></pre>
|
|
</article>
|
|
</section>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<nav>
|
|
<h2><a href="index.html">Home</a></h2><h3>Classes</h3><ul><li><a href="FlashMediaSource.html">FlashMediaSource</a></li><li><a href="FlashSourceBuffer.html">FlashSourceBuffer</a></li><li><a href="HtmlMediaSource.html">HtmlMediaSource</a></li><li><a href="MessageHandlers.html">MessageHandlers</a></li><li><a href="VirtualSourceBuffer.html">VirtualSourceBuffer</a></li></ul><h3>Global</h3><ul><li><a href="global.html#abort">abort</a></li><li><a href="global.html#addSourceBuffer">addSourceBuffer</a></li><li><a href="global.html#appendBuffer">appendBuffer</a></li><li><a href="global.html#appendGopInfo_">appendGopInfo_</a></li><li><a href="global.html#endOfStream">endOfStream</a></li><li><a href="global.html#FlashTransmuxerWorker">FlashTransmuxerWorker</a></li><li><a href="global.html#get">get</a></li><li><a href="global.html#gopsSafeToAlignWith">gopsSafeToAlignWith</a></li><li><a href="global.html#MediaSource">MediaSource</a></li><li><a href="global.html#open">open</a></li><li><a href="global.html#remove">remove</a></li><li><a href="global.html#removeGopBuffer">removeGopBuffer</a></li><li><a href="global.html#set">set</a></li><li><a href="global.html#supportsNativeMediaSources">supportsNativeMediaSources</a></li><li><a href="global.html#TransmuxerWorker">TransmuxerWorker</a></li><li><a href="global.html#updateGopBuffer">updateGopBuffer</a></li><li><a href="global.html#URL">URL</a></li></ul>
|
|
</nav>
|
|
|
|
<br class="clear">
|
|
|
|
<footer>
|
|
Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 3.5.4</a> on Thu Nov 02 2017 12:03:25 GMT-0400 (EDT)
|
|
</footer>
|
|
|
|
<script> prettyPrint(); </script>
|
|
<script src="scripts/linenumber.js"> </script>
|
|
</body>
|
|
</html>
|