Categories
React

How to Build a Simple Video Converter with Node.js

Spread the love

Videos are an incredibly popular medium for sharing information, but in the past, converting videos has always been a problem for people. With FFMPEG, this can now be solved easily.

What is FFMPEG

FFMPEG is a command line video and audio processing program that has many capabilities. It also supports multiple formats for video conversion. The capabilities of FFMPEG include: getting video and audio metadata, changing the resolution of videos, changing audio quality, compressing videos, removing audio from videos, remove video from audio, extract frames from video, change video aspect ratio, and much more.

Developers have done the hard work for us by creating a Node.js wrapper for FFMPEG. The package is called fluent-ffmpeg. It is located at https://github.com/fluent-ffmpeg/node-fluent-ffmpeg. This package allows us to run FFMPEG commands by calling the built-in functions.

In this article, we will build a video converter to change the source video into the format of the user’s choice. We will use the fluent-ffmpeg package for running the conversions. Because the jobs are long-running, we will also create a job queue so that it will run in the background.

We will use Express for the back end and React for the front end.

Back End

To get started, create a project directory and a backend folder inside it. In the backend folder, run npx express-generator to generate the files for the Express framework.

Next run npm i in the backend folder to download the packages in package.json.

Then we have to install our own packages. We need Babel to use import in our app. Also, we will use the Bull package for background jobs, CORS package for cross domain requests with the front end, fluent-ffmpeg for converting videos, Multer for file upload, Dotenv for managing environment variables, Sequelize for ORM, and SQLite3 for or database.

Run npm i @babel/cli @babel/core @babel/node @babel/preset-env bull cors dotenv fluent-ffmpeg multer sequelize sqlite3 to install all the packages.

Next add the .babelrc file to the backend folder:

{
    "presets": [
        "@babel/preset-env"
    ]
}

This enables the latest JavaScript features.

In the scripts section of package.json, replace the existing code with:

"start": "nodemon --exec npm run babel-node --  ./bin/www",
"babel-node": "babel-node"

This runs our program with Babel instead of the regular Node runtime.

Next, we create the Sequelize code by running npx sequelize-cli init which creates a config.json file.

Then in config.json, we replace the existing code with:

{
  "development": {
    "dialect": "sqlite",
    "storage": "development.db"
  },
  "test": {
    "dialect": "sqlite",
    "storage": "test.db"
  },
  "production": {
    "dialect": "sqlite",
    "storage": "production.db"
  }
}

Next we need to create our model and migration. We run:

npx sequelize-cli --name VideoConversion --attributes filePath:string,convertedFilePath:string,outputFormat:string,status:enum

To create the model and migration for the VideoConversions table.

In the newly created migration file, replace the existing code with:

