Skip to content

Combine multiple videos snippets into one video

Automation is key to maximizing your productivity. It could save you minutes of work. In this tutorial, we shall be looking at how we can join/concatenate videos using Cloudinary’s transformation API.

The completed project is available on Codesandbox.

As per the codesandbox FAQ’s you can only upload files with a maximum limit of 2 MBs. Hence we can only upload two files of 1 MB Each. You can use the following two videos for testing purposes.

Sample video 1

Sample video 2

To be able to concatenate videos of any size you can clone the full source code on my Github. Download it and follow the steps below to run it on your local machine.

This tutorial assumes that you have Node.js along with NPM or Yarn installed. You can find instructions on how to install for your development environment in the official documentation. You can also check out Node version manager. In addition, basic knowledge of Javascript, Node.js, and React is required. Familiarity with Next.js is encouraged but not required.

Cloudinary is an amazing service that offers a wide range of solutions for media storage and optimization. We can leverage their API to solve a number of problems. Today we shall be utilizing their upload and transformations API. We need some credentials to communicate with the API. It’s easy to get up and running for free.

Create a new account if you don’t have one and sign into Cloudinary. Navigate to your account’s console page. You should be able to find your API credentials in the top left corner.

Cloudinary Dashboard

Take note of your Cloud name API Key and API Secret. We’ll come back to these.

We need to scaffold a new Next.js project. This is fairly easy to do. Fire up your terminal, navigate to an appropriate project folder and run the following command.


npx create-next-app join-videos-with-cloudinary

Code language: JavaScript (javascript)

This command scaffolds a new project called join-videos-with-cloudinary. You can use whatever name you like. You now have a basic Next.js app. In your terminal switch to the new folder and open it in your favorite code editor.


cd join-videos-with-cloudinary

Code language: JavaScript (javascript)

If you are using Visual Studio Code, you can open the project by running the following


code .

Open pages/index.js and replace the code inside with the following.

Open pages/index.js and replace the code inside with the following.

// pages/index.js

import Head from "next/head";
import { useCallback, useEffect, useState } from "react";

