Last active
August 29, 2015 14:02
-
-
Save thomasjbradley/acc1fd16cc294f9ad154 to your computer and use it in GitHub Desktop.
Slideshow to DVD: Takes a folder of images and converts them into an MP4 and DVD slideshow.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
/** | |
* # Slideshow to DVD | |
* | |
* Takes a folder of images and converts them into an MP4 and DVD slideshow. | |
* Will resize and crop images to 720p (1280x720). | |
* Expects music to be AAC M4A, though will likely work with other formats using the -m flag. | |
* | |
* ## Installation | |
* | |
* 1. Install Homebrew | |
* 2. brew install node imagemagick ffmpeg dvdauthor | |
* 3. npm install -g commander rimraf multimeter exec-sync ncp | |
* 4. Add to .profile | |
* PATH=/usr/local/share/npm/bin:$PATH | |
* PATH=/usr/local/sbin:$PATH | |
* PATH=/usr/local/bin:$PATH | |
* export PATH | |
* export NODE_PATH=/usr/local/lib/node_modules:/usr/local/share/npm/lib/node_modules:$NODE_PATH | |
*/ | |
var | |
fs = require('fs'), | |
program = require('commander'), | |
rimraf = require('rimraf'), | |
ncp = require('ncp').ncp, | |
exec = require('child_process').exec, | |
execSync = require('exec-sync'), | |
multimeter = require('multimeter'), | |
debug = false, | |
verbose = false, | |
multi = multimeter(process), | |
progressBar, | |
totalSteps = 100, | |
currentStep = 0, | |
path = '', | |
out = '/tmp', | |
createDvd = true, | |
dvd = '/dvd', | |
titlecard = false, | |
files = [], | |
slides = [], | |
music = false, | |
slidetime = 5, | |
fadetime = 0.7, | |
finalfile = 'slideshow', | |
format = '-pix_fmt yuvj420p -c:v libx264 -preset slow', | |
commands = { | |
copy: 'cp "{{path}}/{{img}}" "{{path}}{{out}}/{{img}}"', | |
resize: 'mogrify -quality 92 -resize 1280x720^ -gravity center -extent 1280x720 "{{path}}{{out}}/*.jpg"', | |
fadein: 'ffmpeg -loop 1 -i "{{path}}{{out}}/{{img}}" -vf fade=in:st=0:d={{fadetime}} -t {{slidetime}} {{format}} -y "{{path}}{{out}}/{{index}}-0000.mp4"', | |
single: 'ffmpeg -loop 1 -i "{{path}}{{out}}/{{img}}" -t {{slidetime}} {{format}} -y "{{path}}{{out}}/{{index}}-0000.mp4"', | |
crossfade: 'ffmpeg -loop 1 -i "{{path}}{{out}}/{{img1}}" -loop 1 -i "{{path}}{{out}}/{{img2}}" -filter_complex "[1:v][0:v]blend=all_expr=\'A*(if(gte(T,{{fadetime}}),1,T/{{fadetime}}))+B*(1-(if(gte(T,{{fadetime}}),1,T/{{fadetime}})))\'" -t {{fadetime}} {{format}} -y "{{path}}{{out}}/{{index1}}-{{index2}}.mp4"', | |
fadeout: 'ffmpeg -loop 1 -i "{{path}}{{out}}/{{img}}" -vf fade=out:st={{st}}:d={{fadetime}} -t {{slidetime}} {{format}} -y "{{path}}{{out}}/{{index}}-0000.mp4"', | |
fadein_titlecard: 'ffmpeg -loop 1 -i "{{titlecard}}" -vf fade=in:st=0:d={{fadetime}} -t {{slidetime}} {{format}} -y "{{path}}{{out}}/zzzz-0000.mp4"', | |
fadeout_titlecard: 'ffmpeg -loop 1 -i "{{titlecard}}" -vf fade=out:st={{st}}:d={{fadetime}} -t {{slidetime}} {{format}} -y "{{path}}{{out}}/zzzz-0001.mp4"', | |
concat: 'ffmpeg -f concat -i "{{path}}{{out}}/list.txt" -c copy -y "{{path}}{{out}}/concat.mp4"', | |
audiolength: 'ffmpeg -i "{{audio}}"', | |
audiorepeat: 'ffmpeg {{inputs}} -filter_complex \'{{channels}}concat=n={{total}}:v=0:a=1[out]\' -map \'[out]\' -y "{{path}}{{out}}/audio.m4a"', | |
audiofade: 'ffmpeg -i "{{audio}}" -af afade=t=out:st={{st}}:d={{fadetime}} -t {{length}} -y "{{path}}{{out}}/audio-final.m4a"', | |
audiobind: 'ffmpeg -i "{{path}}{{out}}/concat.mp4" -i "{{path}}{{out}}/audio-final.m4a" -map 0:v -map 1:a -codec copy -y "{{path}}/{{finalfile}}.mp4"', | |
dvdformat: 'ffmpeg -i "{{path}}/{{finalfile}}.mp4" -aspect 16:9 -target ntsc-dvd "{{path}}{{out}}/dvd.mpg"', | |
dvdadd: 'dvdauthor -o "{{path}}{{dvd}}" -t "{{path}}{{out}}/dvd.mpg" -i post="jump chapter 1;"', | |
dvdcreate: 'export VIDEO_FORMAT=NTSC && dvdauthor -o "{{path}}{{dvd}}" -T' | |
} | |
; | |
var writeIfVerbose = function (text) { | |
if (verbose) { | |
process.stdout.write(text); | |
} | |
}; | |
var determineSteps = function () { | |
var base = 9 + ((slides.length * 2) - 3); | |
if (music) { | |
base += 3; | |
} | |
if (createDvd) { | |
base += 3; | |
} | |
if (!debug) { | |
base += 1; | |
} | |
totalSteps = base; | |
}; | |
var updateProgress = function () { | |
currentStep++; | |
if (typeof progressBar !== 'undefined') { | |
progressBar.percent(Math.round((currentStep / totalSteps) * 100)); | |
} | |
}; | |
var getVideoLength = function () { | |
return (slides.length * slidetime) + (fadetime * slides.length); | |
}; | |
var getAudioLength = function () { | |
var | |
cmd = getCommand('audiolength').replace('{{audio}}', music), | |
raw = execSync(cmd, true), | |
matcher = /Duration\: \d\d\:(\d\d\:\d\d\.\d\d)\,/, | |
dur = raw.stderr.match(matcher)[1].split(':'), | |
min = parseInt(dur[0], 10) * 60, | |
total = min + parseFloat(dur[1]) | |
; | |
return total; | |
}; | |
var getCommand = function (cmd, opts) { | |
var opts = opts || {}; | |
return commands[cmd] | |
.replace('{{format}}', format) | |
.replace(/\{\{path\}\}/g, opts.path || path) | |
.replace(/\{\{out\}\}/g, opts.out || out) | |
.replace(/\{\{slidetime\}\}/g, opts.slidetime || slidetime) | |
.replace(/\{\{fadetime\}\}/g, opts.fadetime || fadetime) | |
.replace('{{finalfile}}', finalfile) | |
.replace(/\{\{dvd\}\}/g, dvd) | |
; | |
} | |
var prePad = function (text) { | |
return ('0000000000' + text).substr(-4); | |
}; | |
var prepareDirectory = function () { | |
writeIfVerbose('Preparing directory ... '); | |
rimraf.sync(path + '/' + finalfile + '.mp4') | |
rimraf.sync(path + out); | |
fs.mkdirSync(path + out); | |
rimraf.sync(path + dvd); | |
if (createDvd) { | |
fs.mkdirSync(path + dvd); | |
} | |
updateProgress(); | |
writeIfVerbose('done.\n'); | |
}; | |
var findFiles = function () { | |
files = fs.readdirSync(path).sort() | |
writeIfVerbose('Finding files ... '); | |
files.forEach(function (item, index) { | |
if (!music && item.substr(-3) === 'm4a') { | |
music = path + '/' + item; | |
} | |
if (item.substr(-3) === 'jpg') { | |
slides.push(item); | |
} | |
}); | |
updateProgress(); | |
writeIfVerbose('done.\n'); | |
}; | |
var copyImages = function () { | |
var cmd = getCommand('copy'); | |
writeIfVerbose('Copying files ... '); | |
slides.forEach(function (item, index) { | |
execSync(cmd.replace(/\{\{img\}\}/g, item)); | |
}); | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
resizeImages(); | |
}; | |
var resizeImages = function () { | |
var cmd = getCommand('resize'); | |
writeIfVerbose('Resizing images ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done\n'); | |
updateProgress(); | |
fadeIn(); | |
}); | |
}; | |
var fadeIn = function () { | |
var cmd = getCommand('fadein'); | |
cmd = cmd | |
.replace('{{img}}', slides[0]) | |
.replace('{{index}}', prePad(0)) | |
; | |
writeIfVerbose('Fading in: ' + slides[0] + ' ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
buildSingle(1); | |
}); | |
}; | |
var buildSingle = function (index) { | |
var cmd = getCommand('single'); | |
cmd = cmd | |
.replace('{{img}}', slides[index]) | |
.replace('{{index}}', prePad(index)) | |
; | |
writeIfVerbose('Building: ' + slides[index] + ' ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
if (index + 1 > slides.length - 1) { | |
buildTransition(0); | |
} else { | |
buildSingle(index + 1); | |
} | |
}); | |
}; | |
var buildTransition = function (index) { | |
var | |
next = index + 1, | |
cmd = getCommand('crossfade') | |
; | |
if (next > slides.length - 1) { | |
fadeOut(index); | |
return; | |
} | |
cmd = cmd | |
.replace('{{img1}}', slides[index]) | |
.replace('{{img2}}', slides[next]) | |
.replace('{{index1}}', prePad(index)) | |
.replace('{{index2}}', prePad(next)) | |
; | |
writeIfVerbose('Transitioning: ' + slides[index] + ' to ' + slides[next] + ' ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
buildTransition(next); | |
}); | |
}; | |
var fadeOut = function (index) { | |
var cmd = getCommand('fadeout'); | |
cmd = cmd | |
.replace('{{img}}', slides[index]) | |
.replace('{{index}}', prePad(index)) | |
.replace('{{st}}', slidetime - fadetime) | |
; | |
writeIfVerbose('Fading out: ' + slides[index] + ' ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
if (titlecard) { | |
fadeInTitleCard(); | |
return; | |
} | |
concat(); | |
}); | |
}; | |
var fadeInTitleCard = function () { | |
var cmd = getCommand('fadein_titlecard', { | |
slidetime: Math.round(slidetime / 2) | |
}); | |
cmd = cmd | |
.replace('{{titlecard}}', titlecard) | |
; | |
writeIfVerbose('Fading in title card: ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
fadeOutTitleCard(); | |
}); | |
}; | |
var fadeOutTitleCard = function () { | |
var | |
newSlideTime = Math.round(slidetime / 2), | |
cmd = getCommand('fadeout_titlecard', { | |
slidetime: newSlideTime | |
}); | |
cmd = cmd | |
.replace('{{titlecard}}', titlecard) | |
.replace('{{st}}', newSlideTime - (1 + fadetime)) | |
; | |
writeIfVerbose('Fading out title card: ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
concat(); | |
}); | |
}; | |
var prepFileForList = function (file) { | |
return "file '" + path + out + "/" + file + "'"; | |
}; | |
var concat = function () { | |
var | |
allFiles = [], | |
goodFiles = [], | |
concat = getCommand('concat') | |
; | |
writeIfVerbose('Joining ... '); | |
allFiles = fs.readdirSync(path + out).sort(); | |
allFiles.forEach(function (item) { | |
if (item.substr(-3) === 'mp4') { | |
goodFiles.push(prepFileForList(item)); | |
} | |
}); | |
fs.writeFileSync(path + out + '/list.txt', goodFiles.join('\n')); | |
exec(concat, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
if (music) { | |
prepareAudio(); | |
return; | |
} | |
if (createDvd) { | |
dvdFormat(); | |
return; | |
} | |
finalize(); | |
}); | |
}; | |
var getInputs = function (total) { | |
var i, out = ''; | |
for (i = 0; i <= total; i++) { | |
out += ' -i "' + music + '"'; | |
} | |
return out; | |
}; | |
var getChannels = function (total) { | |
var i, out = ''; | |
for (i = 0; i <= total - 1; i++) { | |
out += '[' + i + ':0]'; | |
} | |
return out; | |
} | |
var prepareAudio = function () { | |
var | |
vidLength = getVideoLength(), | |
audLength = getAudioLength(), | |
reps = Math.ceil(vidLength / audLength), | |
cmd = getCommand('audiorepeat') | |
; | |
writeIfVerbose('Repeating audio ... '); | |
if (reps === 1) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
fadeAudio(music); | |
} else { | |
cmd = cmd | |
.replace('{{total}}', reps) | |
.replace('{{channels}}', getChannels(reps)) | |
.replace('{{inputs}}', getInputs(reps)) | |
; | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
fadeAudio(path + out + '/audio.m4a'); | |
}); | |
} | |
}; | |
var fadeAudio = function (audio) { | |
var | |
vidLength = getVideoLength(), | |
cmd = getCommand('audiofade') | |
; | |
cmd = cmd | |
.replace('{{audio}}', audio) | |
.replace('{{st}}', (vidLength - fadetime).toFixed(1)) | |
.replace('{{length}}', vidLength) | |
; | |
writeIfVerbose('Fading audio ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
bindAudio(); | |
}); | |
}; | |
var bindAudio = function () { | |
var cmd = getCommand('audiobind'); | |
writeIfVerbose('Binding audio ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
if (createDvd) { | |
dvdFormat(); | |
return; | |
} | |
finalize(); | |
}); | |
}; | |
var dvdFormat = function () { | |
var cmd = getCommand('dvdformat'); | |
writeIfVerbose('Converting to DVD video ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
dvdAdd(); | |
}); | |
}; | |
var dvdAdd = function () { | |
var cmd = getCommand('dvdadd'); | |
writeIfVerbose('Adding to DVD ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
dvdCreate(); | |
}); | |
}; | |
var dvdCreate = function () { | |
var cmd = getCommand('dvdcreate'); | |
writeIfVerbose('Creating DVD ... '); | |
exec(cmd, function (err, stdout, stderr) { | |
writeIfVerbose('done.\n'); | |
updateProgress(); | |
finalize(); | |
}); | |
}; | |
var finalize = function () { | |
if (!debug) { | |
writeIfVerbose('Deleting temporary folder.\n'); | |
updateProgress(); | |
rimraf.sync(path + out); | |
} | |
writeIfVerbose('Done.\n'); | |
multi.write('\n').destroy(); | |
}; | |
program | |
.version('1.0.0') | |
.usage('[options] <folder>') | |
.option('-m, --music <file>', 'location of the music .m4a') | |
.option('-t, --titlecard <file>', 'location of the title card') | |
.option('-s, --slidetime <length>', 'the length each slide should play') | |
.option('-f, --fadetime <length>', 'the length of the fade transitions') | |
.option('--no-dvd', "don't create the DVD version") | |
.option('-v, --verbose', 'display constant status messages') | |
.option('-d, --debug', "debug mode: don't delete temporary files at the end") | |
program | |
.command('*') | |
.description('Convert a series of JPEGs into a video slideshow and DVD.') | |
.action(function (env) { | |
path = env; | |
debug = program.debug || false; | |
verbose = program.verbose || false; | |
titlecard = program.titlecard || false; | |
slidetime = program.slidetime || 5; | |
fadetime = program.fadetime || 0.7; | |
music = program.music || false; | |
createDvd = program.dvd; | |
prepareDirectory(); | |
findFiles(); | |
determineSteps(); | |
console.log('§ Slides: ' + slides.length + '; Music: ' + music.replace(path + '/', '')); | |
if (!verbose) { | |
multi.drop({ width: 40 }, function (bar) { | |
progressBar = bar; | |
}); | |
} | |
copyImages(); | |
}) | |
program.parse(process.argv) | |
if (program.args.length === 0) { | |
program.help(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment