Autoplaying video previews for the web

If a picture is worth a thousand words, and a video is made of several pictures in a sequence, then a video is worth several thousand words.
A great way to show a preview of online content is by using video thumbnails – an ambiguous term that can both refer to a static image, usually depicting one frame of the video (also known as poster image), or, more interestingly, a video preview that’s a smaller and shorter version of the referenced content.

If you arrived to this page through the homepage of my website, you might have noticed that every post on it features an animated thumbnail. It was a natural choice since most of my work on display is some form of animated media, or at least accompanied by video assets.
For a long time my thumbnails have been simple animated GIFs – one of those ancient formats that managed to maintain popularity for longer than expected – but there now is a good selection of modern, more efficient formats to display motion content on the web.

WebM for the win

I researched a few options: animated PNG (APNG), WebM, animated WebP, MP4. After evaluating the pros and cons of each container, I finally settled for WebM
because of the smallest file size (for a quality that’s visually similar to other solutions tested), a wider browser support, and because – I’m betting my money on it – it’s a future-proof file format. (I’m joking. I never bet any money.)
WebM is an open-source container format, while the actual video encoding format I am using is VP9, which has very good browser support even across the mobile and iOS landscape.

I would have loved to lazily set all videos to play on load and in a loop, but the state of the technology told me we we’re not there yet.
Having many looping videos playing simultaneously is resource-intensive. For this reason if on a page there are multiple videos with the autoplay attribute, most devices will let only the first one play automatically.
So I had to come up with my own method to dynamically control which video is playing, based on how far the user has scrolled the page. I do this in JavaScript. An additional benefit of controlling video with scripting is the ability to only load a resource as the user is about to display it (which saves my bandwitdh and users’ metered data). There’s also room for a fallback poster.

Generating WebM files

My editing video software can output WebM, but in case yours doesn’t, you can use a PNG sequence as intermediary format, or an MP4 video with lossless or near-lossless compression settings. Then you’d batch transcode your source videos with the open-source tool ffmpeg and the following command:

for f in *.mp4; do
echo "Processing file $f."
ffmpeg -i "$f" -c:v libvpx-vp9 -b:v 500K -pass 1 -an -f null /dev/null && \
ffmpeg -i "$f" -c:v libvpx-vp9 -b:v 500K -pass 2 -an "$f".webm
done

This is what I’d use instead to convert a PNG image sequence to WebM:

ffmpeg -i '%.png' -pix_fmt yuv420p -r 24 -c:v libvpx-vp9 -b:v 500K -pass 1 -an -f null /dev/null && ffmpeg -i '%.png' -pix_fmt yuv420p -r 24 -c:v libvpx-vp9 -b:v 500K -pass 2 -an output.webm

Note the need to perform two encoding passes, and the use of the flag “an” (audio=none) to suppress any audio channel.

The JavaScript code

This is the code that does all the magic. Just include it on a page with video elements and it will automatically control their playback. The first video in the DOM will play immediately. As new ones become fully visible and start playing, one at a time, previous ones will stop.

var videos
var currentVideo

// Callback function for the window scroll event (called through debounce() to
// limit the frequency of calls)
function onScroll(){
    let _selectedVideo = videos[0]
    let _last = -1
    for (const [_index, _video] of videos.entries()) {
        if(isVisible(_video))
            loadOnce(_video)
        if(isVisible(_video, true)){ // if the second parameter is true, a video needs to be fully visible for the function to return true
            if(_index>_last){
                _last = _index
                _selectedVideo = _video
            }
        }
    }
    if(currentVideo != _selectedVideo){
        currentVideo = _selectedVideo
        playOneVideo(_selectedVideo)
    }
}

// Lazy-load a video 
function loadOnce(video) {
    if(video && video.dataset.source){
        video.setAttribute("src",video.dataset.source)
        video.load()
        delete video.dataset.source
    }
}

// Play a video after pausing all other videos
function playOneVideo(video) {
    if(video && video.readyState === 4){
        // Pause all videos first
        for (let _video of videos) {
            _video.pause()
        }
        video.play()
    }
}

// Attach to the window as soon as the DOM is fully loaded
window.addEventListener('load',
    function() {
        videos = document.querySelectorAll('video')

        // Play the first video
        if(videos.length>0){
            currentVideo = videos[0]
            loadOnce(currentVideo)
            currentVideo.oncanplay = function(event) {
                playOneVideo(event.target)
            }
        }

        window.addEventListener('scroll', debounce(onScroll))
    }, false)

// Check if an element is visible in the viewport
function isVisible(elem, fullyVisible) {
    let bounds = elem.getBoundingClientRect()
    let windowHeight = document.documentElement.clientHeight
    let isTopVisible = bounds.top > 0 && bounds.top < windowHeight
    let isBottomVisible = bounds.bottom < windowHeight && bounds.bottom > 0
    if(fullyVisible) {
        return isTopVisible && isBottomVisible
    }
    return isTopVisible || isBottomVisible
}

// Limit the amount of calls to a callback function.
// From https://gist.github.com/Sidd27/daa8c600694ee99b62daabcba0af85cb#file-debounce-js
function debounce(func, wait, immediate) {
	var timeout
	return function() {
		var context = this, args = arguments
		var later = function() {
			timeout = null
			if (!immediate) func.apply(context, args)
		}
		var callNow = immediate && !timeout
		clearTimeout(timeout)
		timeout = setTimeout(later, wait)
		if (callNow) func.apply(context, args)
	}
}

That’s it! Now everything moves, but only when we need it to.

Sources:
https://trac.ffmpeg.org/wiki/Encode/VP9
https://corydowdy.com/blog/apng-vs-webp-vs-gif