"use strict";
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable("VideoConversions", {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      filePath: {
        type: Sequelize.STRING
      },
      convertedFilePath: {
        type: Sequelize.STRING
      },
      outputFormat: {
        type: Sequelize.STRING
      },
      status: {
        type: Sequelize.ENUM,
        values: ["pending", "done", "cancelled"],
        defaultValue: "pending"
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable("VideoConversions");
  }
};

This adds the constants for our enum.

In models/videoconversion.js, replace the existing code with:

"use strict";
module.exports = (sequelize, DataTypes) => {
  const VideoConversion = sequelize.define(
    "VideoConversion",
    {
      filePath: DataTypes.STRING,
      convertedFilePath: DataTypes.STRING,
      outputFormat: DataTypes.STRING,
      status: {
        type: DataTypes.ENUM("pending", "done", "cancelled"),
        defaultValue: "pending"
      }
    },
    {}
  );
  VideoConversion.associate = function(models) {
    // associations can be defined here
  };
  return VideoConversion;
};

This add the enum constants to the model.

Next run npx sequelize-init db:migrate to create our database.

Then create a files folder in the backend directory for storing the files.

Next we create our video processing job queue. Create a queues folder and inside it, create videoQueue.js file and add:

const Queue = require("bull");
const videoQueue = new Queue("video transcoding");
const models = require("../models");
var ffmpeg = require("fluent-ffmpeg");
const fs = require("fs");

const convertVideo = (path, format) => {
  const fileName = path.replace(/.[^/.]+$/, "");
  const convertedFilePath = `${fileName}_${+new Date()}.${format}`;
  return new Promise((resolve, reject) => {
    ffmpeg(`${__dirname}/../files/${path}`)
      .setFfmpegPath(process.env.FFMPEG_PATH)
      .setFfprobePath(process.env.FFPROBE_PATH)
      .toFormat(format)
      .on("start", commandLine => {
        console.log(`Spawned Ffmpeg with command: ${commandLine}`);
      })
      .on("error", (err, stdout, stderr) => {
        console.log(err, stdout, stderr);
        reject(err);
      })
      .on("end", (stdout, stderr) => {
        console.log(stdout, stderr);
        resolve({ convertedFilePath });
      })
      .saveToFile(`${__dirname}/../files/${convertedFilePath}`);
  });
};

videoQueue.process(async job => {
  const { id, path, outputFormat } = job.data;
  try {
    const conversions = await models.VideoConversion.findAll({ where: { id } });
    const conv = conversions[0];
    if (conv.status == "cancelled") {
      return Promise.resolve();
    }
    const pathObj = await convertVideo(path, outputFormat);
    const convertedFilePath = pathObj.convertedFilePath;
    const conversion = await models.VideoConversion.update(
      { convertedFilePath, status: "done" },
      {
        where: { id }
      }
    );
    Promise.resolve(conversion);
  } catch (error) {
    Promise.reject(error);
  }
});

export { videoQueue };

In the convertVideo function, we use fluent-ffmpeg to get the video file, then set the FFMPEG and FFProbe paths from the environment variables. Then we call toFormat to convert it to the format that the user wants. We add a log statement in the start, error, and end handlers to see the outputs and resolve our promise on the end event. When the conversion is complete, we save it to a new file.

videoQueue is a Bull queue that processes jobs in the background sequentially. Redis is required to run the queue, we will need a Ubuntu Linux installation. We run the following commands in Ubuntu to install and run Redis:

$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install redis-server
$ redis-server

In the callback of the videoQueue.process function, we call the convertVideo function and update the path of the converted file and the status of the given job when the job is done.

Next we create our routes. Create a conversions.js file in the routes folder and add:

var express = require("express");
var router = express.Router();
const models = require("../models");
var multer = require("multer");
const fs = require("fs").promises;
const path = require("path");
import { videoQueue } from "../queues/videoQueue";
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./files");
  },
  filename: (req, file, cb) => {
    cb(null, `${+new Date()}_${file.originalname}`);
  }
});
const upload = multer({ storage });

router.get("/", async (req, res, next) => {
  const conversions = await models.VideoConversion.findAll();
  res.json(conversions);
});

router.post("/", upload.single("video"), async (req, res, next) => {
  const data = { ...req.body, filePath: req.file.path };
  const conversion = await models.VideoConversion.create(data);
  res.json(conversion);
});

router.delete("/:id", async (req, res, next) => {
  const id = req.params.id;
  const conversions = await models.VideoConversion.findAll({ where: { id } });
  const conversion = conversions[0];
  try {
    await fs.unlink(`${__dirname}/../${conversion.filePath}`);
    if (conversion.convertedFilePath) {
      await fs.unlink(`${__dirname}/../files/${conversion.convertedFilePath}`);
    }
  } catch (error) {
  } finally {
    await models.VideoConversion.destroy({ where: { id } });
    res.json({});
  }
});

router.put("/cancel/:id", async (req, res, next) => {
  const id = req.params.id;
  const conversion = await models.VideoConversion.update(
    { status: "cancelled" },
    {
      where: { id }
    }
  );
  res.json(conversion);
});

router.get("/start/:id", async (req, res, next) => {
  const id = req.params.id;
  const conversions = await models.VideoConversion.findAll({ where: { id } });
  const conversion = conversions[0];
  const outputFormat = conversion.outputFormat;
  const filePath = path.basename(conversion.filePath);
  await videoQueue.add({ id, path: filePath, outputFormat });
  res.json({});
});

module.exports = router;

In the POST / route, we accept the file upload with the Multer package. We add the job and save the file to the files folder that we created before. We save it with the file’s original name in the filename function in the object we passed into the diskStorage function and specified the file be saved in the files folder in the destination function.

