├── .gitignore ├── test ├── assets │ ├── tailor.mp4 │ ├── tailor-5-10.mp4 │ └── tailor-20-25.mp4 └── tailor.test.js ├── index.js ├── package.json ├── lib ├── videomerge.js ├── videoconcat.js └── videocut.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /test/assets/tailor.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleladd/VideoStitch/master/test/assets/tailor.mp4 -------------------------------------------------------------------------------- /test/assets/tailor-5-10.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleladd/VideoStitch/master/test/assets/tailor-5-10.mp4 -------------------------------------------------------------------------------- /test/assets/tailor-20-25.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleladd/VideoStitch/master/test/assets/tailor-20-25.mp4 -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | concat: require('./lib/videoconcat'), 4 | cut: require('./lib/videocut'), 5 | merge: require('./lib/videomerge') 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-stitch", 3 | "version": "1.4.0", 4 | "description": "Stitches video clips on top of another clip using ffmpeg", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "homepage": "https://github.com/ArsalanDotMe/VideoStitch", 10 | "keywords": [ 11 | "ffmpeg", 12 | "video", 13 | "cut", 14 | "merge", 15 | "edit", 16 | "overlay" 17 | ], 18 | "author": "Arsalan Ahmad ", 19 | "license": "ISC", 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/ArsalanDotMe/VideoStitch.git" 23 | }, 24 | "dependencies": { 25 | "bluebird": "^3.0.6", 26 | "file-extension": "^2.0.1", 27 | "lodash": "^3.10.1", 28 | "moment": "^2.10.6", 29 | "moment-duration-format": "^1.3.0", 30 | "shelljs": "^0.5.3", 31 | "tmp": "0.0.28" 32 | }, 33 | "devDependencies": { 34 | "tape": "^4.2.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/tailor.test.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | let test = require('tape'); 5 | let path = require('path'); 6 | let videoStitch = require('../index'); 7 | let util = require('util'); 8 | 9 | 10 | test('Video Stitch Module', (t) => { 11 | let merger = videoStitch.merge; 12 | t.plan(1); 13 | merger() 14 | .original({ 15 | duration: 30000, 16 | startTime: 0, 17 | fileName: path.join(__dirname, 'assets', 'tailor.mp4'), 18 | }) 19 | .clips([ 20 | { 21 | startTime: 5000, 22 | duration: 5000, 23 | fileName: path.join(__dirname, 'assets', 'tailor-5-10.mp4'), 24 | }, 25 | { 26 | startTime: 20000, 27 | duration: 5000, 28 | fileName: path.join(__dirname, 'assets', 'tailor-20-25.mp4'), 29 | } 30 | ]) 31 | .merge() 32 | .then((finalOutput) => { 33 | console.log('finalOutput: ', finalOutput); 34 | t.pass(finalOutput); 35 | }) 36 | .catch(err => { 37 | t.fail(util.inspect(err)); 38 | }); 39 | }) 40 | -------------------------------------------------------------------------------- /lib/videomerge.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _ = require('lodash'); 4 | let shelljs = require('shelljs'); 5 | let fs = require('fs'); 6 | let moment = require('moment'); 7 | 8 | let videoCut = require('./videocut'); 9 | let videoConcat = require('./videoconcat'); 10 | 11 | require('moment-duration-format'); 12 | 13 | module.exports = function videoMerge(spec) { 14 | let that = null; 15 | const durationFormat = 'hh:mm:ss'; 16 | 17 | let originalVideo = null, clips = null; 18 | 19 | spec = _.defaults(spec || {}, { 20 | silent: true, 21 | }); 22 | 23 | function setOriginal(org) { 24 | if (!org.fileName || !org.duration) { 25 | throw new Error("Expected properties `fileName` or `duration` not found."); 26 | } 27 | originalVideo = org; 28 | originalVideo.startTime = moment.duration(originalVideo.startTime).format(durationFormat, { trim: false }); 29 | originalVideo.duration = moment.duration(originalVideo.duration).format(durationFormat, { trim: false }); 30 | return that; 31 | } 32 | 33 | function setClips(_clips) { 34 | if (_.isArray(_clips)) { 35 | clips = _clips.map(clip => { 36 | clip.startTime = moment.duration(clip.startTime).format(durationFormat, { trim: false }); 37 | clip.duration = moment.duration(clip.duration).format(durationFormat, { trim: false }); 38 | return clip; 39 | }); 40 | } else { 41 | throw new Error('Expected parameter to be of type `Array`'); 42 | } 43 | return that; 44 | } 45 | 46 | /** 47 | * Merges video clips together 48 | * @param {string} mergeOpts.outputFileName Name of the output file 49 | * @return {Promise} 50 | */ 51 | function doMerge(mergeOpts) { 52 | /** 53 | * 1. Cut original video into small clips 54 | * 2. Concat all the clips together 55 | */ 56 | 57 | mergeOpts = mergeOpts || {}; 58 | 59 | return videoCut(spec) 60 | .original(originalVideo) 61 | .exclude(clips) 62 | .cut() 63 | .then((videoClips) => { 64 | 65 | let combinedClips = [].concat(videoClips, clips); 66 | combinedClips = _.sortBy(combinedClips, 'startTime'); 67 | 68 | return videoConcat(spec) 69 | .clips(combinedClips) 70 | .output(mergeOpts.outputFileName) 71 | .concat(); 72 | 73 | }); 74 | } 75 | 76 | that = Object.create({ 77 | original: setOriginal, 78 | clips: setClips, 79 | merge: doMerge 80 | }); 81 | 82 | 83 | return that; 84 | } 85 | -------------------------------------------------------------------------------- /lib/videoconcat.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let tmp = require('tmp'); 4 | let fext = require('file-extension'); 5 | let fs = require('fs'); 6 | let shelljs = require('shelljs'); 7 | let _ = require('lodash'); 8 | 9 | module.exports = function (spec) { 10 | 11 | let that = null; 12 | let clips = []; 13 | let outputFileName = tmp.tmpNameSync({ 14 | prefix: 'video-output-', 15 | postfix: '.mp4' 16 | }); 17 | 18 | spec = _.defaults(spec, { 19 | silent: true, 20 | overwrite: null 21 | }); 22 | 23 | function handleOverwrite() { 24 | switch (spec.overwrite) { 25 | case true: 26 | return '-y' 27 | case false: 28 | return '-n' 29 | default: 30 | return '' 31 | } 32 | } 33 | 34 | function setClips(_clips) { 35 | if (Array.isArray(_clips)) { 36 | clips = _clips; 37 | } else { 38 | throw new Error('Expected parameter to be of type `Array`'); 39 | } 40 | return that; 41 | } 42 | 43 | function setOutput(fileName) { 44 | 45 | if (fileName) { 46 | 47 | outputFileName = fileName; 48 | } 49 | return that; 50 | } 51 | 52 | /** 53 | * concatenates clips together using a fileList 54 | * @param {string} args.fileList Address of a text file containing filenames of the clips 55 | * @return {[type]} [description] 56 | */ 57 | function concatClips(args) { 58 | const overwrite = handleOverwrite(); 59 | 60 | return new Promise((resolve, reject) => { 61 | let child = shelljs.exec(`ffmpeg -f concat -safe 0 -protocol_whitelist file,http,https,tcp,tls,crypto -i ${args.fileList} -c copy ${outputFileName} ${overwrite}`, { async: true, silent: spec.silent }); 62 | 63 | child.on('exit', (code, signal) => { 64 | 65 | if (code === 0) { 66 | resolve(outputFileName); 67 | } else { 68 | reject(); 69 | } 70 | }); 71 | }); 72 | } 73 | 74 | function escapePath(pathString) { 75 | return pathString.replace(/\\/g, '\\\\'); 76 | } 77 | 78 | function getLineForClip(clip) { 79 | return `file ${escapePath(clip.fileName)}`; 80 | } 81 | 82 | function getTextForClips(clips) { 83 | return clips.map(getLineForClip).join('\n'); 84 | } 85 | 86 | function doConcat() { 87 | 88 | 89 | let fileListText = getTextForClips(clips); 90 | 91 | let fileListFilename = tmp.tmpNameSync({ 92 | postfix: '.txt' 93 | }); 94 | 95 | fs.writeFileSync(fileListFilename, fileListText, 'utf8'); 96 | 97 | return concatClips({ 98 | fileList: fileListFilename, 99 | }); 100 | } 101 | 102 | that = Object.create({ 103 | clips: setClips, 104 | output: setOutput, 105 | concat: doConcat, 106 | }); 107 | 108 | return that; 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VideoStitch 2 | A node module that performs cutting, clips extraction, merging on videos using ffpmeg. 3 | 4 | # Video Merge Usage 5 | Video merge overwrites given clips on top of an original video and outputs the final video. 6 | 7 | ```javascript 8 | 'use strict'; 9 | 10 | let videoStitch = require('video-stitch'); 11 | 12 | let videoMerge = videoStitch.merge; 13 | 14 | videoMerge() 15 | .original({ 16 | "fileName": "FILENAME", 17 | "duration": "hh:mm:ss" 18 | }) 19 | .clips([ 20 | { 21 | "startTime": "hh:mm:ss", 22 | "fileName": "FILENAME", 23 | "duration": "hh:mm:ss" 24 | }, 25 | { 26 | "startTime": "hh:mm:ss", 27 | "fileName": "FILENAME", 28 | "duration": "hh:mm:ss" 29 | }, 30 | { 31 | "startTime": "hh:mm:ss", 32 | "fileName": "FILENAME", 33 | "duration": "hh:mm:ss" 34 | } 35 | ]) 36 | .merge() 37 | .then((outputFile) => { 38 | console.log('path to output file', outputFile); 39 | }); 40 | ``` 41 | 42 | # Video Cut Usage 43 | Takes an original video, applies required cuts to exclude specified regions (clips) and gives you back the resulting clips of the originally cut video. 44 | 45 | ```javascript 46 | 'use strict'; 47 | 48 | let videoStitch = require('video-stitch'); 49 | 50 | let videoCut = videoStitch.cut; 51 | 52 | videoCut({ 53 | silent: true // optional. if set to false, gives detailed output on console 54 | }) 55 | .original({ 56 | "fileName": "FILENAME", 57 | "duration": "hh:mm:ss" 58 | }) 59 | .exclude([ 60 | { 61 | "startTime": "hh:mm:ss", 62 | "duration": "hh:mm:ss" 63 | }, 64 | { 65 | "startTime": "hh:mm:ss", 66 | "duration": "hh:mm:ss" 67 | }, 68 | { 69 | "startTime": "hh:mm:ss", 70 | "duration": "hh:mm:ss" 71 | } 72 | ]) 73 | .cut() 74 | .then((videoClips) => { 75 | // [{startTime, duration, fileName}] 76 | }); 77 | ``` 78 | 79 | # Video Concat Usage 80 | Takes a bunch of clips and joins them together. 81 | 82 | ```javascript 83 | 'use strict'; 84 | 85 | let videoStitch = require('video-stitch'); 86 | 87 | let videoConcat = videoStitch.concat; 88 | 89 | videoConcat({ 90 | silent: true, // optional. if set to false, gives detailed output on console 91 | overwrite: false // optional. by default, if file already exists, ffmpeg will ask for overwriting in console and that pause the process. if set to true, it will force overwriting. if set to false it will prevent overwriting. 92 | }) 93 | .clips([ 94 | { 95 | "fileName": "FILENAME" 96 | }, 97 | { 98 | "fileName": "FILENAME" 99 | }, 100 | { 101 | "fileName": "FILENAME" 102 | } 103 | ]) 104 | .output("myfilename") //optional absolute file name for output file 105 | .concat() 106 | .then((outputFileName) => { 107 | 108 | }); 109 | ``` 110 | -------------------------------------------------------------------------------- /lib/videocut.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _ = require('lodash'); 4 | let moment = require('moment'); 5 | require('moment-duration-format'); 6 | let tmp = require('tmp'); 7 | let shelljs = require('shelljs'); 8 | let fext = require('file-extension'); 9 | 10 | let Promise = require('bluebird'); 11 | 12 | module.exports = function videoCut(spec) { 13 | let that = null; 14 | let originalVideo = null, clips = null; 15 | const durationFormat = 'hh:mm:ss'; 16 | 17 | spec = _.defaults(spec, { 18 | silent: true, 19 | }); 20 | 21 | function setOriginal(org) { 22 | if (!org.fileName || !org.duration) { 23 | throw new Error("Expected properties `fileName` or `duration` not found."); 24 | } 25 | originalVideo = org; 26 | return that; 27 | } 28 | 29 | function setClips(_clips) { 30 | if (_.isArray(_clips)) { 31 | 32 | clips = _(_clips).map(clip => { 33 | clip.duration = moment.duration(clip.duration).format(durationFormat, { trim: false }); 34 | return clip; 35 | }).sortBy('startTime').value(); 36 | 37 | } else { 38 | throw new Error('Expected parameter to be of type `Array`'); 39 | } 40 | return that; 41 | } 42 | 43 | function getCutsForVideo(clips) { 44 | let originalCuts = []; 45 | 46 | clips.forEach((clip, index) => { 47 | 48 | let startForThisClip = index === 0 49 | ? moment.duration() 50 | : moment.duration(clips[index - 1].startTime) 51 | .add(moment.duration(clips[index - 1].duration)); 52 | 53 | let durationOfThisClip = moment.duration(clip.startTime).subtract(startForThisClip); 54 | 55 | originalCuts.push({ 56 | startTime: startForThisClip.format(durationFormat, { trim: false }), 57 | duration: durationOfThisClip.format(durationFormat, { trim: false }) 58 | }); 59 | 60 | }); 61 | 62 | // Add remaining clip 63 | let lastClip = clips[clips.length - 1]; 64 | let lastClipStartTime = moment.duration(lastClip.startTime) 65 | .add(moment.duration(lastClip.duration)); 66 | 67 | let lastClipDuration = moment.duration(originalVideo.duration) 68 | .subtract(lastClipStartTime); 69 | 70 | originalCuts.push({ 71 | startTime: lastClipStartTime 72 | .format(durationFormat, { trim: false }), 73 | duration: lastClipDuration.format(durationFormat, { trim: false }) 74 | }); 75 | 76 | return originalCuts; 77 | } 78 | 79 | /** 80 | * Cuts a video using ffmpeg 81 | * @param {string} args.startTime Start time of the cut in `hh:mm:ss` format. 82 | * @param {string} args.duration Duration of the cut in `hh:mm:ss` format. 83 | * @param {string} args.fileName Filename of the video to be cut 84 | * @return {Promise} A promise for an object containing startTime, duration, and fileName of the cut clip. 85 | */ 86 | function cutVideo(args) { 87 | 88 | let cutFileName = tmp.tmpNameSync({ 89 | prefix: 'video-cut-', 90 | postfix: `.${fext(originalVideo.fileName)}` 91 | }); 92 | 93 | return new Promise((resolve, reject) => { 94 | 95 | let commandQuery = 96 | `ffmpeg -i ${args.fileName} -ss ${args.startTime} -t ${args.duration} ${cutFileName} -y`; 97 | 98 | let child = shelljs.exec(commandQuery, { async: true, silent: spec.silent }); 99 | 100 | child.on('exit', (code, signal) => { 101 | 102 | if (code === 0) { 103 | 104 | resolve({ 105 | startTime: args.startTime, 106 | duration: args.duration, 107 | fileName: cutFileName, 108 | }); 109 | 110 | } else { 111 | reject({ err: { code: code, signal: signal } }); 112 | } 113 | 114 | }); 115 | 116 | }); 117 | } 118 | 119 | function doCut() { 120 | let originalVideoCuts = getCutsForVideo(clips); 121 | 122 | let cutPromises = originalVideoCuts.map((cut) => { 123 | 124 | return cutVideo({ 125 | startTime: cut.startTime, 126 | duration: cut.duration, 127 | fileName: originalVideo.fileName, 128 | }); 129 | }); 130 | return Promise.all(cutPromises); 131 | } 132 | 133 | that = Object.create({ 134 | original: setOriginal, 135 | exclude: setClips, 136 | cut: doCut, 137 | }); 138 | 139 | return that; 140 | } 141 | --------------------------------------------------------------------------------