Alex Standiford

Batch Video Editing With Node.JS

☕️☕️☕️ 16 min read

Over at DesignFrame, one of my clients hosts videos on their own site. In order to ensure that these videos will play correctly on all devices, I have been manually converting these videos using Cloudconvert. It’s a very handy tool, but the process can be tedious when you have a lot of files to deal with, and it doesn’t (at least to my knowledge) handle generating screenshots of your videos for you.

So, in-order to upload the videos to their website, my (admittedly awful) workflow looked something like this:

  1. Take each video, and use cloudconvert to create ogv, webm, and mp4 versions of each video
  2. Open the video and save a screenshot at a good place
  3. Upload each version of each video to their server
  4. Publish the video with the screenshot

This wasn’t too bad, but as a programmer, doing manual, repetitive tasks makes my skin crawl, so I started looking into ways to automate this. I’ve been playing with creating small CLI applications with Node.js using commander lately, and decided that this would be an excellent place to start.

What’s nice about starting with a CLI-based solution is that it allows me to spend most of my time focusing on the back-end instead of building out some kind of interface. If you build correctly, it should be easy to set up what you’ve built with an interface.

Here’s what the script does:

  1. Add 3 commands accessible from my terminal’s command line: run, screenshots, and videos
  2. Take all of the files in a specified directory, and convert the videos to ogv, webm, and mp4
  3. Automatically generate 6 screenshots of each video at different intervals throughout.
  4. Save the results of each video in a converted files directory, with each video title as the sub directory.

The nice thing about setting it up with Node is that, if the conversion job warrants it, you can spin up a cpu-optimized droplet on DigitalOcean, upload the files, and make the conversion quickly, and then destroy the droplet. This is way faster than doing it on your local machine, and since the droplet is usually destroyed in 1–2 hours you’re going to spend very little money to get the job done. This isn’t a requirement, of course; The script runs perfectly fine on a local machine - the conversion will just take longer.

Completed Project Files

You can get the completed project files here.

Project Structure

I set the project up to use 3 files.

  • index.js - The entry point for our program. This is where we configure our CLI commands
  • FileConverter.js - Handles the actual conversion of a single file.
  • MultiFileConverter.js - Gathers up videos from a directory, creates instances of FileConverter, and runs the conversion.

Setting Up Your Project

Here is the resulting package.json file that I’m using for this project:

    {
      "name": "video-converstion-script",
      "version": "1.0.0",
      "description": "Converts Videos",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "bin": {
        "asconvert": "./index.js"
      },
      "author": "",
      "license": "ISC",
      "dependencies": {
        "@ffmpeg-installer/ffmpeg": "^1.0.15",
        "@ffprobe-installer/ffprobe": "^1.0.9",
        "commander": "^2.16.0",
        "fluent-ffmpeg": "^2.1.2",
        "junk": "^2.1.0"
      }
    }

Here’s a list of each dependency and a brief description of their role in this project

  • @ffmpeg-installer/ffmpeg - sets up the binaries needed to convert the videos and create screenshots
  • @ffprobe-installer/ffprobe - sets up the binaries needed to convert the videos and create screenshots
  • commander - Super awesome tool that allows us to build out a CLI from our Node.js application.
  • fluent-ffmpeg - Allows us to interface with ffmpeg using Node
  • junk - A nice little library that makes it easy to filter out junk files from our directory. This will keep us from trying to convert a .DS_Store file, or something like that.

Note that we also have set the bin object. This allows us to associate our CLI command asconvert with our index.js file. You can change asconvert to whatever you want, just keep in mind that you will need to use whatever you call asconvert instead of what I call it in this post.

Place JSON above into your package.json file, and run npm install. Once you do that, you’ll also need to run npm link. This will connect the bin configuration to your terminal so you can run your commands directly from the command line.

Setting up our Index file

Before we can start messing with our system, we need to set up some commander commands. This will allow us to test, debug, and tinker with our javascript from the terminal. We will be adding multiple commands later, but for now, let’s simply add the run command. The code below is a basic example, and should respond with "hello world!' in your terminal.

#!/usr/bin/env node

/**
 * Allows us to run this script as a cli command
 */
const program = require('commander');

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

/**
 The run command
 */
program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{
 console.log('hello world!');
//We will put our actual command here.
 });

program.parse(process.argv);

