Combine multiple videos snippets into one video

Eugene Musebe

Introduction

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.

Codesandbox

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

Github

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.

Prerequisites

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 account and credentials

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.

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

Getting started

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.

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

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.

1cd join-videos-with-cloudinary

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

1code .

Select videos to upload

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

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

1// pages/index.js
2
3import Head from "next/head";
4import { useCallback, useEffect, useState } from "react";
5
6export default function Home() {
7 /**
8 * Holds the selected video files
9 * @type {[File[],Function]}
10 */
11 const [files, setFiles] = useState([]);
12
13 /**
14 * Holds the uploading/loading state
15 * @type {[boolean,Function]}
16 */
17 const [loading, setLoading] = useState(false);
18
19 const [concatenatedVideos, setConcatenatedVideos] = useState([]);
20
21 const getVideos = useCallback(async () => {
22 try {
23 const response = await fetch(`/api/videos`, {
24 method: "GET",
25 });
26
27 const data = await response.json();
28
29 if (!response.ok) {
30 throw data;
31 }
32
33 setConcatenatedVideos(data.result.resources);
34 } catch (error) {
35 // TODO: Show error message to user
36 console.error(error);
37 } finally {
38 // setLoading(false);
39 }
40 }, []);
41
42 useEffect(() => {
43 getVideos();
44 }, [getVideos]);
45
46 const handleFormSubmit = async (e) => {
47 e.preventDefault();
48
49 setLoading(true);
50 try {
51 // Get the form data
52 const formData = new FormData(e.target);
53
54 // Post the form data to the /api/videos endpoint
55 const response = await fetch("/api/videos", {
56 method: "POST",
57 body: formData,
58 });
59
60 const data = await response.json();
61
62 if (!response.ok) {
63 throw data;
64 }
65
66 e.target[0].value = "";
67 setFiles([]);
68 getVideos();
69 } catch (error) {
70 // TODO: Show error message to user
71 console.error(error);
72 } finally {
73 setLoading(false);
74 }
75 };
76
77 const handleDeleteResource = async (id) => {
78 try {
79 setLoading(true);
80 const response = await fetch(`/api/videos/?id=${id}`, {
81 method: "DELETE",
82 });
83
84 const data = await response.json();
85
86 if (!response.ok) {
87 throw data;
88 }
89
90 getVideos();
91 } catch (error) {
92 // TODO: Show error message to user
93 console.error(error);
94 } finally {
95 setLoading(false);
96 }
97 };
98
99 return (
100 <div>
101 <Head>
102 <title>Join Videos using Cloudinary and Next.js</title>
103 <meta
104 name="description"
105 content="Join Videos using Cloudinary and Next.js"
106 />
107 <link rel="icon" href="/favicon.ico" />
108 </Head>
109
110 <main>
111 <div className="header">
112 <h1>Join Videos using Cloudinary and Next.js</h1>
113 </div>
114 <hr />
115
116 <form className="upload" onSubmit={handleFormSubmit}>
117 {files.length > 0 && (
118 <ul>
119 <b>{files.length} Selected files</b>
120 {files.map((file, index) => (
121 <li key={`file${index}`}>
122 <p>{file.name}</p>
123 </li>
124 ))}
125 </ul>
126 )}
127
128 <label htmlFor="videos">
129 <p>
130 <b>Select Videos in the order you would like them joined</b>
131 </p>
132 </label>
133 <br />
134 <input
135 type="file"
136 name="videos"
137 id="videos"
138 accept=".mp4"
139 required
140 multiple
141 disabled={loading}
142 onChange={(e) => {
143 setFiles([...e.target.files]);
144 }}
145 />
146 <br />
147 <button type="submit" disabled={loading || !files.length}>
148 Upload Videos
149 </button>
150 </form>
151
152 {loading && (
153 <div className="loading">
154 <hr />
155 <p>Please be patient as the action is performed...</p>
156 <hr />
157 </div>
158 )}
159 <hr />
160 <div className="videos-wrapper">
161 <h2>Concatenated Videos</h2>
162 {concatenatedVideos.map((video, index) => (
163 <div className="video-wrapper" key={`video${index}`}>
164 <video src={video.secure_url} controls></video>
165 <div className="controls">
166 <button
167 disabled={loading}
168 onClick={() => {
169 handleDeleteResource(video.public_id);
170 }}
171 >
172 Delete Video
173 </button>
174 </div>
175 </div>
176 ))}
177 </div>
178 </main>
179 <style jsx>{`
180 main {
181 background-color: #e5e3ff;
182 min-height: 100vh;
183 }
184
185 main div.header {
186 text-align: center;
187 height: 100px;
188 display: flex;
189 justify-content: center;
190 align-items: center;
191 }
192
193 main form {
194 display: flex;
195 flex-flow: column;
196 background-color: #ffffff;
197 max-width: 600px;
198 margin: auto;
199 padding: 20px;
200 border-radius: 5px;
201 }
202
203 main form button {
204 border: none;
205 padding: 20px;
206 border-radius: 5px;
207 font-weight: bold;
208 background-color: #ececec;
209 }
210
211 main form button:hover:not([disabled]) {
212 background-color: #b200f8;
213 color: #ffffff;
214 }
215
216 main div.loading {
217 text-align: center;
218 }
219
220 main div.videos-wrapper {
221 display: flex;
222 flex-flow: column;
223 justify-content: center;
224 align-items: center;
225 }
226
227 main div.videos-wrapper div.video-wrapper {
228 max-width: 1000px;
229 display: flex;
230 flex-flow: column;
231 justify-content: center;
232 align-items: center;
233 margin: 10px auto;
234 }
235
236 main div.videos-wrapper div.video-wrapper video {
237 width: 100%;
238 }
239 `}</style>
240 </div>
241 );
242}

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.

