UltraMega Blog
1Sep/101

Record HTML Canvas Animations to Video

Sometimes it might be useful to be able to record your canvas animation to a video format. Maybe you want to use your JavaScript skills to create fancy effects for a video. You could use some kind of screen capturing program and crop the video, but I'll show you how to do it with code!

Note: You'll need an HTTP server with PHP running on your local machine to do this. Don't try this with your website over the Internet, unless you don't mind waiting for tons of images to upload... Also, I recommend using Chrome for best results.

Let's start with our simple animation. You can see it in action here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
(function () {
    var canvas = document.getElementById('c'),
        c = canvas.getContext('2d'),
        w = canvas.width, h = canvas.height,
        p = [], clr, n = 200;
 
    clr = [ 'red', 'green', 'blue', 'yellow', 'purple' ];
 
    for (var i = 0; i < n; i++) {
        // generate particle with random initial velocity, radius, and color
        p.push({
            x: w/2,
            y: h/2,
            vx: Math.random()*12-6,
            vy: Math.random()*12-6,
            r: Math.random()*4+3,
            clr: Math.floor(Math.random()*clr.length)
        });
    }
 
    function frame() {
        // cover the canvas with 50% opacity (creates fading trails)
        c.fillStyle = 'rgba(0,0,0,0.5)';
        c.fillRect(0, 0, w, h);
 
        for (var i = 0; i < n; i++) {
            // reduce velocity to 99%
            p[i].vx *= 0.99;
            p[i].vy *= 0.99;
 
            // adjust position by the current velocity
            p[i].x += p[i].vx;
            p[i].y += p[i].vy;
 
            // detect collisions with the edges
            if (p[i].x < p[i].r || p[i].x > w-p[i].r) {
                // reverse velocity (direction)
                p[i].vx = -p[i].vx;
                // adjust position again (in case it already passed the edge)
                p[i].x += p[i].vx;
            }
            // see above
            if (p[i].y < p[i].r || p[i].y > h-p[i].r) {
                p[i].vy = -p[i].vy;
                p[i].y += p[i].vy;
            }
 
            // draw the circle at the new postion
            c.fillStyle = clr[p[i].clr]; // set color
            c.beginPath();
            c.arc(p[i].x, p[i].y, p[i].r, 0, Math.PI*2, false);
            c.fill();
        }
    }
 
    // execute frame() every 30 ms
    setInterval(frame, 30);
}());

This code creates 200 randomly generated particles, each with different positions, velocities, sizes, and colors. It also has a function that is executed every 30 milliseconds via setInterval. This frame function represents a single frame of our animation in which each particle is adjusted and rendered into the image. The velocity is also reduced to 99% each frame, causing the particles to gradually slow down.

Recording Frames

In order to create a video, we'll need to capture each frame as an image. Fortunately, the canvas element gives us access to this image data via the toDataURL method. This method returns a data URL for an image representation of the canvas, which can be understood by the browser. The URL contains the binary image data (PNG by default) as a base64 encoded string.

So here is how our process will go: Every time a new frame is rendered, we will send the data URL, along with a serial number, asynchronously to a PHP script. This script will extract the base64 string, decode it to binary, then write it to a PNG file named with the serial number. After every request, the next frame will be executed, repeating the process until we stop it. Once we have our sequence of images, we can use our favorite video program to convert it to our preferred format.

The Back-End

I don't know of a way to force a browser to automatically save images, but I do know how to force it to send stuff to an HTTP server. So this is where PHP comes in handy.

We'll start with our very simple PHP script. After checking that all the required inputs are there, it extracts the base64 and decodes it with the base64_decode function. Once it has the binary data, we craft a file name that includes the serial number with sprintf and then writes the file with file_put_contents.

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$path = 'frames/';
if(isset($_POST['data']) && isset($_POST['i']) && is_numeric($_POST['i'])) {
    // split the data URL at the comma
    $data = explode(',', $_POST['data']);
    // decode the base64 into binary data
    $data = base64_decode(trim($data[1]));
 
    // create the numbered image file
    $filename = sprintf('%s%08d.png', $path, $_POST['i']);
    file_put_contents($filename, $data);
}

The $path variable should contain a path, with trailing slash, to where you want the images stored. This directory must be writable to your HTTP server.

There are a few important things to note here. First is how we extract the data part of the URL, which is in the format "data:image/png;base64,iVBORw0KGgoAAA..." Everything after the comma is the data, so that's all we care about. The other thing is how we name our file using sprintf, specifically the "%08d" part, which prints the serial number as 8 digits, 0 padding as necessary. This helps with sorting later.

Modifying the JavaScript

With our back-end in place, it's time to modify our JavaScript. Here is the part where we defined our global variables, but with one addition. We have added a counter variable to store our serial number. This will be incremented for each frame.

    var canvas = document.getElementById('c'),
        c = canvas.getContext('2d'),
        w = canvas.width, h = canvas.height,
        p = [], clr, n = 200,
        // frame identifier
        counter = 0;