Once you add this, you should be able to run asconvert run from your terminal and you should get “hello world!” back.  Superkewl!

Set Up the MultiFileConverter Class

Now that we’ve got some simple command line things set up, let’s start working on the good stuff.

Create a new file called MultiFileConverter.js and add the following code.

/**
 * Parses file names
 */
const path = require('path');

/**
 * converts files from a directory
 */
class MultiFileConverter{
 constructor(args = {}){
 //Set the argument object
 const defaults = {
 directory: false,
 formats: false
 };
 this.args = Object.assign(args, defaults);

 //Construct from the args object
 this.formats = this.args.formats;
 this.directory = this.args.directory === false ? `${path.dirname(require.main.filename)}/files-to-convert/` : this.args.directory;
 }
}

module.exports = MultiFileConverter;

This basic setup will allow us to pass an object of arguments to our constructor, which will merge with default arguments and build everything we’ll need to complete the conversions.

Connect The Converter to the CLI

Once you do this, we need to set up our CLI command to use this object. Go back to your index.js file and create an instance of this class, like so.

#!/usr/bin/env node
/**
 * Allows us to run this script as a cli command
 */
const program = require('commander');

const MultiFileConverter = require('./lib/MultiFileConverter');

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

/**
 The run command
 */
program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{
 const converter = new MultiFileConverter();
 console.log(converter);
 });

program.parse(process.argv);

If you run the command now, the converter object should be displayed in the terminal. 

I personally organize my js files inside a lib directory. You can put your files wherever you want, just make sure your include paths are correct.

Get the List of FileConverter objects

The primary purpose of the MultiFileConverter class is to batch-convert files in the directory. In order to do that, we are going to loop through the files in the directory and construct an array of FileConverter objects from each file. We’ll let the FileConverter object handle the actual conversion and other file-specific things.

I like to delay processes that have the potential to be time-consuming until I absolutely need them. That way I can construct the class without going through the time-consuming bits every time. To do this, I often create a getter method, like this:

/**
 * Constructs the files object
 * @returns {*}
 */
getFiles(){
 if(this.files) return this.files;
 this.files = [];
 const files = fs.readdirSync(this.directory, {});
 //Loop through and construct the files from the specified directory
 files.filter(junk.not).forEach((file) =>{
 this.files.push(new FileConverter(this.directory + file, false, this.formats));
 });

 return this.files;
}

You’ll notice the first line checks to see if the class already has a files array set. If it does, it simply returns that array. Otherwise, it goes through and builds this array. This allows us to use getFiles() throughout the class without re-building the array every time.

A lot is happening in this method. Let’s break it down.

  1. Check to see if the files array exists. If it does, it returns the value
  2. Reads the specified directory and returns an array of files
  3. Filters out junk files, and then loops through the filtered array.
  4. Inside the loop, we push a new instance of FileConverter and pass the arguments into the the files array.
  5. Return the files in the object

Update your MultiFileConverter class to include a couple of required libraries, and add the getFiles() class. You should end up with something like this:

/**
 * Node File system
 */
const fs = require('fs');

/**
 * Parses file names
 */
const path = require('path');

/**
 * Allows us to filter out junk files in our results
 */
const junk = require('junk');

/**
 * Handles the actual file conversion of individual files
 * @type {FileConverter}
 */
const FileConverter = require('./FileConverter');

/**
 * converts files from a directory
 */
class MultiFileConverter{
 constructor(args = {}){
 //Set the argument object
 const defaults = {
 directory: false,
 formats: false
 };
 this.args = Object.assign(args, defaults);

 //Construct from the args object
 this.formats = this.args.formats;
 this.directory = this.args.directory === false ? `${path.dirname(require.main.filename)}/files-to-convert/` : this.args.directory;
 }

 /**
 * Constructs the files object
 * @returns {*}
 */
 getFiles(){
 if(this.files) return this.files;
 this.files = [];
 const files = fs.readdirSync(this.directory, {});
 //Loop through and construct the files from the specified directory
 files.filter(junk.not).forEach((file) =>{
 this.files.push(new FileConverter(this.directory + file, false, this.formats));
 });

 return this.files;
 }
}

module.exports = MultiFileConverter;

Set Up the FileConverter Class

Now that we are looping through our files, it’s time to build a basic instance of the FileConverter class so our files array builds properly.

 /**
 * Parses file names
 */
const path = require('path');

