Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement AjaxChunks source. #205

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ The `options` argument supports the following properties:
- `disableGl` - whether to disable WebGL and always use the Canvas2D renderer. Default `false`.
- `preserveDrawingBuffer` – whether the WebGL context is created with `preserveDrawingBuffer` - necessary for "screenshots" via `canvas.toDataURL()`. Default `false`.
- `progressive` - whether to load data in chunks (static files only). When enabled, playback can begin before the whole source has been completely loaded. Default `true`.
- `throttled` - when using `progressive`, whether to defer loading chunks when they're not needed for playback yet. Default `true`.
- `chunks` - Like progressive, but works with separate binary chunks. This is good option in case of highload. In progressive mode you will have 2*N+2 requests because of CSP. In this mode - just N requests. Default `false`.
- `throttled` - when using `progressive` or `chunks`, whether to defer loading chunks when they're not needed for playback yet. Default `true`.
- `chunkSize` - when using `progressive`, the chunk size in bytes to load at a time. Default `1024*1024` (1mb).
- `chunkDigits` - when using `chunks`, the chunk digits count in url suffix. Default `2`. For instance, if url=/test.ts and chunkDigits=3, then actual chunk urls will look like /test.ts.000, /test.ts.001, etc.
- `decodeFirstFrame` - whether to decode and display the first frame of the video. Useful to set up the Canvas size and use the frame as the "poster" image. This has no effect when using `autoplay` or streaming sources. Default `true`.
- `maxAudioLag` – when streaming, the maximum enqueued audio length in seconds.
- `videoBufferSize` – when streaming, size in bytes for the video decode buffer. Default 512*1024 (512kb). You may have to increase this for very high bitrates.
Expand Down Expand Up @@ -221,6 +223,49 @@ In my tests, USB Webcams introduce about ~180ms of latency and there seems to be
To capture webcam input on Windows or macOS using ffmpeg, see the [ffmpeg Capture/Webcam Wiki](https://trac.ffmpeg.org/wiki/Capture/Webcam).


## Chunks vs Progressive

Progressive mode based on Range header. But it requires special server-side tuning related with CSP issues. For instance, possible nginx config may looks like this:

```
location / {
add_header Access-Control-Allow-Origin "$http_origin";
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
add_header Access-Control-Allow-Headers 'Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Expose-Headers "Content-Length";
if ($request_method = 'OPTIONS') {
return 204;
}
}
```

Also, for N-chunked video you will have 2*N+2 requests:
* The first HEAD request to get file size (read Content-Length header) + duplicate OPTIONS request (CSP related stuff)
* N GET requests with Range header + N duplicate OPTIONS requests (also CSP)

In case of GET/HEAD requests we can expect caching on CDN side. But not for OPTIONS.

I.e. this is not suitable solution for highload.

In contrast, chunks mode works with out-of-box configured server. Also, it produce only N requests. But also it requires additional files preparation. For instance, we can prepare file like this:

```
$ split --bytes=32k -d -a 4 test.ts test.ts.
```

and then use it with options:

```
options = {
chunks = true;
chunkDigits = 4;
throttled: true,
canvas: document.getElementById('video')
};
var player = new JSMpeg.Player('test.ts', options);
```

## JSMpeg Architecture and Internals

This library was built in a fairly modular fashion while keeping overhead at a minimum. Implementing new Demuxers, Decoders, Outputs (Renderers, Audio Devices) or Sources should be possible without changing any other parts. However, you would still need to subclass the `JSMpeg.Player` in order to use any new modules.
Expand Down
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ uglifyjs \
src/buffer.js \
src/ajax.js \
src/ajax-progressive.js \
src/ajax-chunks.js \
src/websocket.js \
src/ts.js \
src/decoder.js \
Expand Down
4 changes: 1 addition & 3 deletions jsmpeg.min.js

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions src/ajax-chunks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
JSMpeg.Source.AjaxChunks = (function(){ "use strict";

if (!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength,padString) {
targetLength = targetLength>>0; //floor if number or convert non-number to 0;
padString = String(padString || ' ');
if (this.length > targetLength) {
return String(this);
}
else {
targetLength = targetLength-this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0,targetLength) + String(this);
}
};
}

var AjaxChunksSource = function(url, options) {
this.url = url;
this.destination = null;
this.request = null;

this.completed = false;
this.established = false;
this.progress = 0;

this.chunkIdx = 0;
this.chunkDigits = options.chunkDigits || 2;

this.isLoading = false;
this.loadStartTime = 0;
this.throttled = options.throttled !== false;
this.aborted = false;
};

AjaxChunksSource.prototype.connect = function(destination) {
this.destination = destination;
};

AjaxChunksSource.prototype.start = function() {
this.loadNextChunk();
};

AjaxChunksSource.prototype.resume = function(secondsHeadroom) {
if (this.isLoading || !this.throttled) {
return;
}

// Guess the worst case loading time with lots of safety margin. This is
// somewhat arbitrary...
var worstCaseLoadingTime = this.loadTime * 8 + 2;
if (worstCaseLoadingTime > secondsHeadroom) {
this.loadNextChunk();
}
};

AjaxChunksSource.prototype.destroy = function() {
this.request.abort();
this.aborted = true;
};

AjaxChunksSource.prototype.loadNextChunk = function() {
if (this.aborted || this.completed) {
this.completed = true;
return;
}

this.isLoading = true;
this.loadStartTime = JSMpeg.Now();
this.request = new XMLHttpRequest();

this.request.onreadystatechange = function() {
if (
this.request.readyState === this.request.DONE &&
this.request.status === 200
) {
this.onChunkLoad(this.request.response);
}
else if (
this.request.readyState === this.request.DONE &&
this.request.status === 404
) {
// Regular case - just eof.
this.completed = true;
}
else if (this.request.readyState === this.request.DONE) {
// Retry?
if (this.loadFails++ < 3) {
this.loadNextChunk();
}
}
}.bind(this);

if (this.chunkIdx === 0) {
this.request.onprogress = this.onProgress.bind(this);
}

this.request.open('GET', this.url+'.'+this.chunkIdx.toString().padStart(this.chunkDigits,'0'));
this.request.responseType = "arraybuffer";
this.request.send();
};

AjaxChunksSource.prototype.onProgress = function(ev) {
this.progress = (ev.loaded / ev.total);
};

AjaxChunksSource.prototype.onChunkLoad = function(data) {
this.established = true;
this.progress = 1;
this.chunkIdx++;
if (this.chunkSize && data.byteLength < this.chunkSize)
this.completed = true;
this.chunkSize = data.byteLength || 1;
this.loadFails = 0;
this.isLoading = false;

if (this.destination) {
this.destination.write(data);
}

this.loadTime = JSMpeg.Now() - this.loadStartTime;
if (!this.throttled) {
this.loadNextChunk();
}
};

return AjaxChunksSource;

})();


4 changes: 4 additions & 0 deletions src/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ var Player = function(url, options) {
this.source = new JSMpeg.Source.WebSocket(url, options);
options.streaming = true;
}
else if (options.chunks) {
this.source = new JSMpeg.Source.AjaxChunks(url, options);
options.streaming = false;
}
else if (options.progressive !== false) {
this.source = new JSMpeg.Source.AjaxProgressive(url, options);
options.streaming = false;
Expand Down