export default function Home() {
  /**
   * Holds the selected video files
   * @type {[File[],Function]}
   */
  const [files, setFiles] = useState([]);

  /**
   * Holds the uploading/loading state
   *  @type {[boolean,Function]}
   */
  const [loading, setLoading] = useState(false);

  const [concatenatedVideos, setConcatenatedVideos] = useState([]);

  const getVideos = useCallback(async () => {
    try {
      const response = await fetch(`/api/videos`, {
        method: "GET",
      });

      const data = await response.json();

      if (!response.ok) {
        throw data;
      }

      setConcatenatedVideos(data.result.resources);
    } catch (error) {
      // TODO: Show error message to user
      console.error(error);
    } finally {
      // setLoading(false);
    }
  }, []);

  useEffect(() => {
    getVideos();
  }, [getVideos]);

  const handleFormSubmit = async (e) => {
    e.preventDefault();

    setLoading(true);
    try {
      // Get the form data
      const formData = new FormData(e.target);

      // Post the form data to the /api/videos endpoint
      const response = await fetch("/api/videos", {
        method: "POST",
        body: formData,
      });

      const data = await response.json();

      if (!response.ok) {
        throw data;
      }

      e.target[0].value = "";
      setFiles([]);
      getVideos();
    } catch (error) {
      // TODO: Show error message to user
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  const handleDeleteResource = async (id) => {
    try {
      setLoading(true);
      const response = await fetch(`/api/videos/?id=${id}`, {
        method: "DELETE",
      });

      const data = await response.json();

      if (!response.ok) {
        throw data;
      }

      getVideos();
    } catch (error) {
      // TODO: Show error message to user
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <Head>
        <title>Join Videos using Cloudinary and Next.js</title>
        <meta
          name="description"
          content="Join Videos using Cloudinary and Next.js"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <div className="header">
          <h1>Join Videos using Cloudinary and Next.js</h1>
        </div>
        <hr />

        <form className="upload" onSubmit={handleFormSubmit}>
          {files.length > 0 && (
            <ul>
              <b>{files.length} Selected files</b>
              {files.map((file, index) => (
                <li key={`file${index}`}>
                  <p>{file.name}</p>
                </li>
              ))}
            </ul>
          )}

          <label htmlFor="videos">
            <p>
              <b>Select Videos in the order you would like them joined</b>
            </p>
          </label>
          <br />
          <input
            type="file"
            name="videos"
            id="videos"
            accept=".mp4"
            required
            multiple
            disabled={loading}
            onChange={(e) => {
              setFiles([...e.target.files]);
            }}
          />
          <br />
          <button type="submit" disabled={loading || !files.length}>
            Upload Videos
          </button>
        </form>

        {loading && (
          <div className="loading">
            <hr />
            <p>Please be patient as the action is performed...</p>
            <hr />
          </div>
        )}
        <hr />
        <div className="videos-wrapper">
          <h2>Concatenated Videos</h2>
          {concatenatedVideos.map((video, index) => (
            <div className="video-wrapper" key={`video${index}`}>
              <video src={video.secure_url} controls></video>
              <div className="controls">
                <button
                  disabled={loading}
                  onClick={() => {
                    handleDeleteResource(video.public_id);
                  }}
                >
                  Delete Video
                </button>
              </div>
            </div>
          ))}
        </div>
      </main>
      <style jsx>{`
        main {
          background-color: #e5e3ff;
          min-height: 100vh;
        }

        main div.header {
          text-align: center;
          height: 100px;
          display: flex;
          justify-content: center;
          align-items: center;
        }

        main form {
          display: flex;
          flex-flow: column;
          background-color: #ffffff;
          max-width: 600px;
          margin: auto;
          padding: 20px;
          border-radius: 5px;
        }

        main form button {
          border: none;
          padding: 20px;
          border-radius: 5px;
          font-weight: bold;
          background-color: #ececec;
        }

        main form button:hover:not([disabled]) {
          background-color: #b200f8;
          color: #ffffff;
        }

        main div.loading {
          text-align: center;
        }

        main div.videos-wrapper {
          display: flex;
          flex-flow: column;
          justify-content: center;
          align-items: center;
        }

        main div.videos-wrapper div.video-wrapper {
          max-width: 1000px;
          display: flex;
          flex-flow: column;
          justify-content: center;
          align-items: center;
          margin: 10px auto;
        }

        main div.videos-wrapper div.video-wrapper video {
          width: 100%;
        }
      `}</style>
    </div>
  );
}
Code language: JavaScript (javascript)

Let’s go over this. We have a basic react component. At the top, we have a few state hooks to store the state of selected files, the loading/uploading state, and the concatenated videos. Read more about the useState and other hooks from the official documentation. We then make use of the useCallback hook to store a memoized callback function that will get all the concatenated videos. The function makes a GET request to the /api/videos endpoint and updates the concatenatedVideos state with the result. We then process to make use of the useEffect hook to get concatenated videos when the page is rendered. Read more about the useCallback and useEffect hooks from the hooks API reference. Following that is a function named handleFormSubmit. This function will handle the form submission and post the selected files to the /api/videos endpoint then clear our form and call getVideos to get the updated list. We will create this endpoint in the next section. For the HTML, we just have a form with a file input. The user can select multiple videos in the order in which they would like the videos to be joined. We then have a div that will only show when the loading state is set to true, and another that wraps our video elements and only shows if the concatenatedVideos state is not empty. The video wrapper div also has a delete button to remove the video from cloudinary. The rest is just some css for styling.

Time to create the api/videos endpoint. For this, let’s use Next.js API routes. Create a new file called videos.js under the pages/api folder and paste the following code inside.

// pages/api/videos.js

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction

// Custom config for our API route
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req, res) {
  switch (req.method) {
    case "GET": {
      try {
        const result = await handleGetRequest();

        return res.status(200).json({ message: "Success", result });
      } catch (error) {
        return res.status(400).json({ message: "Error", error });
      }
    }

    case "POST": {
      try {
        const result = await handlePostRequest(req);

        return res.status(200).json({ message: "Success", result });
      } catch (error) {
        return res.status(400).json({ message: "Error", error });
      }
    }

    case "DELETE": {
      try {
        const { id } = req.query;

        if (!id) {
          throw "id param is required";
        }

        const result = await handleDeleteRequest(id);

        return res.status(200).json({ message: "Success", result });
      } catch (error) {
        return res.status(400).json({ message: "Error", error });
      }
    }

    default: {
      return res.status(405).json({ message: "Method not allowed" });
    }
  }
}
Code language: JavaScript (javascript)

This is a basic API route. We export a custom config object that instructs the route not to use the default body-parser. This is because the content type we’re expecting here is not application/json but rather multipart/form-data. We're then using a switch statement to only handle POST requests. You'll quickly notice that we're missing the handleGetRequest,handlePostRequestandhandleDeleteRequest` functions. Before we create them, let’s install a dependency first.

We’re going to be using a package called Formidable to handle the form data parsing so that we can get the uploaded files.


npm install --save formidable

Add the following import at the top of pages/api/videos.js


// pages/api/video.js

import  {  IncomingForm,  Fields,  Files  }  from  "formidable";

Code language: JavaScript (javascript)

Next, we need to define a function that will handle the form parsing. Add the following to pages/api/videos.js

// pages/api/video.js

/**
 *
 * @param {*} req
 * @returns {Promise<{ fields:Fields; files:Files; }>}
 */
const parseForm = (req) => {
  return new Promise((resolve, reject) => {
    const form = new IncomingForm({ keepExtensions: true, multiples: true });

    form.parse(req, (error, fields, files) => {
      if (error) {
        return reject(error);
      }

      return resolve({ fields, files });
    });
  });
};

Code language: JavaScript (javascript)

We’re now ready to define handlePostRequest. Add the following to pages/api/videos.js

// pages/api/video.js


const handleGetRequest = () => handleGetCloudinaryUploads();

const handlePostRequest = async (req) => {
  // Get the form data using the parseForm function
  const data = await parseForm(req);

  // This will store cloudinary upload results for all videos subsequent to the first
  const uploadedVideos = [];

  // Upload result for all videos joined together
  let finalVideoUploadResult;

  // Get the video files and reverse the order
  const videoFiles = data.files.videos.reverse();

  // Loop through all the uploaded videos
  for (const [index, file] of videoFiles.entries()) {
    // Check if it's the last video. In the end result this will actually be the first video
    if (index === data.files.videos.length - 1) {
      // Upload the video to cloudinary, passing an array of public ids for the videos that will be joined together
      const uploadResult = await handleCloudinaryUpload(
        file.path,
        uploadedVideos.map((video) => video.public_id.replaceAll("/", ":"))
      );

      finalVideoUploadResult = uploadResult;
    } else {
      // Upload video to cloudinary
      const uploadResult = await handleCloudinaryUpload(file.path);

      // Add upload result to the start of the array of uploaded videos that will be joined together
      uploadedVideos.unshift(uploadResult);
    }
  }

  return finalVideoUploadResult;
};

const handleDeleteRequest = async (id) => handleCloudinaryDelete([id]);
Code language: JavaScript (javascript)

In handleGetRequest, we’re calling another function that we haven’t yet defined to get all the uploaded/concatenated videos from cloudinary. In handlePostRequest, we’re parsing the form to get the data. Then we have an array stored in a variable called uploadedVideos and a variable finalVideoUploadResult, that will store the final video upload result. We then get the uploaded video files and reverse the order. The reason for reversing the array is that we want to upload the videos that we will be concatenating, then upload the video that will appear first in the final result. We then use a for-of loop to iterate over the video files getting the file as well as the index. We use the index to check if it’s the last file or not. If it’s not the last file, upload it to cloudinary and push the result to the start of the uploadedVideos array. If it’s the last file, get the public ids for the already uploaded files and along with that, upload the file to cloudinary. Once the last file uploads and the videos have been concatenated we store the upload result in finalVideoUploadResult and return that. In handleDeleteRequest we’re again calling another function that’s not defined to delete videos by passing their public IDs. The only thing missing now is the handleGetCloudinaryUploads,handleCloudinaryUpload and handleCloudinaryDelete functions. Let’s first import those before we define them. Add the following import at the top of pages/api/videos.js

// pages/api/videos.js
import {
  handleCloudinaryDelete,
  handleCloudinaryUpload,
  handleGetCloudinaryUploads,
} from "../../lib/cloudinary";
Code language: JavaScript (javascript)

We need to first install the Cloudinary SDK.


npm install --save cloudinary

Create a folder at the root of your project and call it lib. Inside, create a new file called cloudinary.js and paste the following inside.


// lib/cloudinary.js

  

// Import the v2 api and rename it to cloudinary

import  {  v2  as  cloudinary  }  from  "cloudinary";

  

// Initialize the SDK with cloud_name, api_key, and api_secret

cloudinary.config({

cloud_name:  process.env.CLOUD_NAME,

api_key:  process.env.API_KEY,

api_secret:  process.env.API_SECRET,

});

Code language: JavaScript (javascript)

Nothing much happening here. We import the v2 API and rename it as cloudinary. We then call the config method to initialize the SDK. We pass cloud_name, api_key and api_secret. We’ve used some environment variables here, that we haven’t defined yet. Let’s do that now. Create a file called .env.local at the root of your project. Paste the following inside.


CLOUD_NAME=YOUR_CLOUD_NAME

API_KEY=YOUR_API_KEY

API_SECRET=YOUR_API_SECRET

Make sure to replace YOUR_CLOUD_NAME YOUR_API_KEY and YOUR_API_SECRET with the values we got from the cloudinary-account-and-credentials section above. Read more about environment variables support in Next.js from the official docs.

Moving on, paste the following code inside lib/cloudinary.js

// lib/cloudinary.js

const FOLDER_NAME = "concatenated-videos/";

export const handleGetCloudinaryUploads = () => {
  return new Promise((resolve, reject) => {
    cloudinary.api.resources(
      {
        type: "upload",
        prefix: FOLDER_NAME,
        resource_type: "video",
      },
      (error, result) => {
        if (error) {
          return reject(error);
        }

        return resolve(result);
      }
    );
  });
};

export const handleCloudinaryUpload = (path, concatVideos = []) => {
  let folder = "videos/";

  // Array to hold Cloudinary transformation options
  const transformation = [];

  // If concatVideos parameter is not empty, add the videos transformation options to the transformation array
  if (concatVideos.length) {
    folder = FOLDER_NAME;

    // 720p Resolution
    const width = 1280,
      height = 720;

    for (const video of concatVideos) {
      transformation.push(
        { height, width, crop: "pad" },
        { flags: "splice", overlay: `video:${video}` }
      );
    }

    transformation.push(
      { height, width, crop: "pad" },
      { flags: "layer_apply" }
    );
  }

  // Create and return a new Promise
  return new Promise((resolve, reject) => {
    // Use the sdk to upload media
    cloudinary.uploader.upload(
      path,
      {
        // Folder to store video in
        folder,
        // Type of resource
        resource_type: "auto",
        allowed_formats: ["mp4"],
        transformation,
      },
      (error, result) => {
        if (error) {
          // Reject the promise with an error if any
          return reject(error);
        }

        // Resolve the promise with a successful result
        return resolve(result);
      }
    );
  });
};

export const handleCloudinaryDelete = async (ids) => {
  return new Promise((resolve, reject) => {
    cloudinary.api.delete_resources(
      ids,
      {
        resource_type: "video",
      },
      (error, result) => {
        if (error) {
          return reject(error);
        }

        return resolve(result);
      }
    );
  });
};
Code language: JavaScript (javascript)

Let’s start with handleGetCloudinaryUploads. This function gets all videos that have been uploaded to the concatenated-videos/ folder. Next is handleCloudinaryUpload. This is the function that will handle the upload to cloudinary, and concatenation. The function takes in a path to the video file to upload and also an array of public ids belonging to the videos we want to concatenate. We have an empty transformations array. If the concatVideos parameter is not empty, that means that there are some videos we need to concatenate. The concatenated videos need to be of uniform width and height. For the simplicity of this tutorial, we’ve hard-coded these values to a 720p resolution. For every video that needs to be joined, we push two objects into the transformation array. The important options are height, width, crop,flags and overlay. The height and width are self-explanatory. For the crop, we use the pad value which adds padding to the cropped video so it can fill the remaining space. The splice flag tells cloudinary that we want to concatenate the video at the end. The overlay is just the public id of the video we want to concatenate. Read about these options in-depth here and here. Finally, we call the uploader.upload method and pass the path to the video and a few options. In the options we also pass the transformation array which can either be empty or not, depending on whether we have any videos to concatenate. Here’s some documentation on the options you can pass. We then either resolve or reject a promise. Finally, we have handleCloudinaryDelete. This one takes in an array of videos’ public IDS and deletes them from cloudinary.

That’s all. The app is now ready to test. To run in development mode, run the following in your terminal


npm run dev

Further Reading

Back to top

Featured Post