/**
 * Node File system
 */
const fs = require('fs');

/**
 * Handles the actual file conversion
 */
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
const ffprobePath = require('@ffprobe-installer/ffprobe').path;
const ffmpeg = require('fluent-ffmpeg');
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
ffmpeg.setFfprobePath(ffprobePath);

/**
 * Converts files and takes screenshots
 */
class FileConverter{

 constructor(inputPath, outputPath = false, formats = false){
 this.formats = formats === false ? ['ogv', 'webm', 'mp4'] : formats.split(',');
 this.file = path.basename(inputPath);
 this.format = path.extname(this.file);
 this.fileName = path.parse(this.file).name;
 this.conversion = ffmpeg(inputPath);
 this.outputPath = outputPath === false ? `${path.dirname(require.main.filename)}/converted-files/${this.fileName}` : `${outputPath}/${this.fileName}`;
 }
}

module.exports = FileConverter;

You’ll notice that we are constructing some useful data related to the file and its impending conversion, but we don’t actually do the conversion step yet. This simply sets the file up. We’ll add the actual conversion in a separate method.

Test It Out

We now have all 3 of our files all set up and connected. We haven’t started the actual conversion process yet, but if we make a change to our command action we can check to make sure everything is working as-expected.

If you haven’t yet, now would be a good time to create 2 directories in the root of your project. converted-files and files-to-convert. Add a few video files in your files-to-convert directory.

Modify your commander action in your index.js file so that it logs the result of the getFiles() method. If all went well, you should get a big ol' array of objects.

#!/usr/bin/env node
/**
 * Allows us to run this script as a cli command
 */
const program = require('commander');

const MultiFileConverter = require('./lib/MultiFileConverter');

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

/**
 The run command
 */
program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{
 const converter = new MultiFileConverter();
 console.log(converter.getFiles());
 });

program.parse(process.argv);

Convert Videos

Whew. All this effort and we haven’t even started converting videos yet. Let’s change that.

Add a new method, called getVideos() to your MultiFileConverter.js file.

/**
 * Loops through and converts files
 */
getVideos(){
 return this.getFiles().forEach(file => file.convert());
}

This iddy biddy method simply loops through our files array and runs the convert method on each FileConverter object. Of course, we have to actually create the convert method on the FileConverter object for this to work, so let’s do that now.

Add a new method, called convert() to your FileConverter.js file.

/**
 * Converts the file into the specified formats
 */
convert(){
 fs.mkdir(this.outputPath,() =>{

 //Loop through file formats
 this.formats.forEach((fileFormat) =>{
 //Check to see if the current file format matches the given file's format
 if(`.${fileFormat}` !== this.format){
 //Start the conversion
 this.conversion.output(`${this.outputPath}/${this.fileName}.${fileFormat}`)
 .on('end', () => console.log(`${this.file} has been converted to a ${fileFormat}`))
 .on('start', () =>{
 console.log(`${this.fileName}.${fileFormat} conversion started`);
 })
 }

 //If the file format matches the file's format, skip it and let us know.
 else{
 console.log(`Skipping ${this.fileName} conversion to ${fileFormat} as this file is already in the ${fileFormat} format.`);
 }
 });

 this.conversion.run();
 });
}

Here’s the real meat and potatoes of the build. A lot is happening here, so let’s break it down.

  1. Creates a directory named after the original video we’re converting. This will hold all files generated for this video.
  2. Loops through each file format specified for this conversion.
  3. In the loop, we check to see if the current file format matches the format of the video we’re converting. If they match, the converter skips that conversion and moves on to the next format. This keeps us from needlessly converting an .mp4 to another .mp4.
  4. If the formats are different, we queue up the converter using the specified format.
  5. Once we’ve looped through all of the formats we’re converting to, we run the actual converter.

Test It Out

We have now set up the actual converter. Let’s see if it works as expected.

Modify your commander action in your index.js file to use the getVideos() method, like so.

#!/usr/bin/env node
/**
 * Allows us to run this script as a cli command
 */
const program = require('commander');

const MultiFileConverter = require('./lib/MultiFileConverter');

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

/**
 The run command
 */
program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{

 });

program.parse(process.argv);

You should see a message for each video, stating that the conversion started for each format. It will also let you know if it skipped one of the conversions, and why. This will take a long time to convert, and since we’re just testing, cancel the command (CTRL+C on a Mac) after about 20 seconds. Check your converted-files directory and see if the video conversion started to run.

