Sheraff
Sheraff

Reputation: 6782

Consistent FPS in frame by frame video with <canvas>

I'm trying to display precisely enough a video that I can stop on or jump to a specific frame. For now my approach is to display a video frame by frame on a canvas (I do have the list of images to display, I don't have to extract them from the video). The speed doesn't really matter as long as it's consistent and around 30fps. Compatibility somewhat matters (we can ignore IE≤8).

So first off, I'm pre-loading all the images:

var all_images_loaded = {};
var all_images_src = ["Continuity_0001.png","Continuity_0002.png", ..., "Continuity_0161.png"];

function init() {
    for (var i = all_images_src.length - 1; i >= 0; i--) {
        var objImage = new Image();
        objImage.onload = imagesLoaded;
        objImage.src = 'Continuity/'+all_images_src[i];
        all_images_loaded[all_images_src[i]] = objImage;
    }
}

var loaded_count = 0;
function imagesLoaded () {
    console.log(loaded_count + " / " + all_images_src.length);
    if(++loaded_count === all_images_src.length) startvid();
}

init();

and once that's done, the function startvid() is called.


Then the first solution I came up with was to draw on requestAnimationFrame() after a setTimeout (to tame the fps):

var canvas = document.getElementsByTagName('canvas')[0];
var ctx = canvas.getContext("2d");

var video_pointer = 0;
function startvid () {
    video_pointer++;
    if(all_images_src[video_pointer]){
        window.requestAnimationFrame((function (video_pointer) {
            ctx.drawImage(all_images_loaded[all_images_src[video_pointer]], 0, 0);
        }).bind(undefined, video_pointer))
        setTimeout(startvid, 33);
    }
}

but that felt somewhat slow and irregular...


So second solution is to use 2 canvases and draw on the one being hidden and then switch it to visible with the proper timing:

var canvas = document.getElementsByTagName('canvas');
var ctx = [canvas[0].getContext("2d"), canvas[1].getContext("2d")];

var curr_can_is_0 = true;
var video_pointer = 0;
function startvid () {
    video_pointer++;
    curr_can_is_0 = !curr_can_is_0;
    if(all_images_src[video_pointer]){
        ctx[curr_can_is_0?1:0].drawImage(all_images_loaded[all_images_src[video_pointer]], 0, 0);

        window.requestAnimationFrame((function (curr_can_is_0, video_pointer) {
            ctx[curr_can_is_0?0:1].canvas.style.visibility = "visible";
            ctx[curr_can_is_0?1:0].canvas.style.visibility = "hidden";
        }).bind(undefined, curr_can_is_0, video_pointer));

        setTimeout(startvid, 33);
    }
}

but that too feels slow and irregular...


Yet, Google Chrome (which I'm developing on) seems to have plenty of idle time: Chrome Dev Tools, timeline 1 Chrome Dev Tools, timeline 2 Chrome Dev Tools, timeline 3

So what can I do?

Upvotes: 1

Views: 2351

Answers (1)

Alexander O'Mara
Alexander O'Mara

Reputation: 60587

The Problem:

Your main issue is setTimeout and setInterval are not guaranteed to fire at exactly the delay specified, but at some point after the delay.

From the MDN article on setTimeout (emphasis added by me).

delay is the number of milliseconds (thousandths of a second) that the function call should be delayed by. If omitted, it defaults to 0. The actual delay may be longer; see Notes below.

Here are the relevant notes from MDN mentioned above.

Historically browsers implement setTimeout() "clamping": successive setTimeout() calls with delay smaller than the "minimum delay" limit are forced to use at least the minimum delay. The minimum delay, DOM_MIN_TIMEOUT_VALUE, is 4 ms (stored in a preference in Firefox: dom.min_timeout_value), with a DOM_CLAMP_TIMEOUT_NESTING_LEVEL of 5.

In fact, 4ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.

In addition to "clamping", the timeout can also fire later when the page (or the OS/browser itself) is busy with other tasks.

The Solution:

You would be better off using just requestAnimationFrame, and inside the callback using the timestamp arguments passed to the callback to compute the delta time into the video, and drawing the necessary frame from the list. See working example below. As a bonus, I've even included code to prevent re-drawing the same frame twice.

Working Example:

var start_time = null;
var frame_rate = 30;

var canvas = document.getElementById('video');
var ctx = canvas.getContext('2d');

var all_images_loaded = {};
var all_images_src = (function(frames, fps){//Generate some placeholder images.
	var a = [];
	var zfill = function(s, l) {
		s = '' + s;
		while (s.length < l) {
			s = '0' + s;
		}
		return s;
	}
	for(var i = 0; i < frames; i++) {
		a[i] = 'http://placehold.it/480x270&text=' + zfill(Math.floor(i / fps), 2) + '+:+' + zfill(i % fps, 2)
	}
	return a;
})(161, frame_rate);

var video_duration = (all_images_src.length / frame_rate) * 1000;

function init() {
	for (var i = all_images_src.length - 1; i >= 0; i--) {
		var objImage = new Image();
		objImage.onload = imagesLoaded;
		//objImage.src = 'Continuity/'+all_images_src[i];
		objImage.src = all_images_src[i];
		all_images_loaded[all_images_src[i]] = objImage;
	}
}

var loaded_count = 0;
function imagesLoaded () {
    //console.log(loaded_count + " / " + all_images_src.length);
    if (++loaded_count === all_images_src.length) {
		startvid();
	}
}

function startvid() {
	requestAnimationFrame(draw);
}

var last_frame = null;
function draw(timestamp) {
	//Set the start time on the first call.
	if (!start_time) {
		start_time = timestamp;
	}
	//Find the current time in the video.
	var current_time = (timestamp - start_time);
	//Check that it is less than the end of the video.
	if (current_time < video_duration) {
		//Find the delta of the video completed.
		var delta = current_time / video_duration;
		//Find the frame for that delta.
		var current_frame = Math.floor(all_images_src.length * delta);
		//Only draw this frame if it is different from the last one.
		if (current_frame !== last_frame) {
			ctx.drawImage(all_images_loaded[all_images_src[current_frame]], 0, 0);
			last_frame = current_frame;
		}
		//Continue the animation loop.
		requestAnimationFrame(draw);
	}
}

init();
<canvas id="video" width="480" height="270"></canvas>

Upvotes: 4

Related Questions