Now we need to add some code to the end of the frame function. This is where we will be making an AJAX request. Here, we create two new variables, an XMLHttpRequest object and our data URL. We have also opened a new POST connection to our PHP script.

        var req = new XMLHttpRequest();
 
        // open a POST request to our backend
        req.open('post', 'saveframe.php');
 
        // capture the data URL of the image
        var data = canvas.toDataURL();

Now, we are going to put together our POST data in a URL encoded string. This includes two variables: data, an escaped version of our data URL; and i, the frame number. Note the use of counter++, which returns the current value and then increments it.

        // encode the data along with the frame identifier (and increment it)
        data = 'data=' + encodeURIComponent(data) + '&i=' + counter++;

We also must set the appropriate headers.

        // set the appropriate request headers
        req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        req.setRequestHeader("Content-length", data.length);
        req.setRequestHeader("Connection", "close");

We also need to specify what to do when the request is done. Here is where we either execute another frame or end based on whatever conditions we decide. In this example, I stop it after the 150th frame which is 5 seconds at 30 fps. You can replace counter < 150 with whatever condition you prefer.

        // handle request responses
        req.onreadystatechange = function () {
            // continue if request is done
            if (req.readyState === 4 && req.status === 200) {
                // if we have not finished recording, execute another frame
                if (counter < 150) {
                    frame();
                } else {
                    alert('done');
                }
            }
        };

After setting the event handler, we send our data.

        // send the data
        req.send(data);

In our original script, we had a setInterval to keep the animation going at a steady pace. Now that we have our frame function repeating itself on its own time, we replace the setInterval with a single function call to start the process.

    // start the whole process
    frame();

Creating Video

With everything in place, we can now execute the script. After waiting patiently for the process to finish, assuming everything is right, we will have a pile of PNG images in our server directory.

We can now convert this image sequence to a video format. There are many programs that can do this, and I always use VirtualDub. All you have to do is open the first image, set the framerate (~30 fps), choose a compression, then save as a video.

Conclusion

Here's the result of this process:

Finally, here is the complete code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
(function () {
    var canvas = document.getElementById('c'),
    c = canvas.getContext('2d'),
    w = canvas.width, h = canvas.height,
    p = [], clr, n = 200,
    // frame identifier
    counter = 0;
 
    clr = [ 'red', 'green', 'blue', 'yellow', 'purple' ];
 
    for (var i = 0; i < n; i++) {
        // generate particle with random initial velocity, radius, and color
        p.push({
            x: w/2,
            y: h/2,
            vx: Math.random()*12-6,
            vy: Math.random()*12-6,
            r: Math.random()*4+3,
            clr: Math.floor(Math.random()*clr.length)
        });
    }
 
    function frame() {
        // cover the canvas with 50% opacity (creates fading trails)
        c.fillStyle = 'rgba(0,0,0,0.5)';
        c.fillRect(0, 0, w, h);
 
        for (var i = 0; i < n; i++) {
            // reduce velocity to 99%
            p[i].vx *= 0.99;
            p[i].vy *= 0.99;
 
            // adjust position by the current velocity
            p[i].x += p[i].vx;
            p[i].y += p[i].vy;
 
            // detect collisions with the edges
            if (p[i].x < p[i].r || p[i].x > w-p[i].r) {
                // reverse velocity (direction)
                p[i].vx = -p[i].vx;
                // adjust position again (in case it already passed the edge)
                p[i].x += p[i].vx;
            }
            // see above
            if (p[i].y < p[i].r || p[i].y > h-p[i].r) {
                p[i].vy = -p[i].vy;
                p[i].y += p[i].vy;
            }
 
            // draw the circle at the new postion
            c.fillStyle = clr[p[i].clr]; // set color
            c.beginPath();
            c.arc(p[i].x, p[i].y, p[i].r, 0, Math.PI*2, false);
            c.fill();
        }
 
        var req = new XMLHttpRequest();
 
        // open a POST request to our backend
        req.open('post', 'saveframe.php');
 
        // capture the data URL of the image
        var data = canvas.toDataURL();
 
        // encode the data along with the frame identifier (and increment it)
        data = 'data=' + encodeURIComponent(data) + '&i=' + counter++;
 
        // set the appropriate request headers
        req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        req.setRequestHeader("Content-length", data.length);
        req.setRequestHeader("Connection", "close");
 
        // handle request responses
        req.onreadystatechange = function () {
            // continue if request is done
            if (req.readyState === 4 && req.status === 200) {
                // if we have not finished recording, execute another frame
                if (counter < 150) {
                    frame();
                } else {
                    alert('done');
                }
            }
        };
 
        // send the data
        req.send(data);
    }
 
    // start the whole process
    frame();
}());

Posted by Steve

Comments (1) Trackbacks (1)
  1. hello sir, ive developed a webpage where i upload a video and add some effects to it after copying it in the canvas element, i want to save the video as it is being rendered in the canvas tag..


Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.