Handle file upload

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.

1// pages/api/videos.js
2
3// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
4
5// Custom config for our API route
6export const config = {
7 api: {
8 bodyParser: false,
9 },
10};
11
12export default async function handler(req, res) {
13 switch (req.method) {
14 case "GET": {
15 try {
16 const result = await handleGetRequest();
17
18 return res.status(200).json({ message: "Success", result });
19 } catch (error) {
20 return res.status(400).json({ message: "Error", error });
21 }
22 }
23
24 case "POST": {
25 try {
26 const result = await handlePostRequest(req);
27
28 return res.status(200).json({ message: "Success", result });
29 } catch (error) {
30 return res.status(400).json({ message: "Error", error });
31 }
32 }
33
34 case "DELETE": {
35 try {
36 const { id } = req.query;
37
38 if (!id) {
39 throw "id param is required";
40 }
41
42 const result = await handleDeleteRequest(id);
43
44 return res.status(200).json({ message: "Success", result });
45 } catch (error) {
46 return res.status(400).json({ message: "Error", error });
47 }
48 }
49
50 default: {
51 return res.status(405).json({ message: "Method not allowed" });
52 }
53 }
54}

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.

1npm install --save formidable

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

1// pages/api/video.js
2
3import { IncomingForm, Fields, Files } from "formidable";

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

1// pages/api/video.js
2
3/**
4 *
5 * @param {*} req
6 * @returns {Promise<{ fields:Fields; files:Files; }>}
7 */
8const parseForm = (req) => {
9 return new Promise((resolve, reject) => {
10 const form = new IncomingForm({ keepExtensions: true, multiples: true });
11
12 form.parse(req, (error, fields, files) => {
13 if (error) {
14 return reject(error);
15 }
16
17 return resolve({ fields, files });
18 });
19 });
20};

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