The GET route / route gets the jobs added. DELETE / deletes the job with the given ID along with the original and converted file of the job. PUT /cancel/:id route sets status to cancelled .

The GET /start/:id route adds the job with the given ID to the queue we created earlier.

In app.js, we replace the existing code with:

require("dotenv").config();
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");

var indexRouter = require("./routes/index");
var conversionsRouter = require("./routes/conversions");

var app = express();

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "files")));
app.use(cors());

app.use("/", indexRouter);
app.use("/conversions", conversionsRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

// render the error page
  res.status(err.status || 500);
  res.render("error");
});

module.exports = app;

This adds the CORS add-on to enable cross domain communication, exposes the files folder to the public, and exposes the conversions routes we created earlier to the public.

To add the environment variables, create an .env file in the backend folder and add:

FFMPEG_PATH='c:ffmpegbinffmpeg.exe'
FFPROBE_PATH='c:ffmpegbinffprobe.exe'

Front End

With back end done, we can move on to the front end. In the project’s root folder, run npx create-react-app frontend to create the front end files.

Next we install some packages. We need Axios for making HTTP requests, Formik for form value handling, MobX for state management, React Router for routing URLs to our pages, and Bootstrap for styling.

Run npm i axios bootstrap formik mobx mobx-react react-bootstrap react-router-dom to install the packages.

Next we replace the existing code in App.js with:

import React from "react";
import { Router, Route } from "react-router-dom";
import "./App.css";
import { createBrowserHistory as createHistory } from "history";
import HomePage from "./HomePage";
import { ConversionsStore } from "./store";
import TopBar from "./TopBar";
const conversionsStore = new ConversionsStore();
const history = createHistory();

function App() {
  return (
    <div className="App">
      <TopBar />
      <Router history={history}>
        <Route
          path="/"
          exact
          component={props => (
            <HomePage {...props} conversionsStore={conversionsStore} />
          )}
        />
      </Router>
    </div>
  );
}

export default App;

We add the top bar and the routes in this file.

In App.css, we replace the existing code with:

.page {
  padding: 20px;
}

.button {
  margin-right: 10px;
}

Next create HomePage.js in the src folder and add:

import React from "react";
import Table from "react-bootstrap/Table";
import Button from "react-bootstrap/Button";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import { observer } from "mobx-react";
import {
  getJobs,
  addJob,
  deleteJob,
  cancel,
  startJob,
  APIURL
} from "./request";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";

function HomePage({ conversionsStore }) {
  const fileRef = React.createRef();
  const [file, setFile] = React.useState(null);
  const [fileName, setFileName] = React.useState("");
  const [initialized, setInitialized] = React.useState(false);

  const onChange = event => {
    setFile(event.target.files[0]);
    setFileName(event.target.files[0].name);
  };

  const openFileDialog = () => {
    fileRef.current.click();
  };

  const handleSubmit = async evt => {
    if (!file) {
      return;
    }
    let bodyFormData = new FormData();
    bodyFormData.set("outputFormat", evt.outputFormat);
    bodyFormData.append("video", file);
    await addJob(bodyFormData);
    getConversionJobs();
  };

  const getConversionJobs = async () => {
    const response = await getJobs();
    conversionsStore.setConversions(response.data);
  };

  const deleteConversionJob = async id => {
    await deleteJob(id);
    getConversionJobs();
  };

  const cancelConversionJob = async id => {
    await cancel(id);
    getConversionJobs();
  };

  const startConversionJob = async id => {
    await startJob(id);
    getConversionJobs();
  };

  React.useEffect(() => {
    if (!initialized) {
      getConversionJobs();
      setInitialized(true);
    }
  });

  return (
    <div className="page">
      <h1 className="text-center">Convert Video</h1>
      <Formik onSubmit={handleSubmit} initialValues={{ outputFormat: "mp4" }}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group
                as={Col}
                md="12"
                controlId="outputFormat"
                defaultValue="mp4"
              >
                <Form.Label>Output Format</Form.Label>
                <Form.Control
                  as="select"
                  value={values.outputFormat || "mp4"}
                  onChange={handleChange}
                  isInvalid={touched.outputFormat && errors.outputFormat}
                >
                  <option value="mov">mov</option>
                  <option value="webm">webm</option>
                  <option value="mp4">mp4</option>
                  <option value="mpeg">mpeg</option>
                  <option value="3gp">3gp</option>
                </Form.Control>
                <Form.Control.Feedback type="invalid">
                  {errors.outputFormat}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>

            <Form.Row>
              <Form.Group as={Col} md="12" controlId="video">
                <input
                  type="file"
                  style={{ display: "none" }}
                  ref={fileRef}
                  onChange={onChange}
                  name="video"
                />
                <ButtonToolbar>
                  <Button
                    className="button"
                    onClick={openFileDialog}
                    type="button"
                  >
                    Upload
                  </Button>
                  <span>{fileName}</span>
                </ButtonToolbar>
              </Form.Group>
            </Form.Row>

            <Button type="submit">Add Job</Button>
          </Form>
        )}
      </Formik>

      <br />
      <Table>
        <thead>
          <tr>
            <th>File Name</th>
            <th>Converted File</th>
            <th>Output Format</th>
            <th>Status</th>
            <th>Start</th>
            <th>Cancel</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          {conversionsStore.conversions.map(c => {
            return (
              <tr>
                <td>{c.filePath}</td>
                <td>{c.status}</td>
                <td>{c.outputFormat}</td>
                <td>
                  {c.convertedFilePath ? (
                    <a href={`${APIURL}/${c.convertedFilePath}`}>Open</a>
                  ) : (
                    "Not Available"
                  )}
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={startConversionJob.bind(this, c.id)}
                  >
                    Start
                  </Button>
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={cancelConversionJob.bind(this, c.id)}
                  >
                    Cancel
                  </Button>
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={deleteConversionJob.bind(this, c.id)}
                  >
                    Delete
                  </Button>
                </td>
              </tr>
            );
          })}
        </tbody>
      </Table>
    </div>
  );
}

