Christopher Pitt

Creating GIFs with HTML

I love weird combinations of tech, in an effort to create something new. A while ago I was wondering if it would be possible to create videos or GIFs using tech I understand. Something like SVG or HTML. Then it dawned on me that a I had already previously created PNGs using SVG, and that all I would need to do is find some tech that I could feed those images to. A bit of googling led me to gif.js...

Creating SVG from HTML

The first, and arguably the coolest, step is making an SVG out of some HTML. SVG is really good for creating vector artwork, that scales to any resolution. Artwork that can animate and be controlled by JS. The trick is getting bog-standard HTML (and maybe even a bit of inline CSS animation) to work inside an SVG.

I discovered this element called Foreign Object. MDN says it's supported in most modern browsers, while caniuse.com says a few modern mobile browsers have spotty support. We're looking to create a GIF in a controlled environment (like modern Safari or Chrome), so this is fine.

The simplest use is:

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
    <foreignObject width="100" height="100">
        <div xmlns="http://www.w3.org/1999/xhtml">
            some content...
        </div>
    <foreignObject>
</svg>

You shouldn't need the xmlns attributes; but I've had strange rendering issues without them. I guess they're staying!

So, let's say I set up blank environment...

<!DOCTYPE html>
<html lang="en">
    <head>
        <link rel="stylesheet" href="app.css" />
    </head>
    <body>
        <div class="edit" contenteditable="true">
            <div class="marquee">hello world</div>
        </div>
        <canvas class="preview"></canvas>
        <script src="app.js"></script>
    </body>
</html>
body,
.preview-inner {
    font-family: sans-serif;
    font-size: 2rem;
    color: #000;
}

body {
    padding: 5rem;
}

.edit,
.preview {
    position: absolute;
    width: calc(50% - 1.5rem);
    height: calc(100% - 2rem);
    border: solid 2px #666;
    padding: 1rem;
    box-sizing: border-box;
    overflow: hidden;
}

.edit {
    left: 1rem;
    top: 1rem;
}

.preview {
    right: 1rem;
    top: 1rem;
    overflow: hidden;
}

.preview-inner {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    padding: 1rem;
    box-sizing: border-box;
    overflow: hidden;
}

.marquee {
    display: inline-block;
    white-space: nowrap;
    position: absolute;
}
const edit = document.querySelector(".edit")
const marquee = document.querySelector(".marquee")
const preview = document.querySelector(".preview")

const context = preview.getContext("2d")

const devicePixelRatio = window.devicePixelRatio || 1
context.scale(devicePixelRatio, devicePixelRatio)

We can begin to animate the "marquee" element, by changing it's left position:

function animate() {
    requestAnimationFrame(function() {
        marquee.style.left = `${marquee.offsetLeft + 5}px`

        if (marquee.offsetLeft > edit.offsetWidth) {
            marquee.style.left = `0px`
        }

        animate()
    })
}

animate()

requestAnimationFrame vs setInterval

Let's talk, for a moment, about why I've gone down the requestAnimationFrame route. There are a few different ways we can animate stuff, including CSS and SVG animation. If we want to animate, using JS, then we need to use timing functions in those other techs, or we need to make our own.

In the past, you might have seen something like setTimeout or setInterval used, to animate HTML. You might even have used these yourself. The approach usually follows this pattern:

setInterval(function() {
    // animate elements by changing their style objects...
}, 1000 / 60)

The 1000 / 60 value is so that this animation function can be run as close to 60 frames a second, as possible.

There are a few problems with using these timing functions for animation. The biggest is that they ignore the capabilities of the machine that's running the animation. We aim to make the animation happen at 60 frames a second, but what about when the animation is running on a slow machine? It's not like we leave any room for it to "skip" some of the frames if it can't get to them. On a slow machine, all this will do is build up a backlog of animation frames, many of which might never be used.

requestAnimationFrame is a cross-browser attempt at solving this problem. It's a way of telling the browser; "here's some animation I want to happen. Please try to make it happen 60 times a second". The browser will usually adjust the number of frames it renders according to the capacity it has available, at a predictable time.

If we want the animation to continue beyond a single frame, we need to recursively call requestAnimationFrame. We can also "cancel" an animation frame request, using cancelAnimationFrame, similarly to how setTimeout and clearTimeout work.

A new marquee for a new age

HTML to SVG to Image to Canvas

Now that the marquee is animating, we can replicate this animation in canvas. The process is a bit tricky, though. First, we need to understand that canvas can have multiple sources. We can draw on the canvas, using common drawing functions. Things like lines and rectangles and arcs. We can also use videos and images as a source, by copying a frame from video or the contents of an image into the canvas.

