├── .npmignore ├── .eslintrc ├── index.js ├── media ├── 0.mp4 ├── 0a.mp4 ├── 1.mp4 ├── 2.mp4 ├── example.gif └── example.mp4 ├── .editorconfig ├── .gitignore ├── .travis.yml ├── lib ├── extract-audio.js ├── get-file-ext.js ├── extract-video-frames.js ├── get-pixels.js ├── render-audio.js ├── context.js ├── frame-writer.js ├── transcode-video.js ├── transition.js ├── render-frames.js ├── cli.js ├── index.js ├── index.test.js └── init-frames.js ├── package.json ├── readme.zh.md └── readme.md /.npmignore: -------------------------------------------------------------------------------- 1 | media 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('./lib') 4 | -------------------------------------------------------------------------------- /media/0.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/0.mp4 -------------------------------------------------------------------------------- /media/0a.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/0a.mp4 -------------------------------------------------------------------------------- /media/1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/1.mp4 -------------------------------------------------------------------------------- /media/2.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/2.mp4 -------------------------------------------------------------------------------- /media/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/example.gif -------------------------------------------------------------------------------- /media/example.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jankozik/ffmpeg-concat/HEAD/media/example.mp4 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # builds 7 | build 8 | dist 9 | 10 | # misc 11 | .DS_Store 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | .cache 18 | .vscode 19 | 20 | temp 21 | out.mp4 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | os: linux 5 | sudo: required 6 | dist: trusty 7 | addons: 8 | apt: 9 | packages: 10 | - mesa-utils 11 | - xvfb 12 | - libgl1-mesa-dri 13 | - libxi-dev 14 | - xserver-xorg-dev 15 | - libxext-dev 16 | - libglapi-mesa 17 | - libosmesa6 18 | before_script: 19 | - export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start 20 | before_install: 21 | - sudo add-apt-repository ppa:mc3man/trusty-media -y 22 | - sudo apt-get update -q 23 | - sudo apt-get install ffmpeg -y 24 | -------------------------------------------------------------------------------- /lib/extract-audio.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ffmpeg = require('fluent-ffmpeg') 4 | 5 | module.exports = (opts) => { 6 | const { 7 | log, 8 | videoPath, 9 | outputFileName, 10 | start, 11 | duration 12 | } = opts 13 | 14 | return new Promise((resolve, reject) => { 15 | const cmd = ffmpeg(videoPath) 16 | .noVideo() 17 | .audioCodec('libmp3lame') 18 | .on('start', cmd => log({ cmd })) 19 | .on('end', () => resolve(outputFileName)) 20 | .on('error', (err) => reject(err)) 21 | if (start) { 22 | cmd.seekInput(start) 23 | } 24 | if (duration) { 25 | cmd.duration(duration) 26 | } 27 | cmd.save(outputFileName) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /lib/get-file-ext.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const parseUrl = require('url-parse') 4 | 5 | const extWhitelist = new Set([ 6 | // videos 7 | 'gif', 8 | 'mp4', 9 | 'webm', 10 | 'mkv', 11 | 'mov', 12 | 'avi', 13 | 14 | // images 15 | 'bmp', 16 | 'jpg', 17 | 'jpeg', 18 | 'png', 19 | 'tif', 20 | 'webp', 21 | 22 | // audio 23 | 'mp3', 24 | 'aac', 25 | 'wav', 26 | 'flac', 27 | 'opus', 28 | 'ogg' 29 | ]) 30 | 31 | module.exports = (url, opts = { strict: true }) => { 32 | const { pathname } = parseUrl(url) 33 | const parts = pathname.split('.') 34 | const ext = (parts[parts.length - 1] || '').trim().toLowerCase() 35 | 36 | if (!opts.strict || extWhitelist.has(ext)) { 37 | return ext 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/extract-video-frames.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ffmpeg = require('fluent-ffmpeg') 4 | 5 | module.exports = (opts) => { 6 | const { 7 | videoPath, 8 | framePattern, 9 | verbose = false 10 | } = opts 11 | 12 | return new Promise((resolve, reject) => { 13 | const cmd = ffmpeg(videoPath) 14 | .outputOptions([ 15 | '-loglevel', 'info', 16 | '-pix_fmt', 'rgba', 17 | '-start_number', '0' 18 | ]) 19 | .output(framePattern) 20 | .on('start', (cmd) => console.log({ cmd })) 21 | .on('end', () => resolve(framePattern)) 22 | .on('error', (err) => reject(err)) 23 | 24 | if (verbose) { 25 | cmd.on('stderr', (err) => console.error(err)) 26 | } 27 | 28 | cmd.run() 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /lib/get-pixels.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const getPixels = require('get-pixels') 5 | const ndarray = require('ndarray') 6 | const util = require('util') 7 | 8 | const getFileExt = require('./get-file-ext') 9 | const getPixelsP = util.promisify(getPixels) 10 | 11 | module.exports = async (filePath, opts) => { 12 | const ext = getFileExt(filePath, { strict: false }) 13 | 14 | if (ext === 'raw') { 15 | const data = fs.readFileSync(filePath) 16 | 17 | // @see https://github.com/stackgl/gl-texture2d/issues/16 18 | return ndarray(data, [ 19 | opts.width, 20 | opts.height, 21 | 4 22 | ], [ 23 | 4, 24 | opts.width * 4, 25 | 1 26 | ]) 27 | } 28 | 29 | const pixels = await getPixelsP(filePath) 30 | return pixels 31 | } 32 | -------------------------------------------------------------------------------- /lib/render-audio.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs-extra') 4 | const path = require('path') 5 | const ffmpeg = require('fluent-ffmpeg') 6 | 7 | module.exports = async (opts) => { 8 | const { 9 | log, 10 | scenes, 11 | outputDir, 12 | fileName 13 | } = opts 14 | 15 | return new Promise((resolve, reject) => { 16 | const concatListPath = path.join(outputDir, 'audioConcat.txt') 17 | const toConcat = scenes.filter(scene => scene.sourceAudioPath).map(scene => `file '${scene.sourceAudioPath}'`) 18 | const outputFileName = path.join(outputDir, fileName) 19 | fs.outputFile(concatListPath, toConcat.join('\n')).then(() => { 20 | log(`created ${concatListPath}`) 21 | const cmd = ffmpeg() 22 | .input(concatListPath) 23 | .inputOptions(['-f concat', '-safe 0']) 24 | .on('start', cmd => log(cmd)) 25 | .on('end', () => resolve(outputFileName)) 26 | .on('error', (err, stdout, stderr) => { 27 | if (err) { 28 | console.error('failed to concat audio', err, stdout, stderr) 29 | } 30 | reject(err) 31 | }) 32 | cmd.save(outputFileName) 33 | }).catch(err => { 34 | console.error(`failed to concat audio ${err}`) 35 | reject(err) 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffmpeg-concat", 3 | "version": "1.2.3", 4 | "description": "Concats a list of videos together using ffmpeg with sexy OpenGL transitions.", 5 | "repository": "transitive-bullshit/ffmpeg-concat", 6 | "author": "Travis Fischer ", 7 | "license": "MIT", 8 | "main": "index.js", 9 | "bin": { 10 | "ffmpeg-concat": "lib/cli.js" 11 | }, 12 | "engines": { 13 | "node": ">=10.13.0" 14 | }, 15 | "scripts": { 16 | "test": "ava -v && standard" 17 | }, 18 | "keywords": [ 19 | "ffmpeg", 20 | "fluent-ffmpeg", 21 | "opengl", 22 | "gl", 23 | "gl-transition", 24 | "transition", 25 | "concat", 26 | "concatenate", 27 | "video" 28 | ], 29 | "devDependencies": { 30 | "ava": "^1.4.1", 31 | "standard": "^12.0.1" 32 | }, 33 | "dependencies": { 34 | "commander": "^2.19.0", 35 | "ffmpeg-on-progress": "^1.0.0", 36 | "ffmpeg-probe": "^1.0.6", 37 | "fluent-ffmpeg": "^2.1.2", 38 | "fs-extra": "^7.0.1", 39 | "get-pixels": "^3.3.3", 40 | "gl": "^6.0.1", 41 | "gl-buffer": "^2.1.2", 42 | "gl-texture2d": "^2.1.0", 43 | "gl-transition": "^1.13.0", 44 | "gl-transitions": "^1.43.0", 45 | "left-pad": "^1.3.0", 46 | "ndarray": "^1.0.19", 47 | "p-map": "^2.0.0", 48 | "p-race": "^2.0.0", 49 | "rmfr": "^2.0.0", 50 | "sharp": "^0.31.1", 51 | "tempy": "^0.2.1", 52 | "url-parse": "^1.4.4" 53 | }, 54 | "ava": { 55 | "failFast": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/context.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const GL = require('gl') 4 | 5 | const createFrameWriter = require('./frame-writer') 6 | const createTransition = require('./transition') 7 | 8 | module.exports = async (opts) => { 9 | const { 10 | frameFormat, 11 | theme 12 | } = opts 13 | 14 | const { 15 | width, 16 | height 17 | } = theme 18 | 19 | const gl = GL(width, height) 20 | 21 | if (!gl) { 22 | console.error('Failed to create OpenGL context. Please see https://github.com/stackgl/headless-gl#supported-platforms-and-nodejs-versions for compatibility.') 23 | 24 | throw new Error('failed to create OpenGL context') 25 | } 26 | 27 | const frameWriter = await createFrameWriter({ 28 | gl, 29 | width, 30 | height, 31 | frameFormat 32 | }) 33 | 34 | const ctx = { 35 | gl, 36 | width, 37 | height, 38 | frameWriter, 39 | transition: null 40 | } 41 | 42 | ctx.setTransition = ({ name, resizeMode }) => { 43 | if (ctx.transition) { 44 | if (ctx.transition.name === name) { 45 | return 46 | } 47 | 48 | ctx.transition.dispose() 49 | ctx.transition = null 50 | } 51 | 52 | ctx.transition = createTransition({ 53 | gl, 54 | name, 55 | resizeMode 56 | }) 57 | } 58 | 59 | ctx.capture = ctx.frameWriter.write.bind(ctx.frameWriter) 60 | 61 | ctx.render = async (...args) => { 62 | if (ctx.transition) { 63 | return ctx.transition.draw(...args) 64 | } 65 | } 66 | 67 | ctx.flush = async () => { 68 | return ctx.frameWriter.flush() 69 | } 70 | 71 | ctx.dispose = async () => { 72 | if (ctx.transition) { 73 | ctx.transition.dispose() 74 | ctx.transition = null 75 | } 76 | 77 | gl.destroy() 78 | } 79 | 80 | return ctx 81 | } 82 | -------------------------------------------------------------------------------- /lib/frame-writer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs-extra') 4 | const sharp = require('sharp') 5 | 6 | const supportedFormats = new Set([ 7 | 'png', 8 | 'jpg', 9 | 'raw' 10 | ]) 11 | 12 | module.exports = async (opts) => { 13 | const { 14 | frameFormat = 'raw', 15 | gl, 16 | width, 17 | height 18 | } = opts 19 | 20 | if (!supportedFormats.has(frameFormat)) { 21 | throw new Error(`frame writer unsupported format "${frameFormat}"`) 22 | } 23 | 24 | let worker = { 25 | byteArray: new Uint8Array(width * height * 4), 26 | encoder: null 27 | } 28 | 29 | if (frameFormat === 'png') { 30 | const buffer = Buffer.from(worker.byteArray.buffer) 31 | worker.encoder = sharp(buffer, { 32 | raw: { 33 | width, 34 | height, 35 | channels: 4 36 | } 37 | }).png({ 38 | compressionLevel: 0, 39 | adaptiveFiltering: false 40 | }) 41 | } else if (frameFormat === 'jpg') { 42 | const buffer = Buffer.from(worker.byteArray.buffer) 43 | worker.encoder = sharp(buffer, { 44 | raw: { 45 | width, 46 | height, 47 | channels: 4 48 | } 49 | }).jpeg() 50 | } 51 | 52 | return { 53 | write: async (filePath) => { 54 | gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, worker.byteArray) 55 | 56 | if (frameFormat === 'raw') { 57 | fs.writeFileSync(filePath, worker.byteArray) 58 | } else { 59 | await new Promise((resolve, reject) => { 60 | worker.encoder.toFile(filePath, (err) => { 61 | if (err) reject(err) 62 | resolve() 63 | }) 64 | }) 65 | } 66 | }, 67 | 68 | flush: async () => { 69 | return Promise.resolve() 70 | }, 71 | 72 | dispose: () => { 73 | worker = null 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/transcode-video.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ffmpeg = require('fluent-ffmpeg') 4 | const onTranscodeProgress = require('ffmpeg-on-progress') 5 | 6 | module.exports = async (opts) => { 7 | const { 8 | args, 9 | log, 10 | audio, 11 | frameFormat, 12 | framePattern, 13 | onProgress, 14 | output, 15 | theme, 16 | verbose 17 | } = opts 18 | 19 | return new Promise((resolve, reject) => { 20 | const inputOptions = [ 21 | '-framerate', theme.fps 22 | ] 23 | 24 | if (frameFormat === 'raw') { 25 | Array.prototype.push.apply(inputOptions, [ 26 | '-vcodec', 'rawvideo', 27 | '-pixel_format', 'rgba', 28 | '-video_size', `${theme.width}x${theme.height}` 29 | ]) 30 | } 31 | 32 | const cmd = ffmpeg(framePattern) 33 | .inputOptions(inputOptions) 34 | 35 | if (audio) { 36 | cmd.addInput(audio) 37 | } 38 | 39 | const outputOptions = [] 40 | // misc 41 | .concat([ 42 | '-hide_banner', 43 | '-map_metadata', '-1', 44 | '-map_chapters', '-1' 45 | ]) 46 | 47 | // video 48 | .concat(args || [ 49 | '-c:v', 'libx264', 50 | '-profile:v', 'main', 51 | '-preset', 'medium', 52 | '-crf', '20', 53 | '-movflags', 'faststart', 54 | '-pix_fmt', 'yuv420p', 55 | '-r', theme.fps 56 | ]) 57 | 58 | // audio 59 | .concat(!audio ? [] : [ 60 | '-c:a', 'copy' 61 | ]) 62 | 63 | if (onProgress) { 64 | cmd.on('progress', onTranscodeProgress(onProgress, theme.duration)) 65 | } 66 | 67 | if (verbose) { 68 | cmd.on('stderr', (err) => console.error(err)) 69 | } 70 | 71 | cmd 72 | .outputOptions(outputOptions) 73 | .output(output) 74 | .on('start', (cmd) => log({ cmd })) 75 | .on('end', () => resolve()) 76 | .on('error', (err) => reject(err)) 77 | .run() 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /lib/transition.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const createBuffer = require('gl-buffer') 4 | const createTexture = require('gl-texture2d') 5 | const createTransition = require('gl-transition').default 6 | const getPixels = require('./get-pixels') 7 | const transitions = require('gl-transitions') 8 | 9 | module.exports = (opts) => { 10 | const { 11 | name = 'directionalwarp', 12 | resizeMode = 'stretch', 13 | gl 14 | } = opts 15 | 16 | const buffer = createBuffer(gl, 17 | [-1, -1, -1, 4, 4, -1], 18 | gl.ARRAY_BUFFER, 19 | gl.STATIC_DRAW 20 | ) 21 | 22 | const transitionName = name.toLowerCase() 23 | const source = transitions.find(t => t.name.toLowerCase() === transitionName) || 24 | transitions.find(t => t.name.toLowerCase() === 'fade') 25 | 26 | const transition = createTransition(gl, source, { 27 | resizeMode 28 | }) 29 | 30 | return { 31 | name, 32 | draw: async ({ 33 | imagePathFrom, 34 | imagePathTo, 35 | progress, 36 | params 37 | }) => { 38 | gl.clear(gl.COLOR_BUFFER_BIT) 39 | 40 | const dataFrom = await getPixels(imagePathFrom, { 41 | width: gl.drawingBufferWidth, 42 | height: gl.drawingBufferHeight 43 | }) 44 | 45 | const textureFrom = createTexture(gl, dataFrom) 46 | textureFrom.minFilter = gl.LINEAR 47 | textureFrom.magFilter = gl.LINEAR 48 | 49 | const dataTo = await getPixels(imagePathTo, { 50 | width: gl.drawingBufferWidth, 51 | height: gl.drawingBufferHeight 52 | }) 53 | const textureTo = createTexture(gl, dataTo) 54 | textureTo.minFilter = gl.LINEAR 55 | textureTo.magFilter = gl.LINEAR 56 | 57 | buffer.bind() 58 | transition.draw(progress, textureFrom, textureTo, gl.drawingBufferWidth, gl.drawingBufferHeight, params) 59 | 60 | textureFrom.dispose() 61 | textureTo.dispose() 62 | }, 63 | 64 | dispose: () => { 65 | buffer.dispose() 66 | transition.dispose() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/render-frames.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs-extra') 4 | const leftPad = require('left-pad') 5 | const path = require('path') 6 | const pMap = require('p-map') 7 | 8 | const createContext = require('./context') 9 | 10 | module.exports = async (opts) => { 11 | const { 12 | frameFormat, 13 | frames, 14 | onProgress, 15 | outputDir, 16 | theme 17 | } = opts 18 | 19 | const ctx = await createContext({ 20 | frameFormat, 21 | theme 22 | }) 23 | 24 | await pMap(frames, (frame, index) => { 25 | return module.exports.renderFrame({ 26 | ctx, 27 | frame, 28 | frameFormat, 29 | index, 30 | onProgress, 31 | outputDir, 32 | theme 33 | }) 34 | }, { 35 | concurrency: 8 36 | }) 37 | 38 | await ctx.flush() 39 | await ctx.dispose() 40 | 41 | const framePattern = path.join(outputDir, `%012d.${frameFormat}`) 42 | return framePattern 43 | } 44 | 45 | module.exports.renderFrame = async (opts) => { 46 | const { 47 | ctx, 48 | frame, 49 | frameFormat, 50 | index, 51 | onProgress, 52 | outputDir, 53 | theme 54 | } = opts 55 | 56 | const fileName = `${leftPad(index, 12, '0')}.${frameFormat}` 57 | const filePath = path.join(outputDir, fileName) 58 | 59 | const { 60 | current, 61 | next 62 | } = frame 63 | 64 | const cFrame = index - current.frameStart 65 | const cFramePath = current.getFrame(cFrame) 66 | 67 | if (!next) { 68 | await fs.move(cFramePath, filePath, { overwrite: true }) 69 | } else { 70 | ctx.setTransition(current.transition) 71 | 72 | const nFrame = index - next.frameStart 73 | const nFramePath = next.getFrame(nFrame) 74 | const cProgress = (cFrame - current.numFramesPreTransition) / current.numFramesTransition 75 | 76 | await ctx.render({ 77 | imagePathFrom: cFramePath, 78 | imagePathTo: nFramePath, 79 | progress: cProgress, 80 | params: current.transition.params 81 | }) 82 | 83 | await ctx.capture(filePath) 84 | } 85 | 86 | if (onProgress && index % 16 === 0) { 87 | onProgress(index / theme.numFrames) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | 4 | const fs = require('fs') 5 | const program = require('commander') 6 | 7 | const concat = require('.') 8 | const { version } = require('../package') 9 | 10 | 11 | module.exports = async (argv) => { 12 | program 13 | .version(version) 14 | .usage('[options] ') 15 | .option('-o, --output ', 'path to mp4 file to write', (s) => s, 'out.mp4') 16 | .option('-t, --transition-name ', 'name of gl-transition to use', (s) => s, 'fade') 17 | .option('-d, --transition-duration ', 'duration of transition to use in ms', (v) => parseInt(v), 500) 18 | .option('-T, --transitions ', 'json file to load transitions from') 19 | .option('-f, --frame-format ', 'format to use for temp frame images', /^(raw|png|jpg)$/i, 'raw') 20 | .option('-c, --concurrency ', 'number of videos to process in parallel', (v) => parseInt(v), 4) 21 | .option('-C, --no-cleanup-frames', 'disables cleaning up temp frame images') 22 | .option('-v, --verbose', 'enable verbose debug logging from FFmpeg') 23 | .option('-O, --temp-dir ', 'temporary working directory to store frame data') 24 | .parse(argv) 25 | 26 | let transitions 27 | 28 | if (program.transitions) { 29 | try { 30 | transitions = JSON.parse(fs.readFileSync(program.transitions, 'utf8')) 31 | } catch (err) { 32 | console.error(`error parsing transitions file "${program.transitions}"`, err) 33 | throw err 34 | } 35 | } 36 | 37 | 38 | 39 | try { 40 | const videos = program.args.filter((v) => typeof v === 'string') 41 | 42 | await concat({ 43 | log: console.log, 44 | concurrency: program.concurrency, 45 | 46 | videos, 47 | output: program.output, 48 | 49 | transition: { 50 | name: program.transitionName, 51 | duration: program.transitionDuration 52 | }, 53 | transitions, 54 | 55 | frameFormat: program.frameFormat, 56 | cleanupFrames: program.cleanupFrames, 57 | tempDir: program.tempDir, 58 | verbose: !!program.verbose 59 | }) 60 | 61 | console.log(program.output) 62 | } catch (err) { 63 | console.error('concat error!', err) 64 | throw err 65 | } 66 | } 67 | 68 | module.exports(process.argv) 69 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs-extra') 4 | const rmfr = require('rmfr') 5 | const tempy = require('tempy') 6 | 7 | const initFrames = require('./init-frames') 8 | const renderFrames = require('./render-frames') 9 | const renderAudio = require('./render-audio') 10 | const transcodeVideo = require('./transcode-video') 11 | 12 | const noop = () => { } 13 | 14 | module.exports = async (opts) => { 15 | const { 16 | args, 17 | log = noop, 18 | concurrency = 4, 19 | frameFormat = 'raw', 20 | cleanupFrames = true, 21 | transition = undefined, 22 | transitions = undefined, 23 | audio = undefined, 24 | videos, 25 | output, 26 | tempDir, 27 | verbose = false 28 | } = opts 29 | 30 | if (tempDir) { 31 | fs.ensureDirSync(tempDir) 32 | } 33 | 34 | const temp = tempDir || tempy.directory() 35 | 36 | console.time('ffmpeg-concat') 37 | 38 | try { 39 | console.time('init-frames') 40 | const { 41 | frames, 42 | scenes, 43 | theme 44 | } = await initFrames({ 45 | log, 46 | concurrency, 47 | videos, 48 | transition, 49 | transitions, 50 | outputDir: temp, 51 | frameFormat, 52 | renderAudio: !audio, 53 | verbose 54 | }) 55 | console.timeEnd('init-frames') 56 | 57 | console.time('render-frames') 58 | const framePattern = await renderFrames({ 59 | log, 60 | concurrency, 61 | outputDir: temp, 62 | frameFormat, 63 | frames, 64 | theme, 65 | onProgress: (p) => { 66 | log(`render ${(100 * p).toFixed()}%`) 67 | } 68 | }) 69 | console.timeEnd('render-frames') 70 | 71 | console.time('render-audio') 72 | let concatAudioFile = audio 73 | if (!audio && scenes.filter(s => s.sourceAudioPath).length === scenes.length) { 74 | concatAudioFile = await renderAudio({ 75 | log, 76 | scenes, 77 | outputDir: temp, 78 | fileName: 'audioConcat.mp3' 79 | }) 80 | } 81 | console.timeEnd('render-audio') 82 | 83 | console.time('transcode-video') 84 | await transcodeVideo({ 85 | args, 86 | log, 87 | framePattern, 88 | frameFormat, 89 | audio: concatAudioFile, 90 | output, 91 | theme, 92 | verbose, 93 | onProgress: (p) => { 94 | log(`transcode ${(100 * p).toFixed()}%`) 95 | } 96 | }) 97 | console.timeEnd('transcode-video') 98 | } catch (err) { 99 | if (cleanupFrames) { 100 | await rmfr(temp) 101 | } 102 | 103 | console.timeEnd('ffmpeg-concat') 104 | throw err 105 | } 106 | 107 | if (cleanupFrames && !tempDir) { 108 | await rmfr(temp) 109 | } 110 | 111 | console.timeEnd('ffmpeg-concat') 112 | } 113 | 114 | -------------------------------------------------------------------------------- /lib/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | const ffmpegProbe = require('ffmpeg-probe') 5 | const path = require('path') 6 | const rmfr = require('rmfr') 7 | const tempy = require('tempy') 8 | 9 | const concat = require('.') 10 | 11 | const fixturesPath = path.join(__dirname, '..', 'media') 12 | const videos = [ 13 | path.join(fixturesPath, '0.mp4'), 14 | path.join(fixturesPath, '1.mp4'), 15 | path.join(fixturesPath, '2.mp4') 16 | ] 17 | const videosWithAudio = [ 18 | path.join(fixturesPath, '0a.mp4'), 19 | path.join(fixturesPath, '0a.mp4'), 20 | path.join(fixturesPath, '0a.mp4') 21 | ] 22 | 23 | test.serial('concat 3 mp4s with using constant 500ms transitions', async (t) => { 24 | const output = tempy.file({ extension: 'mp4' }) 25 | await concat({ 26 | log: console.log, 27 | verbose: true, 28 | output, 29 | videos, 30 | transition: { 31 | name: 'directionalwipe', 32 | duration: 500 33 | } 34 | }) 35 | 36 | const probe = await ffmpegProbe(output) 37 | t.is(probe.width, 640) 38 | t.is(probe.height, 360) 39 | t.truthy(probe.duration >= 10500) 40 | t.truthy(probe.duration <= 11500) 41 | 42 | await rmfr(output) 43 | }) 44 | 45 | test.serial('concat 9 mp4s with unique transitions', async (t) => { 46 | const output = tempy.file({ extension: 'mp4' }) 47 | await concat({ 48 | log: console.log, 49 | verbose: true, 50 | output, 51 | videos: videos.concat(videos).concat(videos), 52 | transitions: [ 53 | { 54 | name: 'directionalWarp', 55 | duration: 1000 56 | }, 57 | { 58 | name: 'circleOpen', 59 | duration: 1000 60 | }, 61 | { 62 | name: 'crossWarp', 63 | duration: 1000 64 | }, 65 | { 66 | name: 'crossZoom', 67 | duration: 1000 68 | }, 69 | { 70 | name: 'directionalWipe', 71 | duration: 1000 72 | }, 73 | { 74 | name: 'squaresWire', 75 | duration: 1000 76 | }, 77 | { 78 | name: 'radial', 79 | duration: 1000 80 | }, 81 | { 82 | name: 'swap', 83 | duration: 1000 84 | } 85 | ] 86 | }) 87 | 88 | const probe = await ffmpegProbe(output) 89 | t.is(probe.width, 640) 90 | t.is(probe.height, 360) 91 | t.truthy(probe.duration >= 27000) 92 | t.truthy(probe.duration <= 28000) 93 | 94 | await rmfr(output) 95 | }) 96 | 97 | test.serial('concat 3 mp4s with source audio and unique transitions', async (t) => { 98 | const output = tempy.file({ extension: 'mp4' }) 99 | await concat({ 100 | log: console.log, 101 | verbose: true, 102 | output, 103 | videos: videosWithAudio, 104 | transitions: [ 105 | { 106 | name: 'circleOpen', 107 | duration: 1000 108 | }, 109 | { 110 | name: 'crossWarp', 111 | duration: 1000 112 | } 113 | ] 114 | }) 115 | 116 | const probe = await ffmpegProbe(output) 117 | t.is(probe.width, 1280) 118 | t.is(probe.height, 720) 119 | t.is(probe.streams.length, 2) 120 | t.truthy(probe.duration >= 11000) 121 | t.truthy(probe.duration <= 15000) 122 | 123 | await rmfr(output) 124 | }) 125 | -------------------------------------------------------------------------------- /lib/init-frames.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ffmpegProbe = require('ffmpeg-probe') 4 | const fs = require('fs-extra') 5 | const leftPad = require('left-pad') 6 | const path = require('path') 7 | const pMap = require('p-map') 8 | 9 | const extractVideoFrames = require('./extract-video-frames') 10 | const extractAudio = require('./extract-audio') 11 | 12 | module.exports = async (opts) => { 13 | const { 14 | concurrency, 15 | log, 16 | videos, 17 | transition, 18 | transitions, 19 | frameFormat, 20 | outputDir, 21 | renderAudio = false, 22 | verbose 23 | } = opts 24 | 25 | if (transitions && videos.length - 1 !== transitions.length) { 26 | throw new Error( 27 | 'number of transitions must equal number of videos minus one' 28 | ) 29 | } 30 | 31 | const scenes = await pMap( 32 | videos, 33 | (video, index) => { 34 | return module.exports.initScene({ 35 | log, 36 | index, 37 | videos, 38 | transition, 39 | transitions, 40 | frameFormat, 41 | outputDir, 42 | renderAudio, 43 | verbose 44 | }) 45 | }, 46 | { 47 | concurrency 48 | } 49 | ) 50 | 51 | // first video dictates dimensions and fps 52 | const { width, height, fps } = scenes[0] 53 | 54 | const frames = [] 55 | let numFrames = 0 56 | 57 | scenes.forEach((scene, index) => { 58 | scene.frameStart = numFrames 59 | 60 | scene.numFramesTransition = Math.floor( 61 | (scene.transition.duration * fps) / 1000 62 | ) 63 | scene.numFramesPreTransition = Math.max( 64 | 0, 65 | scene.numFrames - scene.numFramesTransition 66 | ) 67 | 68 | numFrames += scene.numFramesPreTransition 69 | 70 | for (let frame = 0; frame < scene.numFrames; ++frame) { 71 | const cFrame = scene.frameStart + frame 72 | 73 | if (!frames[cFrame]) { 74 | const next = 75 | frame < scene.numFramesPreTransition ? undefined : scenes[index + 1] 76 | 77 | frames[cFrame] = { 78 | current: scene, 79 | next 80 | } 81 | } 82 | } 83 | }) 84 | 85 | const duration = scenes.reduce( 86 | (sum, scene, index) => scene.duration + sum - scene.transition.duration, 87 | 0 88 | ) 89 | 90 | return { 91 | frames, 92 | scenes, 93 | theme: { 94 | numFrames, 95 | duration, 96 | width, 97 | height, 98 | fps 99 | } 100 | } 101 | } 102 | 103 | module.exports.initScene = async (opts) => { 104 | const { 105 | log, 106 | index, 107 | videos, 108 | transition, 109 | transitions, 110 | frameFormat, 111 | outputDir, 112 | renderAudio, 113 | verbose 114 | } = opts 115 | 116 | const video = videos[index] 117 | const probe = await ffmpegProbe(video) 118 | const format = (probe.format && probe.format.format_name) || 'unknown' 119 | 120 | if (!probe.streams || !probe.streams[0]) { 121 | throw new Error(`Unsupported input video format "${format}": ${video}`) 122 | } 123 | 124 | const scene = { 125 | video, 126 | index, 127 | width: probe.width, 128 | height: probe.height, 129 | duration: probe.duration, 130 | numFrames: parseInt(probe.streams[0].nb_frames), 131 | fps: probe.fps 132 | } 133 | 134 | if (isNaN(scene.numFrames) || isNaN(scene.duration)) { 135 | throw new Error(`Unsupported input video format "${format}": ${video}`) 136 | } 137 | 138 | if (verbose) { 139 | console.error(scene) 140 | } 141 | 142 | const t = transitions ? transitions[index] : transition 143 | scene.transition = { 144 | name: 'fade', 145 | duration: 500, 146 | params: {}, 147 | ...t 148 | } 149 | 150 | if (index >= videos.length - 1) { 151 | scene.transition.duration = 0 152 | } 153 | 154 | const fileNamePattern = `scene-${index}-%012d.${frameFormat}` 155 | const audioFileName = `scene-${index}.mp3` 156 | const framePattern = path.join(outputDir, fileNamePattern) 157 | const audioPath = path.join(outputDir, audioFileName) 158 | await extractVideoFrames({ 159 | log, 160 | videoPath: scene.video, 161 | framePattern, 162 | verbose 163 | }) 164 | 165 | scene.getFrame = (frame) => { 166 | return framePattern.replace('%012d', leftPad(frame, 12, '0')) 167 | } 168 | 169 | // guard to ensure we only use frames that exist 170 | while (scene.numFrames > 0) { 171 | const frame = scene.getFrame(scene.numFrames - 1) 172 | const exists = await fs.pathExists(frame) 173 | 174 | if (exists) { 175 | break 176 | } else { 177 | scene.numFrames-- 178 | } 179 | } 180 | 181 | if ( 182 | renderAudio && 183 | probe.streams && 184 | probe.streams.filter((s) => s.codec_type === 'audio').length 185 | ) { 186 | const previousTransition = 187 | index > 0 && transitions ? transitions[index - 1] : transition 188 | const previousTransitionDuration = 189 | index === 0 ? 0 : previousTransition.duration || 500 190 | 191 | await extractAudio({ 192 | log, 193 | videoPath: scene.video, 194 | outputFileName: audioPath, 195 | start: previousTransitionDuration / 2000, 196 | duration: 197 | scene.duration / 1000 - 198 | previousTransitionDuration / 2000 - 199 | scene.transition.duration / 2000 200 | }) 201 | scene.sourceAudioPath = audioPath 202 | } 203 | 204 | return scene 205 | } 206 | -------------------------------------------------------------------------------- /readme.zh.md: -------------------------------------------------------------------------------- 1 | 2 | # ffmpeg-concat 3 | 4 | > 拼接 一组视频.,通过使用 ffmpeg和 性感的 OpenGL 过渡 (动画效果) 5 | 6 | [![NPM](https://img.shields.io/npm/v/ffmpeg-concat.svg)](https://www.npmjs.com/package/ffmpeg-concat) [![Build Status](https://travis-ci.com/transitive-bullshit/ffmpeg-concat.svg?branch=master)](https://travis-ci.com/transitive-bullshit/ffmpeg-concat) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 7 | 8 | ![](https://raw.githubusercontent.com/transitive-bullshit/ffmpeg-concat/master/media/example.gif) 9 | 10 | *9个视频 与 独特过渡 连接在一起的示例* 11 | 12 | *请注意,由于GIF预览,质量和fps很差;[这个](https://raw.githubusercontent.com/transitive-bullshit/ffmpeg-concat/master/media/example.mp4)是源文件* 13 | 14 | ## 介绍 15 | 16 | [FFmpeg](http://ffmpeg.org/)是命令行视频编辑中的事实标准,但使用 非平凡过渡 将视频连接在一起真的很困难. 这里有一些[错综复杂](https://superuser.com/questions/778762/crossfade-between-2-videos-using-ffmpeg) [的例子](https://video.stackexchange.com/questions/17502/concate-two-video-file-with-fade-effect-with-ffmpeg-in-linux)两个视频之间的简单交叉淡入淡出. FFmpeg过滤图非常强大,但是为了实现过渡动画,它们太复杂且容易出错. 17 | 18 | 另一方面,[GL Transitions](https://gl-transitions.com/),是一个伟大的开源由[Gaëtan Renaudeau](https://github.com/gre)倡议,旨在使用 GLSL 建立一个普遍的过渡[集合](https://gl-transitions.com/gallery),它非常简单的规范使得定制现有过渡或编写自己的过渡非常容易,而不是使用复杂的ffmpeg过滤图. 19 | 20 | **使用 gl-transitions 这个模块和CLI轻松地将视频连接在一起.** 21 | 22 | ## 安装 23 | 24 | 这个模块需要[ffmpeg](http://ffmpeg.org/)要安装. 25 | 26 | ```bash 27 | npm install --save ffmpeg-concat 28 | 29 | # 或者 想使用 cli 30 | npm install -g ffmpeg-concat 31 | ``` 32 | 33 | ## CLI 34 | 35 | ```sh 36 | Usage: ffmpeg-concat [options] 37 | 38 | Options: 39 | 40 | -V, --version 输出版本号 41 | -o, --output 要写入的mp4文件的路径(默认值:out.mp4) 42 | -t, --transition-name 要使用的gl-transition名称(默认值:淡入淡出) 43 | -d, --transition-duration 转换持续时间以毫秒为单位(默认值:500) 44 | -T, --transitions json文件加载转换 45 | -f, --frame-format 用于临时帧图像的格式(默认值:raw) 46 | -c, --concurrency 要并行处理的视频数量(默认值:4) 47 | -C, --no-cleanup-frames 禁用清除临时帧图像 48 | -O, --temp-dir 用于存储帧数据的临时工作目录 49 | -h, --help 输出使用信息 50 | 51 | Example: 52 | 53 | ffmpeg-concat -t circleopen -d 750 -o huzzah.mp4 0.mp4 1.mp4 2.mp4 54 | ``` 55 | 56 | ## 用法 57 | 58 | ```js 59 | const concat = require('ffmpeg-concat') 60 | 61 | // 拼接 3 个 mp4s 使用 2 个 500ms directionalWipe 过渡 62 | await concat({ 63 | output: 'test.mp4', 64 | videos: [ 65 | 'media/0.mp4', 66 | 'media/1.mp4', 67 | 'media/2.mp4' 68 | ], 69 | transition: { 70 | name: 'directionalWipe', 71 | duration: 500 72 | } 73 | }) 74 | ``` 75 | 76 | ```js 77 | // 拼接 5 个 mp4 使用 4种不同的过渡 78 | await concat({ 79 | output: 'test.mp4', 80 | videos: [ 81 | 'media/0.mp4', 82 | 'media/1.mp4', 83 | 'media/2.mp4', 84 | 'media/0.mp4', 85 | 'media/1.mp4' 86 | ], 87 | transitions: [ 88 | { 89 | name: 'circleOpen', 90 | duration: 1000 91 | }, 92 | { 93 | name: 'crossWarp', 94 | duration: 800 95 | }, 96 | { 97 | name: 'directionalWarp', 98 | duration: 500, 99 | // 将自定义参数传递给转换 100 | params: { direction: [ 1, -1 ] } 101 | }, 102 | { 103 | name: 'squaresWire', 104 | duration: 2000 105 | } 106 | ] 107 | }) 108 | ``` 109 | 110 | ## API 111 | 112 | ### concat(options) 113 | 114 | 将 视频文件 与 OpenGL过渡 连接在一起. 返回一个`Promise`用于输出视频的时间. 115 | 116 | 请注意,您必须指定`videos`,`output`,或者`transition`要么`transitions`. 117 | 118 | 请注意,输出视频的大小 和 fps 由 第一个输入视频决定. 119 | 120 | #### options 121 | 122 | ##### videos 123 | 124 | 类型: `Array` 125 | **必需** 126 | 127 | 要连接的视频数组,其中每个 item 都是视频文件的路径或URL. 128 | 129 | ##### output 130 | 131 | 类型: `String` 132 | **必需** 133 | 134 | 输出的`mp4`视频文件路径. 135 | 136 | 注意: 我们目前只支持输出到mp4;如果您希望获得更多格式的支持,请打开一个问题. 137 | 138 | ##### transition 139 | 140 | 类型: `Object` 141 | 142 | 指定在每个视频之间使用的默认过渡. 143 | 144 | 请注意,您必须指定其中一个`transition`要么`transitions`,取决于您对每次过渡的控制程度. 如果同时指定,`transitions`优先. 145 | 146 | ```js 147 | // 例 148 | const transition = { 149 | duration: 1000, // ms 150 | name: 'directionalwipe', // 要使用的 gl-transition名称(小写匹配) 151 | params: { direction: [1, -1] } // 可选地覆盖默认参数 152 | } 153 | ``` 154 | 155 | ##### transitions 156 | 157 | 类型: `Array` 158 | 159 | 指定每个视频之间的 (可能唯一的) 过渡. 如果有N个视频,则应该有N-1个过渡. 160 | 161 | 请注意,您必须指定其中一个`transition`要么`transitions`,取决于您对每次过渡的控制程度. 如果同时指定,`transitions`优先. 162 | 163 | ```js 164 | // 例 165 | const transitions = [ 166 | { 167 | duration: 1000, 168 | name: 'fade' 169 | }, 170 | { 171 | duration: 500, 172 | name: 'swap' 173 | } 174 | ] 175 | ``` 176 | 177 | ##### audio 178 | 179 | 类型: `String` 180 | **必需** 181 | 182 | 音频文件的路径或URL,用作 输出视频 的音轨. 183 | 184 | ##### args 185 | 186 | 类型: `Array` 187 | **必需** 188 | 189 | 默认值: `['-c:v', 'libx264', '-profile:v', 'main', '-preset', 'medium', '-crf 20', '-movflags', 'faststart']` 190 | 191 | ##### frameFormat 192 | 193 | 类型: `string`默认: `raw` 194 | 195 | 临时帧图像的格式. 例如,您可以使用`png`要么`jpg`. 196 | 197 | 注意: 出于性能原因默认为`raw`,写入和读取 原始二进制像素数据 比 编码和解码`png`帧快得多. 原始格式很难预览和调试,在另一种情况下,您可能想要更改`frameFormat`至`png`. 198 | 199 | ##### concurrency 200 | 201 | 类型: `Number`默认: `4` 202 | 203 | 要并行处理的最大视频数量. 204 | 205 | ##### log 206 | 207 | 类型: `Function`默认: `noop` 208 | 209 | 用于记录进度和底层ffmpeg命令的可选功能. 例如,您可以使用`console.log` 210 | 211 | ##### cleanupFrames 212 | 213 | 类型: `boolean`默认: `true` 214 | 215 | 默认情况下,我们清理临时帧图像. 如果你需要调试中间结果,将此设置为`false`. 216 | 217 | ##### tempDir 218 | 219 | 类型: `string`默认值: `/tmp`下的随机目录 220 | 221 | 用于存储中间帧数据的临时工作目录. 这是`cleanupFrames`时,帧被保存的位置. 222 | 223 | ## 过渡 224 | 225 | 这里有一些[gl-transitions](https://gl-transitions.com/)我发现对高质量的视频过渡 特别有用: 226 | 227 | - [fade](https://gl-transitions.com/editor/fade) 228 | - [fadegrayscale](https://gl-transitions.com/editor/fadegrayscale) 229 | - [circleopen](https://gl-transitions.com/editor/circleopen) 230 | - [directionalwarp](https://gl-transitions.com/editor/directionalwarp) 231 | - [directionalwipe](https://gl-transitions.com/editor/directionalwipe) 232 | - [crosswarp](https://gl-transitions.com/editor/crosswarp) 233 | - [crosszoom](https://gl-transitions.com/editor/CrossZoom) 234 | - [dreamy](https://gl-transitions.com/editor/Dreamy) 235 | - [squareswire](https://gl-transitions.com/editor/squareswire) 236 | - [angular](https://gl-transitions.com/editor/angular) 237 | - [radial](https://gl-transitions.com/editor/Radial) 238 | - [cube](https://gl-transitions.com/editor/cube) 239 | - [swap](https://gl-transitions.com/editor/swap) 240 | 241 | ## 有关 242 | 243 | - [ffmpeg-gl-transition](https://github.com/transitive-bullshit/ffmpeg-gl-transition)- 用于在视频流之间 应用GLSL过渡 的 低级ffmpeg过滤器 ([gl-transitions](https://gl-transitions.com/)) . 它允许使用更高级和可自定义的过滤器图形,但它需要您构建自定义版本的ffmpeg. 244 | - [gl-transitions](https://gl-transitions.com/)- GLSL过渡的集合. 245 | - [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg)- 底层ffmpeg包装库. 246 | - [awesome-ffmpeg](https://github.com/transitive-bullshit/awesome-ffmpeg)- ffmpeg资源的精选列表,重点关注JavaScript. 247 | 248 | ## 执照 249 | 250 | 麻省理工学院©[Travis Fischer](https://github.com/transitive-bullshit) 251 | 252 | Support my OSS work by following me on twitter twitter 253 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ffmpeg-concat 2 | 3 | > Concats a list of videos together using ffmpeg with sexy OpenGL transitions. 4 | 5 | ![](https://raw.githubusercontent.com/jankozik/ffmpeg-concat/master/media/example.gif) 6 | 7 | *(example of 9 videos concatenated together with unique transitions)* 8 | 9 | ## Intro 10 | 11 | [FFmpeg](http://ffmpeg.org/) is the de facto standard in command-line video editing, but it is really difficult to concatenate videos together using non-trivial transitions. Here are some [convoluted](https://superuser.com/questions/778762/crossfade-between-2-videos-using-ffmpeg) [examples](https://video.stackexchange.com/questions/17502/concate-two-video-file-with-fade-effect-with-ffmpeg-in-linux) of a simple cross-fade between two videos. FFmpeg filter graphs are extremely powerful, but for implementing transitions, they are just too complicated and error-prone. 12 | 13 | [GL Transitions](https://gl-transitions.com/), on the other hand, is a great open source initiative spearheaded by [Gaëtan Renaudeau](https://github.com/gre) that is aimed at using GLSL to establish a universal [collection](https://gl-transitions.com/gallery) of transitions. Its extremely simple spec makes it really easy to customize existing transitions or write your own as opposed to struggling with complex ffmpeg filter graphs. 14 | 15 | **This module and CLI make it easy to concat videos together using gl-transitions.** 16 | 17 | ## Install 18 | 19 | This module requires [ffmpeg](http://ffmpeg.org/) to be installed. 20 | 21 | ```bash 22 | npm install --save ffmpeg-concat 23 | 24 | # or if you want to use the CLI 25 | npm install -g ffmpeg-concat 26 | ``` 27 | 28 | This package runs on Linux, macOS, and Windows. 29 | 30 | Node.js versions 10.13.0 and up are supported. Note (**macOS only**): due to an inadvertant low-level breaking change in libuv's process handling code, OpenGL [is not supported](https://github.com/stackgl/headless-gl#supported-platforms-and-nodejs-versions) when running Node.js version 12.13.1 through to 13.6.0 on macOS. A fix has been released in Node.js version 13.7.0. A fix for 12.x is pending. Other platforms are unaffected. 31 | 32 | ## CLI 33 | 34 | ```sh 35 | Usage: ffmpeg-concat [options] 36 | 37 | Options: 38 | 39 | -V, --version output the version number 40 | -o, --output path to mp4 file to write (default: out.mp4) 41 | -t, --transition-name name of gl-transition to use (default: fade) 42 | -d, --transition-duration duration of transition to use in ms (default: 500) 43 | -T, --transitions json file to load transitions from 44 | -f, --frame-format format to use for temp frame images (default: raw) 45 | -c, --concurrency number of videos to process in parallel (default: 4) 46 | -C, --no-cleanup-frames disables cleaning up temp frame images 47 | -O, --temp-dir temporary working directory to store frame data 48 | -v, --verbose enable verbose logging from FFmpeg 49 | -h, --help output usage information 50 | 51 | Example: 52 | 53 | ffmpeg-concat -t circleopen -d 750 -o huzzah.mp4 0.mp4 1.mp4 2.mp4 54 | ``` 55 | 56 | ## Usage 57 | 58 | ```js 59 | const concat = require('ffmpeg-concat') 60 | 61 | // concat 3 mp4s together using 2 500ms directionalWipe transitions 62 | await concat({ 63 | output: 'test.mp4', 64 | videos: [ 65 | 'media/0.mp4', 66 | 'media/1.mp4', 67 | 'media/2.mp4' 68 | ], 69 | transition: { 70 | name: 'directionalWipe', 71 | duration: 500 72 | } 73 | }) 74 | ``` 75 | 76 | ```js 77 | // concat 5 mp4s together using 4 different transitions 78 | await concat({ 79 | output: 'test.mp4', 80 | videos: [ 81 | 'media/0.mp4', 82 | 'media/1.mp4', 83 | 'media/2.mp4', 84 | 'media/0.mp4', 85 | 'media/1.mp4' 86 | ], 87 | transitions: [ 88 | { 89 | name: 'circleOpen', 90 | duration: 1000 91 | }, 92 | { 93 | name: 'crossWarp', 94 | duration: 800 95 | }, 96 | { 97 | name: 'directionalWarp', 98 | duration: 500, 99 | // pass custom params to a transition 100 | params: { direction: [ 1, -1 ] } 101 | }, 102 | { 103 | name: 'squaresWire', 104 | duration: 2000 105 | } 106 | ] 107 | }) 108 | ``` 109 | 110 | ## API 111 | 112 | ### concat(options) 113 | 114 | Concatenates video files together along with OpenGL transitions. Returns a `Promise` for when the output video has been written. 115 | 116 | Note that you must specify `videos`, `output`, and either `transition` or `transitions`. 117 | 118 | Note that the output video's size and fps are determined by the first input video. 119 | 120 | #### options 121 | 122 | ##### videos 123 | 124 | Type: `Array` 125 | **Required** 126 | 127 | Array of videos to concat, where each item is a path or URL to a video file. 128 | 129 | ##### output 130 | 131 | Type: `String` 132 | **Required** 133 | 134 | Path to an `mp4` video file to write. 135 | 136 | Note: we currently only support outputting to mp4; please open an issue if you'd like to see support for more formats. 137 | 138 | ##### transition 139 | 140 | Type: `Object` 141 | 142 | Specifies a default transition to be used between each video. 143 | 144 | Note that you must specify either `transition` or `transitions`, depending on how much control you want over each transition. If you specify both, `transitions` takes precedence. 145 | 146 | ```js 147 | // example 148 | const transition = { 149 | duration: 1000, // ms 150 | name: 'directionalwipe', // gl-transition name to use (will match with lower-casing) 151 | params: { direction: [1, -1] } // optionally override default parameters 152 | } 153 | ``` 154 | 155 | ##### transitions 156 | 157 | Type: `Array` 158 | 159 | Specifies a (possibly unique) transition between each video. If there are N videos, then there should be N - 1 transitions. 160 | 161 | Note that you must specify either `transition` or `transitions`, depending on how much control you want over each transition. If you specify both, `transitions` takes precedence. 162 | 163 | ```js 164 | // example 165 | const transitions = [ 166 | { 167 | duration: 1000, 168 | name: 'fade' 169 | }, 170 | { 171 | duration: 500, 172 | name: 'swap' 173 | } 174 | ] 175 | ``` 176 | 177 | ##### audio 178 | 179 | Type: `String` 180 | **Optional** 181 | 182 | Path or URL to an audio file to use as the audio track for the output video. 183 | 184 | if parameter is not provided - assuming user wants to concat the source scenes audio. 185 | 186 | ##### args 187 | 188 | Type: `Array` 189 | **Optional** 190 | 191 | Default: `['-c:v', 'libx264', '-profile:v', 'main', '-preset', 'medium', '-crf 20', '-movflags', 'faststart']` 192 | 193 | Array of output-only ffmpeg command line arguments for the final video. 194 | 195 | ##### frameFormat 196 | 197 | Type: `string` 198 | Default: `raw` 199 | 200 | The format for temporary frame images. You may, for example, use `png` or `jpg`. 201 | 202 | Note: the default is `raw` for performance reasons, as writing and reading raw binary pixel data is much faster than encoding and decoding `png` frames. Raw format is difficult to preview and debug, however, in which case you may want to change `frameFormat` to `png`. 203 | 204 | ##### concurrency 205 | 206 | Type: `Number` 207 | Default: `4` 208 | 209 | Max number of videos to process in parallel. 210 | 211 | ##### log 212 | 213 | Type: `Function` 214 | Default: `noop` 215 | 216 | Optional function to log progress and the underlying ffmpeg commands. You may, for example, use `console.log` 217 | 218 | ##### cleanupFrames 219 | 220 | Type: `boolean` 221 | Default: `true` 222 | 223 | By default, we cleanup temporary frame images. Set this to `false` if you need to debug intermediate results. 224 | 225 | ##### tempDir 226 | 227 | Type: `string` 228 | Default: random directory in `/tmp` 229 | 230 | The temporary working directory to store intermediate frame data. This is where the frames in `cleanupFrames` will be saved. 231 | 232 | ## Transitions 233 | 234 | Here are some [gl-transitions](https://gl-transitions.com/) that I've found particularly useful for quality video transitions: 235 | 236 | - [fade](https://gl-transitions.com/editor/fade) 237 | - [fadegrayscale](https://gl-transitions.com/editor/fadegrayscale) 238 | - [circleopen](https://gl-transitions.com/editor/circleopen) 239 | - [directionalwarp](https://gl-transitions.com/editor/directionalwarp) 240 | - [directionalwipe](https://gl-transitions.com/editor/directionalwipe) 241 | - [crosswarp](https://gl-transitions.com/editor/crosswarp) 242 | - [crosszoom](https://gl-transitions.com/editor/CrossZoom) 243 | - [dreamy](https://gl-transitions.com/editor/Dreamy) 244 | - [squareswire](https://gl-transitions.com/editor/squareswire) 245 | - [angular](https://gl-transitions.com/editor/angular) 246 | - [radial](https://gl-transitions.com/editor/Radial) 247 | - [cube](https://gl-transitions.com/editor/cube) 248 | - [swap](https://gl-transitions.com/editor/swap) 249 | --------------------------------------------------------------------------------