export default observer(HomePage);

This is the home page of our app. We have a dropdown for selecting the format of the file, an upload button for select the file for conversion, and a table for displaying the video conversion jobs with the status and file names of the source and converted files.

We also have buttons to start, cancel, and delete each job.

To add file upload, we have a hidden file input and in the onChange handler of the file input. The Upload button’s onClick handler will click the file input to open the upload file dialog.

We get latest jobs by calling getConversionJobs when we first load the page, and also when we start, cancel, and delete jobs. The job data is stored in the MobX store that we will create later. We wrap observer in our HomePage in the last line to always get the latest values from the store.

Next create request.js and the src folder and add:

const axios = require("axios");
export const APIURL = "http://localhost:3000";

export const getJobs = () => axios.get(`${APIURL}/conversions`);

export const addJob = data =>
  axios({
    method: "post",
    url: `${APIURL}/conversions`,
    data,
    config: { headers: { "Content-Type": "multipart/form-data" } }
  });

export const cancel = id => axios.put(`${APIURL}/conversions/cancel/${id}`, {});

export const deleteJob = id =>
  axios.delete(`${APIURL}/conversions/${id}`);

export const startJob = id => axios.get(`${APIURL}/conversions/start/${id}`);

The HTTP requests that we make to the back end are all here. They were used on the HomePage .

Next create the MobX store by creating the store.js file in the src folder. In there add:

import { observable, action, decorate } from "mobx";

class ConversionsStore {
  conversions = [];

  setConversions(conversions) {
    this.conversions = conversions;
  }
}

ConversionsStore = decorate(ConversionsStore, {
  conversions: observable,
  setConversions: action
});

export { ConversionsStore };

This is a simple store that persists the contacts, and the conversions array is where we store the contacts for the whole app. The setConversions function lets us set contacts from any component where we pass in this store object to.

Next create TopBar.js in the src folder and add:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";

function TopBar() {
  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Video Converter</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/">Home</Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}

export default TopBar;

This contains the React Bootstrap Navbar to show a top bar with a link to the home page and the name of the app.

In index.html, we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Video Converter</title>
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

This adds the Bootstrap CSS and changes the title.

After writing all that code, we can run our app. Before running anything, install nodemon by running npm i -g nodemon so that we don’t have to restart the back end ourselves when files change.

Then run back end by running npm start in the backend folder and npm start in the frontend folder, then choose ‘yes’ if you’re asked to run it from a different port.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *