Overview
When it comes to web animations, there are two popular options available: canvas-based animation using pixel-based drawings or CSS-based animation applied on HTML DOM elements.
While canvas animation is often considered the simpler approach for exporting animations to video and GIF formats, it can also limit the range of animations that can be created. Although complex and advanced animations are a possibility, it can quickly become a memory-intensive operation, especially when exporting to popular animation formats.
On the other hand, CSS-based animation tools like anime.js offer a more lightweight yet powerful approach to animation, allowing for more complex and visually engaging animations. With anime.js, you can create animations that manipulate CSS properties like colors, opacity, transforms, and more to achieve a wide range of effects. However, exporting these animations to video and GIF formats on the server side can be a more challenging task due to the complexities of translating the CSS-based animation into an accurate video or GIF output.
What we’re going to build:
In this article, I will provides a detailed and step-by-step instructions to converting anime.js animations from the client-side to MP4 and GIF formats on the server-side. The process involves utilizing a combination of Node.js modules and FFMPEG executable to successfully achieve the conversion.
The process starts by first setting up the project’s base structure and configuring the Node.js environment with the necessary modules. Next, it involves creating an anime.js animation on the client-end, as well as writing a combination of server-side scripts to generate an MP4 video of the animated HTML DOM elements.
Then, by taking it one step further and with minimum of effort, I will demonstrate how you can further enhance the output capabilities by adding an FFMPEG executable in the mix to seamlessly convert the MP4 video output to a GIF file.
Since handling dynamic animations and video generation is normally a resource-intensive process, I will also be mindful that the solutions offered in this guide not only allow for greater flexibility but also optimize the workflow and minimize resource usage.
Setting up the Project:
To get started, let’s set up our project with the necessary dependencies and structure. Express Generator, a widely-used Node.js tool, can help us quickly initialize our project and establish the base.
First install Express Generator globally by running the following command in your terminal:
npm install -g express-generator
Then run the following command on the project’s terminal:
express --view=hbs anime.js-to-mp4-gif
This command creates a new directory named “anime.js-to-mp4-gif” with a basic structure for an Express app using the Handlebars (hbs) view engine.
Here’s a possible diagram of the project’s directory structure created by Express Generator:
anime.js-to-mp4-gif/ ├── app.js ├── bin │ └── www ├── node_modules/ ├── package.json ├── public/ │ ├── images/ │ ├── javascripts/ │ └── stylesheets/ ├── routes/ │ └── index.js └── views/ ├── error.hbs ├── index.hbs └── layout.hbs
In this structure, app.js is the main file for our application. The bin/www file is responsible for starting the server. The public directory contains static assets including images, JavaScript, and CSS files. The views folder is from where the templates are loaded from and rendered by our application.
Also make sure to that you have installed the necessary dependencies listed in the package.json file:
npm install
Adding Elements
It’s time to start adding elements that we intend to animate. Add the code below to the layout.hbs template file from the /views folder at the root of your project’s directory:
<!DOCTYPE html> <html> <head> <title>Anime.js to MP4 | GIF</title> <link rel='stylesheet' href='/stylesheets/style.css' /> <link href="https://fonts.googleapis.com/css?family=Graduate:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;700&display=swap" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.0/anime.min.js"></script> </head> <body> {{{body}}} </body> </html>
This code snippet sets up the layout structure for the HTML page. In order to style and animate client-side elements, google fonts and anime.js library are imported via link and script tags, respectively, in the head section of our layout.hbs file. The {{{body}}} placeholder in the body section is where the main content of our project’s index.hbs page will be inserted.
Next, add the following code in the index.hbs template file from the views folder:
<h1>Anime.js to MP4 Video</h1> <p>with FFMPEG & Node.js</p> <section class="columns"> <div class="container"> <div class="svg">.... SVG code for the icon (available on GitHub) ...</div> <div class="company">Company Name</div><div class="slogan">Company Slogan</div> </div> </section> <section class="columns"> <button class="button" type="button">Convert</button> </section>
The index.hbs template file now has a div element with the class name container, which will hold the elements that needs to animate. In this case, there are three elements within the parent div: a div with the class name svg, which contains an SVG icon, followed by two more div elements, with the class name company and slogan respectively.
Finally, in order to ensure that the added elements are visually consistent with the rest of the page, let’s add some CSS in your style.css file located in the /public/stylesheets folder.
.svg { left:220px; top:55px; position:relative; width:130px; height:auto; } .company { font-size: 2em; color:#FF4F00; font-family: 'Graduate', sans-serif; left:150px; top:60px; position:relative; } .slogan { font-size: 1em; color:#9E9C98; font-family: 'Quicksand', sans-serif; left:220px; top:65px; position:relative; } .columns { display: flex; flex-flow: row wrap; justify-content: center; margin: 5px 0; } .container { flex: 1; border: 1px solid gray; margin: 2px; padding: 0px; &:first-child { margin-left: 0; } &:last-child { margin-right: 0; } max-width:600px; height:300px; } .button { appearance: none; background-color: #FAFBFC; border: 1px solid rgba(27, 31, 35, 0.15); border-radius: 6px; box-shadow: rgba(27, 31, 35, 0.04) 0 1px 0, rgba(255, 255, 255, 0.25) 0 1px 0 inset; box-sizing: border-box; color: #24292E; cursor: pointer; display: inline-block; font-family: -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 14px; font-weight: 500; line-height: 20px; list-style: none; padding: 6px 16px; position: relative; transition: background-color 0.2s cubic-bezier(0.3, 0, 0.5, 1); user-select: none; -webkit-user-select: none; touch-action: manipulation; vertical-align: middle; white-space: nowrap; word-wrap: break-word; }
The .svg, .company, and .slogan classes are used to position and style the SVG icon, company name, and slogan, respectively. The .container class is used to set the styling for the container that holds the SVG icon and the related text elements in our layout.
Run your project using the command npm start.
Opting to use a professionally styled logo for our project is to demonstrate that by utilizing the methods discussed here, you can easily export animations of highly aesthetic elements to pixel perfect MP4 and GIF outputs.
Adding Animation
It is time to start applying animation to the existing elements. Create an index.js file in the /public/javascripts folder, which is where the JavaScript code to initiate and control the client-side animation will be placed:
window.addEventListener("DOMContentLoaded", () => { let timeline = anime.timeline({loop: false,autoplay: true}) function initiate() { timeline.add({ targets: '.svg', scale: [0, 1], opacity: [0,1], easing: 'easeOutExpo', duration: 1500, endDelay: 0 }).add({ targets: '.company', "translateX": [-150, 0], opacity: [0,1], duration: 2000, endDelay: 0}).add({ targets: '.slogan', "translateY": [50, 0], opacity: [0,1], duration: 1000, endDelay: 0 })}; initiate(); });
The above code snippet adds the animation to the timeline and initiates the animation sequence when the DOMContentLoaded event is fired. This ensures that the animation is only initiated once the relevant elements are available in the DOM.
An anime.timeline object is initiated with the loop property set to false and the autoplay to true. Next, a function initiate is defined responsible for handling the individual animations to the timeline. Finally, a timeline.add method is used to add the animations.
Breakdown:
- The first animation targets the .svg element and scales it from 0 to 1 while also increasing its opacity. The easing function used is easeOutExpo, and the duration is set to 1500 milliseconds.
- The second animation targets the .company text element and translates it from -150px to 0px on the X-axis while also increasing its opacity. The duration is set to 2000 milliseconds.
- The third animation targets the .slogan , also a text element, and translates it from 50px to 0px on the Y-axis while also increasing its opacity. The duration is set to 1000 milliseconds.
Next, the initiate function is invoked to start the animation sequence.
Import the index.js file in our index.hbs template.
<script src="/javascripts/index.js"></script>
In the next step, I will demonstrate the significance of keeping our animation code within the initiate function and how it ensures an accurate transition of capturing and posting the animation data to the server-side for conversion.
Exporting to Server-side API
Until this point, we have established the project, created the DOM elements, and implemented animations. While these tasks are significant, they are relatively straightforward compared to the upcoming critical steps. We have now reached the core of the process that directly impacts the end result.
As I mentioned earlier in this article, handling dynamic animations and video generation can be a resource-intensive operation. Therefore, it is also crucial to opt for an approach that is minimalist in nature and helps optimize the workflow.
Add the following code snippet to the index.js file:
const convert = async () => { let data = { html: document.querySelectorAll('.container')[0].outerHTML, animation: initiate.toLocaleString(), duration: Number(timeline.duration/1000), width: document.querySelectorAll('.container')[0].offsetWidth, height: document.querySelectorAll('.container')[0].offsetHeight, } const response = await fetch("/convert-to-video", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify(data), }); };
A convert function is defined, along with an object data with a following set of parameters:
Breakdown:
- The html parameter uses document.querySelectorAll to store the entire HTML content of the container element withholding the animated objects, including the parent element itself.
- Since initiate is a function that contains the animation timeline, toLocaleString() is used to convert the initiate function to a string so that it can be included in the animation parameter being sent to the server.
- The duration parameter is being set to the duration of the animation timeline created using anime.js. The timeline duration is measured in milliseconds, but the server-side conversion expects the duration to be provided in seconds. Hence, the duration value is divided by 1000 and converted to a number using the Number() method.
- The width parameter is being set to the width of the container element. The offsetWidth property is used to retrieve the width of the element in pixels.
- For the height parameter, similar to it’s offsetWidth counterpart, the offsetHeight property is used to retrieve the height of the container element in pixels.
Including the aforementioned parameters in the data object ensures that the server API receives the accurate animation data required to convert the animation to MP4 and GIF formats. Moreover, utilizing JavaScript’s toLocaleString() method to stringify the initiate function also keeps the payload greatly reduced to achieve faster load times and improved overall performance.
Next, the data object is passed as the body of the request, and sent as a POST request to a /convert-to-video endpoint by utilizing a fetch API.
Finally, let’s bind the convert function to the onclick event of the submit button.
document.querySelectorAll('.button')[0].onclick = convert;
The Conversion Process
-
Creating the videoRouter:
To handle the POST request sent from the client, a new route in our server-side code needs to be created. This route will receive the animation data sent from the fetch api and process it for conversion.
Open the app.js file from the project’s root directory and add the following code:
var videoRouter = require('./routes/video'); app.use('/', videoRouter);
Your app.js should now look like this.
var createError = require('http-errors'); var express = require('express'); var path = require('path'); var indexRouter = require('./routes/index'); var videoRouter = require('./routes/video'); var app = express(); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'hbs'); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/', indexRouter); app.use('/', videoRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; res.status(err.status || 500); res.render('error'); }); module.exports = app;
Creating the videoRouter route allows us to handle requests at the root endpoint. Next, create a video.js file in the routes directory of our project.
var express = require('express'); var router = express.Router(); router.post('/convert-to-video', async function(req, res) { let data = req.body; // Process animation data here }); module.exports = router;
Our videoRouter route now listens for a POST request at the /convert-to-video endpoint. It also retrieves the animation data sent in the request body using the req.body object.
-
Replicating the Animation on Server-side:
To create a precise MP4 version of our animation, the server-side scripts will need to be served with an identical copy of our client-side animated DOM elements and structure. This is where toHTML.js module comes into play, ensuring that animated DOM elements are duplicated and saved on the server-side as an HTML file.
Starting by create a new folder called modules at the root of our project where all the server-side code conversion will be stored. Next, create your first module, toHTML.js, inside the modules folder with the following code:
const fs = require('fs'); exports.toHTML = async(data) => { return new Promise(function(resolve, reject) { let html = `<html> <body style="margin:0px"> <link rel="stylesheet" href="../stylesheets/style.css" /> <link href="https://fonts.googleapis.com/css?family=Graduate:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;700&display=swap" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.0/anime.min.js"></script> ${data.html} <script> window.addEventListener("DOMContentLoaded", () => { let timeline = anime.timeline({ loop: false, autoplay: true }); ${data.animation} initiate(); }); </script> </body> </html>`; fs.writeFileSync("./public/output/animation.html", html); resolve('success'); }); };
The toHTML module takes in parameters data.html and data.animation, containing the animated DOM element’s HTML markup and the initiate function code in an stringify form respectively. By utilizing these params, toHTML module is able to generate an HTML code similar to the one created on the client, and then write it to an HTML file called animation.html in the /public/output folder of the project.
We now have a file that will enable us to replicate the animation on the server-side. If you open the server-side generated animation.html in your browser, it will play the exact same animation as created on the client end.
Here’s the code of the generated animation.html file for a quick comparison:
<html> <body style="margin:0px"> <link rel="stylesheet" href="../stylesheets/style.css" /> <link href="https://fonts.googleapis.com/css?family=Graduate:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Graduate:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;700&display=swap" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.0/anime.min.js"></script> <div class="container"> <div class="svg"> .... SVG code for the icon (available on GitHub) ... </svg> </div> <div class="company">Company Name</div> <div class="slogan">Company Slogan</div> </div> <script> window.addEventListener("DOMContentLoaded", () => { let timeline = anime.timeline({ loop: false, autoplay: true }); function initiate() { timeline.add({ targets: '.svg', scale: [0, 1], opacity: [0,1], easing: 'easeOutExpo', duration: 1500, endDelay: 0 }).add({ targets: '.company', "translateX": [-150, 0], opacity: [0,1], duration: 2000, endDelay: 0 }).add({ targets: '.slogan', "translateY": [50, 0], opacity: [0,1], duration: 1000, endDelay: 0 }) } initiate(); }); </script> </body> </html>
By drawing parallels in terms of code structure to the client side equivalent of our generated animation.html file, you will find that there are only a few minor differences such as the animation script is now inline, and the body margin set to zero to avoid any unwanted margins in the output. Additionally, the timeline is declared with its scope set to global to avoid code breaks since it was defined outside the scope of the original initiate function.
Next, call toHTML module inside the video.js route file:
var express = require('express'); var router = express.Router(); const { toHTML } = require('../modules/toHTML.js'); router.post('/convert-to-video', async function(req, res) { let data = req.body; await toHTML(data); res.send('success'); });
Our server-side is now ready to proceed with the conversion process.
-
Conversion to MP4 using timecut:
The timecut module of Node.js is specifically developed to capture videos of web pages that use JavaScript animations with smooth transitions. It works by opening the web page, replacing its time-handling functions, taking snapshots of the page, and sending the results to FFMPEG for encoding the frames into a video. This process enables high-fps capture of frames, ensuring that the resulting videos have smooth transitions.
Install timecut to our project by running the following command:
npm install timecut
Then create a folder called “ffmpeg” at the root of our project and place the ffmpeg executable file in it.
Note: To download the ffmpeg executable for your preferred operating system, you can visit its official website at https://ffmpeg.org/download.html.
Next, create a toMP.js module file inside the modules folder, containing the script to perform the MP4 conversion using the timecut module:
const timecut = require('timecut'); exports.toMP4 = async(data) => { return new Promise(function(resolve, reject) { timecut({ url: './public/output/animation.html', viewport: { width: data.width, height:data.height }, selector: '.container', left: 0, top: 0, right: 0, bottom: 0, width: data.width, height:data.height, fps: 30, launchArguments: ['--no-sandbox', '--disable-setuid-sandbox'], duration: data.duration, output: './public/output/animation.mp4', ffmpegPath: process.cwd()+'/ffmpeg/ffmpeg' }).then(function () { resolve('success'); }); }); };
Breakdown
- The toMP4 module takes in the data object as input, which contains the width, height, and duration of the animation.
- The timecut function captures the animation from the animation.html file. The viewport object is used to set the width and height of the captured video. The selector specifies the container element that contains the animation. The fps option sets the frames per second of the resulting video, and the duration option specifies the duration of the video in seconds.
- The launchArguments option is used to pass additional arguments to the browser instance, and the output option specifies the output file path for the MP4 file.
- The ffmpegPath option specifies the path to the ffmpeg executable file downloaded earlier, and is used to encode the captured frames into an MP4 video.
This part of the process is comparatively straightforward since all that needs to be done is to serve the server-side animation.html file to the timecut module along with few other essential parameters and timecut will take care of the rest.
To ensure that the MP4 starts generating only when the animation.html is successfully created, call the toMP4 below the toHTML module:
var express = require('express'); var router = express.Router(); const { toHTML } = require('../modules/toHTML.js'); router.post('/convert-to-video', async function(req, res) { let data = req.body; await toHTML(data); await toMP4(data); res.send('success'); });
Post your animation data and initiate the server-side process by clicking the convert button.
Our animation is around 4.5 seconds long, hence, at a frame rate of 30 fps, it will produce a total of 135 frames. These frames will be converted into an animation.mp4 file and saved in the /public/outputfolder using FFMPEG executable.
-
Conversion to GIF with FFMPEG
Since we already have the MP4 file, generating a GIF version of the same becomes a straightforward process that involves running couple of FFMPEG commands.
Please bear in mind that the FFMPEG library needs to first generate a palette file that will be used to create the GIF output. The palette file is an essential prerequisite used by FFMPEG to map colors in the video to colors in the GIF.
In order to achieve that, first create a toPalette.js module file inside the modules folder.
exports.toPalette = async() => { return new Promise(function(resolve, reject) { var exec = require('child_process').exec; var cmd = process.cwd()+'/ffmpeg/ffmpeg -i ./public/output/animation.mp4 -filter_complex "[0:v] palettegen" -preset veryfast ./public/output/palette.png'; exec(cmd, function(err, stdout, stderr) { if (err) reject(err); else resolve('success'); }); }); };
The FFMPEG command in the code takes the MP4 file as input and applies the “palettegen” filter to generate the palette file. The filter analyzes the colors in the MP4 file and creates a palette file that maps these colors to a smaller set of colors that can be used in the GIF. The resulting palette file is saved as a PNG file in the /public/output folder.
It’s worth noting that FFMPEG needs to create a palette file before creating a GIF file because GIFs are limited to 256 colors. FFMPEG generates a palette file from the MP4 output that contains the 256 most used colors in the animation. This palette file is then used to map the colors in the MP4 output to the 256 colors available in the GIF format.
Our final step is to create a module file called toGif.js, also placed inside the modules folder:
exports.toGif = async() => { return new Promise(function(resolve, reject) { var exec = require('child_process').exec; var cmd = process.cwd()+'/ffmpeg/ffmpeg -i ./public/output/animation.mp4 -i ./public/output/palette.png -filter_complex "[0:v][1:v] paletteuse" -preset veryfast -loop 0 ./public/output/animation.gif'; exec(cmd, function(err, stdout, stderr) { if (err) reject(err); else resolve('success'); }); }); };
This file exports a function containing the child_process module to execute an FFMPEG command. This command uses the palette file created in the previous step to generate a GIF file from the MP4 output.
Lastly, make sure that both toPalette and toGif modules are executed only when the MP4 is successfully generated:
var express = require('express'); var router = express.Router(); const { toHTML } = require('../modules/toHTML.js'); router.post('/convert-to-video', async function(req, res) { let data = req.body; await toHTML(data); await toMP4(data); await toPalette(); await toGif(); res.send('success'); });
Application Flow in Nutshell:
To provide better understanding, the following diagram presents the data flow and processes involved in exporting anime.js animations to MP4 and GIF formats on the server-side, utilizing Node.js modules and FFMPEG.
+-------------------------------------------------+ | | | +------------------------+ | | | Client-side animation | | | | and DOM elements | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | index.js | | | | (initiate animation) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | Client-side API | | | | (communicates with | | | | server-side API) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | Server-side toHTML API | | | | (receives animation | | | | and saves on server | | | | as animation.html) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | Server-side toMP4 API | | | | (loads animation.html | | | | and processes to MP4) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | timecut | | | | (converts animation | | | | to MP4 format) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | Server-side toGIF API | | | | utilizing FFMPEG | | | | (converts MP4 to GIF) | | | +------------------------+ | | | | | | | | | | | +------------------------+ | | | Server sends success | | | | message back to client | | | +------------------------+ | | | +-------------------------------------------------+
Conclusion
This article offers a complete walkthrough on how to export client-side anime.js animations to MP4 and GIF formats using a combination of Node.js modules and FFMPEG on the server-side. By following the steps discussed, you can develop dynamic and cutting-edge online animation tools with the capabilities to export to both video and GIF formats.
Download
Resources
- https://www.npmjs.com/package/timecut
- https://ffmpeg.org/ffmpeg.html
- SVG Icon used in the demo is courtesy Rochak Shukla on Freepik