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.
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.js23import Head from "next/head";4import { useCallback, useEffect, useState } from "react";56export default function Home() {7 /**8 * Holds the selected video files9 * @type {[File[],Function]}10 */11 const [files, setFiles] = useState([]);1213 /**14 * Holds the uploading/loading state15 * @type {[boolean,Function]}16 */17 const [loading, setLoading] = useState(false);1819 const [concatenatedVideos, setConcatenatedVideos] = useState([]);2021 const getVideos = useCallback(async () => {22 try {23 const response = await fetch(`/api/videos`, {24 method: "GET",25 });2627 const data = await response.json();2829 if (!response.ok) {30 throw data;31 }3233 setConcatenatedVideos(data.result.resources);34 } catch (error) {35 // TODO: Show error message to user36 console.error(error);37 } finally {38 // setLoading(false);39 }40 }, []);4142 useEffect(() => {43 getVideos();44 }, [getVideos]);4546 const handleFormSubmit = async (e) => {47 e.preventDefault();4849 setLoading(true);50 try {51 // Get the form data52 const formData = new FormData(e.target);5354 // Post the form data to the /api/videos endpoint55 const response = await fetch("/api/videos", {56 method: "POST",57 body: formData,58 });5960 const data = await response.json();6162 if (!response.ok) {63 throw data;64 }6566 e.target[0].value = "";67 setFiles([]);68 getVideos();69 } catch (error) {70 // TODO: Show error message to user71 console.error(error);72 } finally {73 setLoading(false);74 }75 };7677 const handleDeleteResource = async (id) => {78 try {79 setLoading(true);80 const response = await fetch(`/api/videos/?id=${id}`, {81 method: "DELETE",82 });8384 const data = await response.json();8586 if (!response.ok) {87 throw data;88 }8990 getVideos();91 } catch (error) {92 // TODO: Show error message to user93 console.error(error);94 } finally {95 setLoading(false);96 }97 };9899 return (100 <div>101 <Head>102 <title>Join Videos using Cloudinary and Next.js</title>103 <meta104 name="description"105 content="Join Videos using Cloudinary and Next.js"106 />107 <link rel="icon" href="/favicon.ico" />108 </Head>109110 <main>111 <div className="header">112 <h1>Join Videos using Cloudinary and Next.js</h1>113 </div>114 <hr />115116 <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 )}127128 <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 <input135 type="file"136 name="videos"137 id="videos"138 accept=".mp4"139 required140 multiple141 disabled={loading}142 onChange={(e) => {143 setFiles([...e.target.files]);144 }}145 />146 <br />147 <button type="submit" disabled={loading || !files.length}>148 Upload Videos149 </button>150 </form>151152 {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 <button167 disabled={loading}168 onClick={() => {169 handleDeleteResource(video.public_id);170 }}171 >172 Delete Video173 </button>174 </div>175 </div>176 ))}177 </div>178 </main>179 <style jsx>{`180 main {181 background-color: #e5e3ff;182 min-height: 100vh;183 }184185 main div.header {186 text-align: center;187 height: 100px;188 display: flex;189 justify-content: center;190 align-items: center;191 }192193 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 }202203 main form button {204 border: none;205 padding: 20px;206 border-radius: 5px;207 font-weight: bold;208 background-color: #ececec;209 }210211 main form button:hover:not([disabled]) {212 background-color: #b200f8;213 color: #ffffff;214 }215216 main div.loading {217 text-align: center;218 }219220 main div.videos-wrapper {221 display: flex;222 flex-flow: column;223 justify-content: center;224 align-items: center;225 }226227 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 }235236 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.js23// Next.js API route support: https://nextjs.org/docs/api-routes/introduction45// Custom config for our API route6export const config = {7 api: {8 bodyParser: false,9 },10};1112export default async function handler(req, res) {13 switch (req.method) {14 case "GET": {15 try {16 const result = await handleGetRequest();1718 return res.status(200).json({ message: "Success", result });19 } catch (error) {20 return res.status(400).json({ message: "Error", error });21 }22 }2324 case "POST": {25 try {26 const result = await handlePostRequest(req);2728 return res.status(200).json({ message: "Success", result });29 } catch (error) {30 return res.status(400).json({ message: "Error", error });31 }32 }3334 case "DELETE": {35 try {36 const { id } = req.query;3738 if (!id) {39 throw "id param is required";40 }4142 const result = await handleDeleteRequest(id);4344 return res.status(200).json({ message: "Success", result });45 } catch (error) {46 return res.status(400).json({ message: "Error", error });47 }48 }4950 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,
handlePostRequestand
handleDeleteRequest` 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.js23import { 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.js23/**4 *5 * @param {*} req6 * @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 });1112 form.parse(req, (error, fields, files) => {13 if (error) {14 return reject(error);15 }1617 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.js234const handleGetRequest = () => handleGetCloudinaryUploads();56const handlePostRequest = async (req) => {7 // Get the form data using the parseForm function8 const data = await parseForm(req);910 // This will store cloudinary upload results for all videos subsequent to the first11 const uploadedVideos = [];1213 // Upload result for all videos joined together14 let finalVideoUploadResult;1516 // Get the video files and reverse the order17 const videoFiles = data.files.videos.reverse();1819 // Loop through all the uploaded videos20 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 video22 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 together24 const uploadResult = await handleCloudinaryUpload(25 file.path,26 uploadedVideos.map((video) => video.public_id.replaceAll("/", ":"))27 );2829 finalVideoUploadResult = uploadResult;30 } else {31 // Upload video to cloudinary32 const uploadResult = await handleCloudinaryUpload(file.path);3334 // Add upload result to the start of the array of uploaded videos that will be joined together35 uploadedVideos.unshift(uploadResult);36 }37 }3839 return finalVideoUploadResult;40};4142const 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.js2import {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.js2345// Import the v2 api and rename it to cloudinary67import { v2 as cloudinary } from "cloudinary";891011// Initialize the SDK with cloud_name, api_key, and api_secret1213cloudinary.config({1415cloud_name: process.env.CLOUD_NAME,1617api_key: process.env.API_KEY,1819api_secret: process.env.API_SECRET,2021});
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_NAME23API_KEY=YOUR_API_KEY45API_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.js23const FOLDER_NAME = "concatenated-videos/";45export 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 }1718 return resolve(result);19 }20 );21 });22};2324export const handleCloudinaryUpload = (path, concatVideos = []) => {25 let folder = "videos/";2627 // Array to hold Cloudinary transformation options28 const transformation = [];2930 // If concatVideos parameter is not empty, add the videos transformation options to the transformation array31 if (concatVideos.length) {32 folder = FOLDER_NAME;3334 // 720p Resolution35 const width = 1280,36 height = 720;3738 for (const video of concatVideos) {39 transformation.push(40 { height, width, crop: "pad" },41 { flags: "splice", overlay: `video:${video}` }42 );43 }4445 transformation.push(46 { height, width, crop: "pad" },47 { flags: "layer_apply" }48 );49 }5051 // Create and return a new Promise52 return new Promise((resolve, reject) => {53 // Use the sdk to upload media54 cloudinary.uploader.upload(55 path,56 {57 // Folder to store video in58 folder,59 // Type of resource60 resource_type: "auto",61 allowed_formats: ["mp4"],62 transformation,63 },64 (error, result) => {65 if (error) {66 // Reject the promise with an error if any67 return reject(error);68 }6970 // Resolve the promise with a successful result71 return resolve(result);72 }73 );74 });75};7677export 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 }8889 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