Generate Screenshots

Sweet! Now that we have videos converting, let’s get generate some screenshots while we’re at it. The process of adding screenshots is very similar.

Add a new method, called getScreenshots() to your MultiFileConverter.js file.

/**
 * Loops through and generates screenshots
 */
getScreenshots(){
 return this.getFiles().forEach(file => file.getScreenshots());
}

This works just like getVideos(), only it runs getScreenshots method on each FileConverter object instead. Again, we need to create the convert method on the FileConverter object for this to work.

Add a new method, called getScreenshots() to your FileConverter.js file.

/**
 * Creates 6 screenshots taken throughout the video
 */
getScreenshots(){
 this.conversion
 .on('filenames', filenames => console.log(`\n ${this.fileName} Will generate 6 screenshots, ${filenames.join('\n ')}`))
 .on('end', () =>{
 console.log(`\n Screenshots for ${this.fileName} complete.\n`)
 })
 .screenshots({
 count: 6,
 timestamps: [2, 5, '20%', '40%', '60%', '80%'],
 folder: this.outputPath,
 filename: `${this.fileName}-%s.png`
 })

}

This method is a bit simpler than getVideos(). We simply chain the screenshots() method (included in our ffmpeg library) and pass some arguments. Our arguments instruct ffmpeg to create 6 screenshots at 2 seconds, 5 seconds, and at 20%, 40%, 60%, and 80% of the video. Each file is saved inside the same directory as our converted videos are saved.

Test It Out

Let’s make sure that we can generate screenshots.

Modify your commander action in your index.js file to use the getScreenshots() method, like so.

#!/usr/bin/env node
/**
 * Allows us to run this script as a cli command
 */
const program = require('commander');

const MultiFileConverter = require('./lib/MultiFileConverter');

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

/**
 The run command
 */
program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{
const converter = new MultiFileConverter();
return converter.getScreenshots();
 });

program.parse(process.argv);

You should see a message for each video, listing off the screenshots that will be created. This will take a some time to convert, and since we’re just testing, cancel the command (CTRL+C on a Mac) after about 20 seconds. Check your converted-files directory and see if the screenshots started to generate.

Generate Everything

Now that we have a way to generate screenshots and convert our videos, we need to make one more method in our MultiFileConverter.js file. This method will run both the convert() method and the getScreenshots() method.

We are creating a third method to do both of these because it allows us to loop through the files once, instead of twice, and as such is more efficient than running getVideos() and then getScreenshots() separately.

Add this method to your MultiFileConverter class.

/**
 * Runs the complete converter, converting files and getting screenshots
 */
runConverter(){
 return this.getFiles().forEach((file) =>{
 file.convert();
 file.getScreenshots();
 });

Create Commands

Now that we have everything needed, let’s create our 3 commands we talked about earlier - asconvert videos, asconvert screenshots, and asconvert run

/**
 * Sets up the command to run from the cli
 */
program
 .version('0.1.0')
 .description('Convert Video Files From a Directory');

program
 .command('run')
 .description('Converts the files in the files-to-convert directory of this project')
 .action(() =>{
 const converter = new MultiFileConverter();
 return converter.runConverter();
 });

/**
 * Sets up the command to run from the cli
 */
program
 .command('screenshots')
 .description('Gets a screenshot of each video')
 .action(() =>{
 const converter = new MultiFileConverter();
 return converter.getScreenshots();
 });

/**
 * Sets up the command to run from the cli
 */
program
 .command('videos')
 .description('Gets conversions of each video')
 .action(() =>{
 const converter = new MultiFileConverter();
 return converter.getVideos();
 });

program.parse(process.argv);

You can now run any of those 3 commands, and convert videos, create screenshots, or do both at the same time.

Closing Remarks

There are a couple of things that could improve this tool.

  1. I’m sure someone who knows Docker better than I could put it in some kind of container to make this EZPZ to set up/tear down on a server
  2. The directory that houses the videos is a part of the project. With further configuration you could set this up so that the videos are pulled directly from Google Drive, or something like that. I didn’t have a need for that, but it would be pretty slick.

All in all, it was a fun little build, and I’m sure it will save me some time in the future.

If you are using this, I’d love to hear about how it worked for you, and why you needed it. Cheers!