1// pages/api/video.js
2
3
4const handleGetRequest = () => handleGetCloudinaryUploads();
5
6const handlePostRequest = async (req) => {
7 // Get the form data using the parseForm function
8 const data = await parseForm(req);
9
10 // This will store cloudinary upload results for all videos subsequent to the first
11 const uploadedVideos = [];
12
13 // Upload result for all videos joined together
14 let finalVideoUploadResult;
15
16 // Get the video files and reverse the order
17 const videoFiles = data.files.videos.reverse();
18
19 // Loop through all the uploaded videos
20 for (const [index, file] of videoFiles.entries()) {
21 // Check if it's the last video. In the end result this will actually be the first video
22 if (index === data.files.videos.length - 1) {
23 // Upload the video to cloudinary, passing an array of public ids for the videos that will be joined together
24 const uploadResult = await handleCloudinaryUpload(
25 file.path,
26 uploadedVideos.map((video) => video.public_id.replaceAll("/", ":"))
27 );
28
29 finalVideoUploadResult = uploadResult;
30 } else {
31 // Upload video to cloudinary
32 const uploadResult = await handleCloudinaryUpload(file.path);
33
34 // Add upload result to the start of the array of uploaded videos that will be joined together
35 uploadedVideos.unshift(uploadResult);
36 }
37 }
38
39 return finalVideoUploadResult;
40};
41
42const handleDeleteRequest = async (id) => handleCloudinaryDelete([id]);

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

1// pages/api/videos.js
2import {
3 handleCloudinaryDelete,
4 handleCloudinaryUpload,
5 handleGetCloudinaryUploads,
6} from "../../lib/cloudinary";

Handle upload to cloudinary and concatenation

We need to first install the Cloudinary SDK.

1npm 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.

1// lib/cloudinary.js
2
3
4
5// Import the v2 api and rename it to cloudinary
6
7import { v2 as cloudinary } from "cloudinary";
8
9
10
11// Initialize the SDK with cloud_name, api_key, and api_secret
12
13cloudinary.config({
14
15cloud_name: process.env.CLOUD_NAME,
16
17api_key: process.env.API_KEY,
18
19api_secret: process.env.API_SECRET,
20
21});

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.

1CLOUD_NAME=YOUR_CLOUD_NAME
2
3API_KEY=YOUR_API_KEY
4
5API_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

1// lib/cloudinary.js
2
3const FOLDER_NAME = "concatenated-videos/";
4
5export const handleGetCloudinaryUploads = () => {
6 return new Promise((resolve, reject) => {
7 cloudinary.api.resources(
8 {
9 type: "upload",
10 prefix: FOLDER_NAME,
11 resource_type: "video",
12 },
13 (error, result) => {
14 if (error) {
15 return reject(error);
16 }
17
18 return resolve(result);
19 }
20 );
21 });
22};
23
24export const handleCloudinaryUpload = (path, concatVideos = []) => {
25 let folder = "videos/";
26
27 // Array to hold Cloudinary transformation options
28 const transformation = [];
29
30 // If concatVideos parameter is not empty, add the videos transformation options to the transformation array
31 if (concatVideos.length) {
32 folder = FOLDER_NAME;
33
34 // 720p Resolution
35 const width = 1280,
36 height = 720;
37
38 for (const video of concatVideos) {
39 transformation.push(
40 { height, width, crop: "pad" },
41 { flags: "splice", overlay: `video:${video}` }
42 );
43 }
44
45 transformation.push(
46 { height, width, crop: "pad" },
47 { flags: "layer_apply" }
48 );
49 }
50
51 // Create and return a new Promise
52 return new Promise((resolve, reject) => {
53 // Use the sdk to upload media
54 cloudinary.uploader.upload(
55 path,
56 {
57 // Folder to store video in
58 folder,
59 // Type of resource
60 resource_type: "auto",
61 allowed_formats: ["mp4"],
62 transformation,
63 },
64 (error, result) => {
65 if (error) {
66 // Reject the promise with an error if any
67 return reject(error);
68 }
69
70 // Resolve the promise with a successful result
71 return resolve(result);
72 }
73 );
74 });
75};
76
77export const handleCloudinaryDelete = async (ids) => {
78 return new Promise((resolve, reject) => {
79 cloudinary.api.delete_resources(
80 ids,
81 {
82 resource_type: "video",
83 },
84 (error, result) => {
85 if (error) {
86 return reject(error);
87 }
88
89 return resolve(result);
90 }
91 );
92 });
93};

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.

Finalizing

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

1npm run dev

Further Reading

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.