diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..69e01bc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +charset = utf-8 +indent_style = tabs +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.sh] +insert_final_newline = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4c2d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +npm-debug.log \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..a843dc4 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +test/fixtures diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7ab627a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: node_js +node_js: + - "0.10" + - "0.12" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ebe5eed --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +MOCHA = ./node_modules/.bin/mocha + +default: all +all: test +test: mocha + +mocha: + $(MOCHA) --harmony --timeout 300000 --reporter spec --ui tdd + +publish: test + git push --tags origin HEAD:master + npm publish + +loc: + wc -l lib/* diff --git a/README.md b/README.md index 6a46e1f..c7a5fa3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,110 @@ -# photoshow +# videoshow [![Build Status](https://api.travis-ci.org/h2non/videoshow.svg?branch=master)][travis] [![Dependency Status](https://gemnasium.com/h2non/videoshow.svg)][gemnasium] [![Code Climate](https://codeclimate.com/github/h2non/videoshow/badges/gpa.svg)](https://codeclimate.com/github/h2non/videoshow) [![NPM](https://img.shields.io/npm/v/videoshow.svg)][npm] -Funny programmatic interface to create video slides from images using ffmpeg and node.js/io.js +Simple programmatic interface for node/io.js to create basic video slides from images using [ffmpeg](http://ffmpeg.org) + +With `videoshow` you to create videos form images with audio, subtitles and fade-in/out transitions. +Take a look to the [examples](https://github.com/h2non/videoshow/tree/master/examples) to see the supported features + +Still beta + +## Requirements + +- **[ffmpeg](http://ffmpeg.org)** with additional compilation flags `--enable-libass --enable-libmp3lame --enable-libx264` + +## Installation + +```bash +npm install videoshow +``` + +## Usage + +```js +var videoshow = require('videoshow') + +var images = [ + 'step1.jpg', + 'step2.jpg', + 'step3.jpg', + 'step4.jpg' +] + +var videoOptions = { + fps: 25, + loop: 5, // seconds + videoBitrate: 1024, + videoCodec: 'libx264', + size: '640x?', + audioBitrate: '128k', + audioChannels: 2, + format: 'mp4' +} + +videoshow(images, videoOptions) + .audio('song.mp3') + .save('video.mp4') +``` + +## API + + +#### videoshow([ images ], [ options ]) +Return: `Videoshow` + +Videoshow constructor. You should pass an `array` or `array` or `array` with the desired images, +and optionally passing the video render `options` object per each image + +```js +videoshow([ 'image1.jpg', 'image2.jpg', 'image']) + .save('video.mp4') + .on('error', function () {}) + .on('end', function () {}) +``` + +##### videoshow#audio(path) + +Define the audio file path to use. It supports multiple formats and codecs such as `acc`, `mp3` or `ogg` + +##### videoshow#subtitles(path) + +Define the [SubRip subtitles](http://en.wikipedia.org/wiki/SubRip#SubRip_text_file_format) +file path to load. It should be a `.srt` file + +##### videoshow#save(path) +Return: `EventEmitter` + +Render and write the final video in the given path + +##### videoshow#filter(filter) + +Add custom filter to the video + +##### videoshow#imageOptions(options) + +Add specific image rendering options + +##### videoshow#options(options) + +Add custom video rendering options + +##### videoshow#flag(argument) + +Add a custom CLI flag to pass to `ffmpeg` + +#### videoshow.VERSION +Type: `string` + +Current package semantic version + +#### videoshow.ffmpeg +Type: `function` + +[fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) API constructor ## License -MIT - Tomas Aparicio and contributors +[MIT](http://opensource.org/licenses/MIT) © Tomas Aparicio + +[travis]: http://travis-ci.org/h2non/videoshow +[gemnasium]: https://gemnasium.com/h2non/videoshow +[npm]: http://npmjs.org/package/videoshow diff --git a/examples/audio.js b/examples/audio.js new file mode 100644 index 0000000..7444403 --- /dev/null +++ b/examples/audio.js @@ -0,0 +1,21 @@ +var videoshow = require('../') + +var audio = __dirname + '/../test/fixtures/song.mp3' + +var images = [ + __dirname + '/../test/fixtures/step_1.png', + __dirname + '/../test/fixtures/step_2.png', + __dirname + '/../test/fixtures/step_3.png', + __dirname + '/../test/fixtures/step_4.png', + __dirname + '/../test/fixtures/step_5.png' +] + +videoshow(images) + .save('audio.mp4') + .audio(audio) + .on('error', function (err) { + console.error('Error:', err) + }) + .on('end', function (output) { + console.log('Video created in:', output) + }) diff --git a/examples/basic.js b/examples/basic.js new file mode 100644 index 0000000..e311cc9 --- /dev/null +++ b/examples/basic.js @@ -0,0 +1,18 @@ +var videoshow = require('../') + +var images = [ + __dirname + '/../test/fixtures/step_1.png', + __dirname + '/../test/fixtures/step_2.png', + __dirname + '/../test/fixtures/step_3.png', + __dirname + '/../test/fixtures/step_4.png', + __dirname + '/../test/fixtures/step_5.png' +] + +videoshow(images) + .save('test.mp4') + .on('error', function (err) { + console.error('Error:', err) + }) + .on('end', function (output) { + console.log('Video created in:', output) + }) diff --git a/examples/subtitles.js b/examples/subtitles.js new file mode 100644 index 0000000..932d747 --- /dev/null +++ b/examples/subtitles.js @@ -0,0 +1,22 @@ +var videoshow = require('../') + +var subtitles = __dirname + '/../test/fixtures/subtitles.srt' +var audio = __dirname + '/../test/fixtures/song.mp3' + +var images = [ + __dirname + '/../test/fixtures/step_1.png', + __dirname + '/../test/fixtures/step_2.png', + __dirname + '/../test/fixtures/step_3.png', + __dirname + '/../test/fixtures/step_4.png', + __dirname + '/../test/fixtures/step_5.png' +] + +videoshow(images) + .save('audio.mp4') + .audio(audio) + .on('error', function (err) { + console.error('Error:', err) + }) + .on('end', function (output) { + console.log('Video created in:', output) + }) diff --git a/lib/copy.js b/lib/copy.js new file mode 100644 index 0000000..46b82d1 --- /dev/null +++ b/lib/copy.js @@ -0,0 +1,8 @@ +var fs = require('fs') + +module.exports = function copy(src, dest, cb) { + fs.createReadStream(src) + .on('error', cb) + .on('end', cb) + .pipe(fs.createWriteStream(dest)) +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..496f9ee --- /dev/null +++ b/lib/index.js @@ -0,0 +1,12 @@ +var ffmpeg = require('fluent-ffmpeg') +var Videoshow = require('./videoshow') +var pkg = require('../package.json') + +module.exports = videoshow + +function videoshow(images, options) { + return new Videoshow(images, options) +} + +videoshow.VERSION = pkg.version +videoshow.ffmpeg = ffmpeg diff --git a/lib/merge.js b/lib/merge.js new file mode 100644 index 0000000..2eebe3e --- /dev/null +++ b/lib/merge.js @@ -0,0 +1,25 @@ +var os = require('os') +var ffmpeg = require('fluent-ffmpeg') + +module.exports = merge + +function merge(parts, output, options) { + parts = parts.slice() + var video = ffmpeg(parts.shift()) + + parts.forEach(function (part) { + video.input(part) + }) + + if (options) { + Object.keys(options).forEach(function (key) { + if (typeof video[key] === 'function') { + video[key](options[key]) + } + }) + } + + video.mergeToFile(output, os.tmpdir()) + + return video +} diff --git a/lib/options.js b/lib/options.js new file mode 100644 index 0000000..e6bb2a9 --- /dev/null +++ b/lib/options.js @@ -0,0 +1,18 @@ +var merge = require('lodash.merge') + +var defaults = { + fps: 25, + loop: 5, + videoBitrate: 1024, + videoCodec: 'libx264', + size: '640x?', + audioBitrate: '128k', + audioChannels: 2, + format: 'mp4' +} + +exports = module.exports = function (options) { + return merge({}, defaults, options) +} + +exports.defaults = defaults diff --git a/lib/save.js b/lib/save.js new file mode 100644 index 0000000..2d77cf1 --- /dev/null +++ b/lib/save.js @@ -0,0 +1,158 @@ +var fs = require('fs') +var fw = require('fw') +var os = require('os') +var path = require('path') +var uuid = require('lil-uuid') +var union = require('lodash.merge') +var ffmpeg = require('fluent-ffmpeg') +var EventEmitter = require('events').EventEmitter + +var copy = require('./copy') +var video = require('./video') +var merge = require('./merge') +var defaults = require('./options').defaults + +module.exports = save + +function save(videoshow, output) { + var bus = new EventEmitter + + process.nextTick(function () { + render(videoshow, output, bus) + }) + + return bus +} + +function render(videoshow, output, bus) { + var images = videoshow.images + var params = videoshow.params + var options = videoshow.videoParams + + var jobs = convertImages(images, params) + var process = mergeParts(bus, options, output).bind(videoshow) + + fw.series(jobs, process) +} + +function convertImages(images, params) { + return images.map(function (image) { + var output = randomName() + return function (done) { + video(image, params, output) + .on('error', done) + .on('end', function () { + done(null, output) + }) + } + }) +} + +function mergeParts(bus, options, output) { + var forwardEvent = proxyEvent(bus) + + return function (err, images) { + if (err) return bus.emit('error', err) + + var cleanup = mergeHandler(images, output, forwardEvent) + var end = cleanup('end') + var error = cleanup('error') + + var audio = this.audioFile + var subtitles = this.subtitlesPath + + merge(images, output, options) + .on('start', forwardEvent('start')) + .on('codecData', forwardEvent('codecData')) + .on('progress', forwardEvent('progress')) + .on('error', error) + .on('end', function () { + if (audio) { + addAudio(images, audio, subtitles, end, error) + } else { + end() + } + }) + } + + function addAudio(images, audio, subtitles, end, error) { + renderAudio(output, audio, images, subtitles, function (err) { + if (err) return error(err) + end() + }) + } +} + +function renderAudio(video, audio, images, subtitles, cb) { + var length = calculateVideoLength(images) + + var options = [ + '-map 0:0', + '-map 1:0', + '-t ' + length + ] + + if (subtitles) { + options.push('-vf subtitles=' + subtitles) + } + + renderVideo(video, audio, options, cb) +} + +function renderVideo(video, input, options, cb) { + var output = randomName() + + copy(video, output, function (err) { + if (err) return cb(err) + + ffmpeg(output) + .input(input) + .outputOptions(options) + .on('error', end) + .on('end', end) + .save(video) + }) + + function end(err) { + fs.unlink(output, function () { + cb(err) + }) + } +} + +function mergeHandler(images, output, forwardEvent) { + return function cleanup(event) { + var forward = forwardEvent(event) + return function (err) { + removeFiles(images, function () { + forward(event === 'error' ? err : output) + }) + } + } +} + +function proxyEvent(bus) { + return function (event) { + return function (value) { + bus.emit(event, value) + } + } +} + +function removeFiles(files, cb) { + fw.parallel(files.map(function (file) { + return function (done) { + fs.unlink(file, done) + } + }), cb) +} + +function calculateVideoLength(images) { + return images.reduce(function (acc, image) { + return acc + (image.loop || defaults.loop) + }, 0) +} + +function randomName() { + return path.join(os.tmpdir(), 'videoshow-' + uuid()) +} diff --git a/lib/video.js b/lib/video.js new file mode 100644 index 0000000..5af1da7 --- /dev/null +++ b/lib/video.js @@ -0,0 +1,25 @@ +var ffmpeg = require('fluent-ffmpeg') +var options = require('./options') + +module.exports = video + +function video(image, params, output) { + var video = ffmpeg(image.path || image) + params = options(params) + + Object.keys(params).forEach(function (key) { + if (typeof video[key] === 'function') { + video[key](params[key]) + } + }) + + if (typeof image === 'object') { + if (image.filter || image.filters) { + video.videoFilters(image.filter || image.filters) + } + } + + video.save(output) + + return video +} diff --git a/lib/videoshow.js b/lib/videoshow.js new file mode 100644 index 0000000..3c3aaf1 --- /dev/null +++ b/lib/videoshow.js @@ -0,0 +1,51 @@ +var union = require('lodash.merge') +var save = require('./save') + +module.exports = Videoshow + +function Videoshow(images, params) { + this.images = images || [] + this.params = params || {} + this.videoParams = videoParams() +} + +Videoshow.prototype.audio = function (path) { + this.audioFile = path + return this +} + +Videoshow.prototype.subtitles = function (path) { + this.subtitlesPath = path + return this +} + +Videoshow.prototype.filter = function (filter) { + this.videoParams.videoFilters.push(filter) + return this +} + +Videoshow.prototype.imageOptions = function (params) { + union(this.imageOptions, params) + return this +} + +Videoshow.prototype.options = function (options) { + union(this.videoParams, options) + return this +} + +Videoshow.prototype.flag = function (flag) { + this.videoParams.outputOptions.push(flag) + return this +} + +Videoshow.prototype.save = function (output) { + return save(this, output) +} + +function videoParams() { + return { + videoFilters: [], + outputOptions: [] + } +} diff --git a/package.json b/package.json index 27173f0..4d954a8 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,47 @@ { - "name": "photoshow", - "version": "0.1.0" + "name": "videoshow", + "version": "0.1.0-beta.0", + "description": "Simple programmatic interface to create video slides from images", + "engineStrict": true, + "repository": "h2non/videoshow", + "author": "Tomas Aparicio", + "license": "MIT", + "main": "lib", + "directories": { + "lib": "./lib" + }, + "engines": { + "node": ">= 0.10.0" + }, + "scripts": { + "test": "make test" + }, + "keywords": [ + "video", + "photo", + "slide", + "slides", + "image", + "show", + "ffmpeg", + "mpeg", + "avi", + "xvid", + "compose", + "slider", + "transition", + "photos", + "moviemaker" + ], + "dependencies": { + "fluent-ffmpeg": "^2.0.0-rc3", + "fw": "^0.1.2", + "lil-uuid": "^0.1.0", + "lodash.merge": "^3.0.1" + }, + "devDependencies": { + "chai": "^1.10.0", + "mocha": "^2.0.1", + "rimraf": "^2.2.8" + } } diff --git a/test/.tmp/test.mp4 b/test/.tmp/test.mp4 new file mode 100644 index 0000000..eff3cdd Binary files /dev/null and b/test/.tmp/test.mp4 differ diff --git a/test/.tmp/test2.mp4 b/test/.tmp/test2.mp4 new file mode 100644 index 0000000..5174452 Binary files /dev/null and b/test/.tmp/test2.mp4 differ diff --git a/test/.tmp/test3.mp4 b/test/.tmp/test3.mp4 new file mode 100644 index 0000000..0b08852 Binary files /dev/null and b/test/.tmp/test3.mp4 differ diff --git a/test/.tmp/test4.mp4 b/test/.tmp/test4.mp4 new file mode 100644 index 0000000..7da074e Binary files /dev/null and b/test/.tmp/test4.mp4 differ diff --git a/test/.tmp/test5.mp4 b/test/.tmp/test5.mp4 new file mode 100644 index 0000000..9a5d7e8 Binary files /dev/null and b/test/.tmp/test5.mp4 differ diff --git a/test/fixtures/norris.gif b/test/fixtures/norris.gif new file mode 100644 index 0000000..bb7b01f Binary files /dev/null and b/test/fixtures/norris.gif differ diff --git a/test/fixtures/song.aac b/test/fixtures/song.aac new file mode 100644 index 0000000..73daa02 Binary files /dev/null and b/test/fixtures/song.aac differ diff --git a/test/fixtures/song.mp3 b/test/fixtures/song.mp3 new file mode 100644 index 0000000..d81bbf1 Binary files /dev/null and b/test/fixtures/song.mp3 differ diff --git a/test/fixtures/song.ogg b/test/fixtures/song.ogg new file mode 100644 index 0000000..35f21e4 Binary files /dev/null and b/test/fixtures/song.ogg differ diff --git a/test/fixtures/step_1.png b/test/fixtures/step_1.png new file mode 100644 index 0000000..7a1fc60 Binary files /dev/null and b/test/fixtures/step_1.png differ diff --git a/test/fixtures/step_2.png b/test/fixtures/step_2.png new file mode 100644 index 0000000..27ac40f Binary files /dev/null and b/test/fixtures/step_2.png differ diff --git a/test/fixtures/step_3.png b/test/fixtures/step_3.png new file mode 100644 index 0000000..9a4f591 Binary files /dev/null and b/test/fixtures/step_3.png differ diff --git a/test/fixtures/step_4.png b/test/fixtures/step_4.png new file mode 100644 index 0000000..aa538c8 Binary files /dev/null and b/test/fixtures/step_4.png differ diff --git a/test/fixtures/step_5.png b/test/fixtures/step_5.png new file mode 100644 index 0000000..b5861c2 Binary files /dev/null and b/test/fixtures/step_5.png differ diff --git a/test/fixtures/subtitles.srt b/test/fixtures/subtitles.srt new file mode 100644 index 0000000..ade69c3 --- /dev/null +++ b/test/fixtures/subtitles.srt @@ -0,0 +1,16 @@ +1 +00:00:00,394 --> 00:00:05,031 +Hello World using +“subtitles” + +2 +00:00:06,000 --> 00:00:09,099 +Long slide step description testing: Lorem ipsum ad his scripta blandit partiendo, eum fastidii accumsan euripidis in, eum liber hendrerit an. + +3 +00:00:10,000 --> 00:00:14,099 +Step 3 description"norris" + +4 +00:00:15,000 --> 00:00:19,099 +Step 4 description"jackie" diff --git a/test/videoshow.js b/test/videoshow.js new file mode 100644 index 0000000..09ad7f9 --- /dev/null +++ b/test/videoshow.js @@ -0,0 +1,78 @@ +var fs = require('fs') +var rm = require('rimraf') +var expect = require('chai').expect +var videoshow = require('../') +var TMP = 'test/.tmp' + +suite('videoshow', function () { + var images = [ + 'test/fixtures/step_1.png', + 'test/fixtures/step_2.png', + 'test/fixtures/step_3.png', + 'test/fixtures/step_4.png', + 'test/fixtures/step_5.png' + ] + + before(function () { + rm.sync(TMP) + }) + + before(function () { + fs.mkdirSync(TMP) + }) + + test('create video with images', function (done) { + videoshow(images) + .save(TMP + '/test.mp4') + .on('error', done) + .on('end', function (output) { + expect(fs.existsSync(output)).to.be.true + done() + }) + }) + + test('create video with audio', function (done) { + videoshow(images) + .audio(__dirname + '/fixtures/song.aac') + .save(TMP + '/test2.mp4') + .on('error', done) + .on('end', function (output) { + expect(fs.existsSync(output)).to.be.true + done() + }) + }) + + test('create video with subtitles', function (done) { + videoshow(images) + .audio(__dirname + '/fixtures/song.aac') + .subtitles(__dirname + '/fixtures/subtitles.srt') + .save(TMP + '/test3.mp4') + .on('error', done) + .on('end', function (output) { + expect(fs.existsSync(output)).to.be.true + done() + }) + }) + + test('create video with mp3 audio', function (done) { + videoshow(images) + .audio(__dirname + '/fixtures/song.mp3') + .save(TMP + '/test4.mp4') + .on('error', done) + .on('end', function (output) { + expect(fs.existsSync(output)).to.be.true + done() + }) + }) + + test('create video with ogg audio', function (done) { + videoshow(images) + .audio(__dirname + '/fixtures/song.ogg') + .save(TMP + '/test5.mp4') + .on('error', done) + .on('end', function (output) { + expect(fs.existsSync(output)).to.be.true + done() + }) + }) +})