Knowing we can use images as a source, we also need to understand that image objects can have multiple sources. We can link to any URL that the browser can load. That includes data URIs.

Let's go back to the Foreign Object element. If we put HTML in that, and convert the resulting SVG element to a data URI; then we should be able to feed that into an Image object, and then copy that image object to a canvas!

The code looks like this:

const width = preview.offsetWidth
const height = preview.offsetHeight

// remember, preview is the canvas element...
preview.width = width
preview.height = height

const styles = getStyles().join("\n")

function snapshot() {
    requestAnimationFrame(function() {
        let svg = `
            <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
                <foreignObject width="100%" height="100%">
                    <style xmlns="http://www.w3.org/1999/xhtml">
                        ${styles}
                    </style>
                    <div class="preview-inner" xmlns="http://www.w3.org/1999/xhtml">
                        ${edit.innerHTML}
                    </div>
                </foreignObject>
            </svg>
        `

        svg = btoa(svg)
        // svg = encodeURIComponent(svg)

        const image = new Image()

        image.onload = function() {
            context.fillStyle = "#fff"
            context.fillRect(0, 0, width, height)
            context.drawImage(image, 0, 0, width, height)

            snapshot()
        }

        // image.src = "data:image/svg+xml;utf8," + svg
        image.src = "data:image/svg+xml;base64," + svg
    })
}

snapshot()

There are a couple of ways to generate a data URI. One way is to encodeURIComponent the markup, and use data:image/svg+xml;utf8, as the prefix. The other is to btoa (or Base64 encode) the markup, and use data:image/svg+xml;base64, as the prefix.

Using requestAnimationFrame means we need to schedule the next frame recursively. snapshot generates some SVG, and sets it as the src to a new Image object. Once the src is "loaded", we copy the image data to the canvas.

You may have noticed the styles variable. Foreign Object elements don't inherit all the styles of the document that contains them. We need to "export" these styles to the SVG "document", which we can do with this bit of code:

function getStyles() {
    let styles = []

    for (let i = 0; i < document.styleSheets.length; i++) {
        for (let j = 0; j < document.styleSheets[i].cssRules.length; j++) {
            styles.push(document.styleSheets[i].cssRules[j].cssText)
        }
    }

    return styles
}

It's an ugly but effective way to share styles. It's also why I insisted we use inline styles for animation. We could use other forms of animation, but they'd complicate this example quite a bit.

Now, you should see the animation repeated on the left and right. The right-hand side might look slightly different, as canvas tends to render fonts slightly different to how browser engines do.

HTML → SVG → image → canvas

Creating GIFs from canvas

Now that we have (almost) frame-by-frame updates to canvas; we can take snapshots to compile a GIF. At this point, you'd probably want to use something like FFmpeg to build a proper GIF; but I want to carry the experiment out in the browser!

Let's install gif.js...

npm install gif.js

Then, we need some code to take snapshots of the canvas:

const gif = new GIF({
    workers: 4,
    quality: 10,
    workerScript: "node_modules/gif.js/dist/gif.worker.js",
    width,
    height,
})

const fps = 15

gif.on("finished", function(blob) {
    window.open(URL.createObjectURL(blob), "rendered")
})

setInterval(function() {
    gif.addFrame(preview, { delay: 60 / fps })
}, 1000 / fps)

preview.addEventListener("click", function() {
    gif.render()
})

Notice how we're going back to using setInterval? That's because it's much harder to limit to less than 60 frames per second (intentionally) when using requestAnimationFrame. It's possible, but it's ugly.

We take a "snapshot" of the canvas, at 15 frames per second. Because we're animating at 60 frames per second, but we only want 15 frames per second in the GIF; we need to add a delay that takes the difference into account. It's essentially the same as the delay we use for setInterval.

We also have a click event handler, to build the GIF. The longer you leave the animation "running", before clicking on the canvas, the bigger the GIF is going to be. The longer it's going to take to generate, etc.

Your browser might be blocking popups, automatically. That means window.open will not automatically work.

This is naive way to use this library. A better way would be to have a clear point at which "recording" starts and stops. Consider that an exercise for you, when you write this code. Alternatively, you can save each "frame" as a PNG, using the canvas.toDataURL('image/png') method; and stitch each frame together using FFmpeg.

Would you like me to keep you in the loop?

I write about all sorts of interesting code things, and I'd love to share them with you. I will only send you updates from the blog, and will not share your email address with anyone.