Created
March 9, 2020 17:25
-
-
Save kadamwhite/e79d02b3707852443b1e6c10f0082b91 to your computer and use it in GitHub Desktop.
Gif conversion script
This file contains hidden or 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 | |
/* | |
This script will clip a segment of a video file into a gif, using techniques | |
described in these resources: | |
https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/ | |
https://video.stackexchange.com/questions/4563/how-can-i-crop-a-video-with-ffmpeg | |
https://askubuntu.com/questions/648603/how-to-create-an-animated-gif-from-mp4-video-via-command-line | |
https://trac.ffmpeg.org/wiki/HowToBurnSubtitlesIntoVideo | |
*/ | |
const util = require( 'util' ); | |
const exec = util.promisify( require( 'child_process' ).exec ); | |
const { resolve } = require( 'path' ); | |
// Pad all output with a leading space. | |
console.log(); | |
// Minified version of https://github.com/substack/minimist | |
function parseArgs(n,t){t||(t={});var o={bools:{},strings:{},unknownFn:null};"function"==typeof t.unknown&&(o.unknownFn=t.unknown),"boolean"==typeof t.boolean&&t.boolean?o.allBools=!0:[].concat(t.boolean).filter(Boolean).forEach(function(n){o.bools[n]=!0});var e={};Object.keys(t.alias||{}).forEach(function(n){e[n]=[].concat(t.alias[n]),e[n].forEach(function(t){e[t]=[n].concat(e[n].filter(function(n){return t!==n}))})}),[].concat(t.string).filter(Boolean).forEach(function(n){o.strings[n]=!0,e[n]&&(o.strings[e[n]]=!0)});var s=t.default||{},i={_:[]};Object.keys(o.bools).forEach(function(n){a(n,void 0!==s[n]&&s[n])});var r=[];function a(n,t,s){if(!s||!o.unknownFn||(r=n,a=s,o.allBools&&/^--[^=]+$/.test(a)||o.strings[r]||o.bools[r]||e[r])||!1!==o.unknownFn(s)){var r,a,f=!o.strings[n]&&isNumber(t)?Number(t):t;l(i,n.split("."),f),(e[n]||[]).forEach(function(n){l(i,n.split("."),f)})}}function l(n,t,e){var s=n;t.slice(0,-1).forEach(function(n){void 0===s[n]&&(s[n]={}),s=s[n]});var i=t[t.length-1];void 0===s[i]||o.bools[i]||"boolean"==typeof s[i]?s[i]=e:Array.isArray(s[i])?s[i].push(e):s[i]=[s[i],e]}function f(n){return e[n].some(function(n){return o.bools[n]})}-1!==n.indexOf("--")&&(r=n.slice(n.indexOf("--")+1),n=n.slice(0,n.indexOf("--")));for(var c=0;c<n.length;c++){var u=n[c];if(/^--.+=/.test(u)){var b=u.match(/^--([^=]+)=([\s\S]*)$/),h=b[1],p=b[2];o.bools[h]&&(p="false"!==p),a(h,p,u)}else if(/^--no-.+/.test(u)){a(h=u.match(/^--no-(.+)/)[1],!1,u)}else if(/^--.+/.test(u)){h=u.match(/^--(.+)/)[1];void 0===(k=n[c+1])||/^-/.test(k)||o.bools[h]||o.allBools||e[h]&&f(h)?/^(true|false)$/.test(k)?(a(h,"true"===k,u),c++):a(h,!o.strings[h]||"",u):(a(h,k,u),c++)}else if(/^-[^-]+/.test(u)){for(var v=u.slice(1,-1).split(""),d=!1,g=0;g<v.length;g++){var k;if("-"!==(k=u.slice(g+2))){if(/[A-Za-z]/.test(v[g])&&/=/.test(k)){a(v[g],k.split("=")[1],u),d=!0;break}if(/[A-Za-z]/.test(v[g])&&/-?\d+(\.\d*)?(e-?\d+)?$/.test(k)){a(v[g],k,u),d=!0;break}if(v[g+1]&&v[g+1].match(/\W/)){a(v[g],u.slice(g+2),u),d=!0;break}a(v[g],!o.strings[v[g]]||"",u)}else a(v[g],k,u)}h=u.slice(-1)[0];d||"-"===h||(!n[c+1]||/^(-|--)[^-]/.test(n[c+1])||o.bools[h]||e[h]&&f(h)?n[c+1]&&/true|false/.test(n[c+1])?(a(h,"true"===n[c+1],u),c++):a(h,!o.strings[h]||"",u):(a(h,n[c+1],u),c++))}else if(o.unknownFn&&!1===o.unknownFn(u)||i._.push(o.strings._||!isNumber(u)?u:Number(u)),t.stopEarly){i._.push.apply(i._,n.slice(c+1));break}}return Object.keys(s).forEach(function(n){hasKey(i,n.split("."))||(l(i,n.split("."),s[n]),(e[n]||[]).forEach(function(t){l(i,t.split("."),s[n])}))}),t["--"]?(i["--"]=new Array,r.forEach(function(n){i["--"].push(n)})):r.forEach(function(n){i._.push(n)}),i}function hasKey(n,t){var o=n;return t.slice(0,-1).forEach(function(n){o=o[n]||{}}),t[t.length-1]in o}function isNumber(n){return"number"==typeof n||(!!/^0x[0-9a-f]+$/i.test(n)||/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$/.test(n))} | |
const args = parseArgs( process.argv, { | |
alias: { | |
crop: 'c', | |
duration: 't', | |
help: [ 'h', '?' ], | |
overwrite: 'y', | |
start: 'ss', | |
}, | |
string: [ | |
'crop', | |
'duration', | |
'scale', | |
'start', | |
], | |
boolean: [ | |
'help', | |
'overwrite', | |
'subtitles', | |
'debug', | |
], | |
default: { | |
scale: '480', | |
overwrite: true, | |
subtitles: true, | |
}, | |
} ); | |
// Check whether help is being invoked and display help text if so. | |
if ( args._.length < 4 || args.help ) { | |
console.log( `How to Convert Video to Gif! | |
Basic Usage: | |
convert-to-gif input-file.mkv output-file.gif | |
Available Arguments: | |
${ '' /* 80 char mark here: | */ } | |
-ss, --start Timestamp at which to start the clip | |
-t, --duration Length of the desired clip | |
-c, --crop Crop dimensions (format as "width:height:x:y") | |
--scale Max size of output gif's larger dimension | |
--subtitles Burn in subtitles from input file | |
-h, -?, --help Print this help information` ); | |
// Options that do not currently work: we HAVE to overwrite | |
// or exec() hangs forever. | |
// -y, --overwrite Do not ask before overwriting files | |
// Quit out after displaying help text. | |
process.exit( 0 ); | |
} | |
const pathEscape = str => str.replace( /([^A-Za-z0-9-.\/_])/g, '\\$1' ); | |
const input = args._[ 2 ]; | |
const output = args._[ 3 ]; | |
const inputFile = resolve( process.cwd(), pathEscape( input ) ); | |
const outputFile = resolve( process.cwd(), pathEscape( output ) ) | |
const tempPalette = '~/tmp.png'; | |
const tempMP4 = '~/tmp.mp4'; | |
// Validate input arguments | |
const TIMESTAMP_RE = /^\d\d:\d\d:\d\d(\.\d+)?$/; | |
const CROP_RE = /^.*$/; | |
// const CROP_RE = /^(:?w=)?\d+:(:?h=)?\d+:(:?x=)?\d+:(:?y=)?\d+$/; | |
const NUMBER_RE = /^\d+$/; | |
const argsErrors = { | |
'No input file provided': ! input, | |
'No output file specified': ! output, | |
'No start time specified': ! args.start, | |
'No clip duration specified': ! args.duration, | |
[ | |
`Invalid start "${ args.start }"; expected format hh:mm:ss` | |
]: args.start && ! TIMESTAMP_RE.test( args.start ), | |
[ | |
`Invalid duration "${ args.duration }"; expected format hh:mm:ss` | |
]: args.duration && ! TIMESTAMP_RE.test( args.duration ), | |
[ | |
`Invalid crop "${ args.crop }"; expected format width:height:x:y` | |
]: args.crop && ! CROP_RE.test( args.crop ), | |
[ | |
`Invalid scale "${ args.scale }"; expected an integer` | |
]: args.scale && ! NUMBER_RE.test( args.scale ), | |
}; | |
if ( Object.keys( argsErrors ).reduce( ( hasErrors, errorText ) => { | |
if ( argsErrors[ errorText ] ) { | |
console.error( errorText ); | |
return true; | |
} | |
return hasErrors; | |
}, false ) ) { | |
process.exit( 1 ); | |
} | |
// Define helper functions for executing commands | |
const outputOf = command => exec( command ).then( ( { stdout } ) => stdout ); | |
const ensureFFMpeg = async () => { | |
const ffmpegInstalled = await outputOf( 'which ffmpeg' ).catch( () => undefined ); | |
if ( ! ffmpegInstalled ) { | |
console.error( '\nError! Could not detect ffmpeg.' ); | |
console.error( 'Ensure "ffmpeg" is installed and available in your path to proceed.' ); | |
process.exit( 1 ); | |
} | |
}; | |
const hasSubitles = async inputFile => { | |
const { stdout } = await exec( `ffprobe -loglevel error -select_streams s:0 -show_entries stream=codec_type -of csv=p=0 ${ | |
inputFile | |
}` ); | |
return ! ! stdout.trim(); | |
}; | |
const ffmpeg = ( options, outputFile ) => { | |
const command = `ffmpeg ${ | |
Object.keys( options ) | |
.filter( key => Boolean( options[ key ] ) ) | |
.map( key => { | |
if ( Array.isArray( options[ key ] ) ) { | |
// Format as `-key value1 -key value2`. | |
return options[ key ] | |
.map( value => `${ key } ${ value }` ) | |
.join( ' ' ); | |
} | |
if ( options[ key ] === true ) { | |
// ffmpeg expects boolean arguments to be passed as simple flags. | |
return key; | |
} | |
// Format as `-key value`. | |
return `${ key } ${ options[ key ] }`; | |
} ) | |
.join( ' ' ) | |
} ${ outputFile }`; | |
if ( args.debug ) { | |
console.log( command ); | |
} | |
return exec( command ); | |
}; | |
const spinner = { | |
spinning: false, | |
// Thank you cli-spinner for the ASCII. | |
frames: [ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' ], | |
start( message = '' ) { | |
this.spinning = setInterval( () => { | |
process.stdout.clearLine(); | |
process.stdout.cursorTo( 0 ); | |
process.stdout.write( `${ this.frames[ 0 ] } ${ message }`) | |
this.frames.push( this.frames.shift() ); | |
}, 200 ); | |
}, | |
stop( message = '' ) { | |
clearInterval( this.spinning ); | |
this.spinning = false; | |
process.stdout.clearLine(); | |
process.stdout.cursorTo( 0 ); | |
console.log( message ); | |
}, | |
abort() { | |
clearInterval( this.spinning ); | |
} | |
}; | |
( async () => { | |
await ensureFFMpeg(); | |
try { | |
spinner.start( 'Extracting video clip...' ); | |
const subtitles = args.subtitles && await hasSubitles( inputFile ); | |
const filters = ( args.crop || subtitles ) ? [ | |
args.crop ? `crop=${ args.crop }` : null, | |
// See http://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping | |
subtitles ? `subtitles=${ inputFile.replace( /\'/g, '\\\\\\\\\\\'' ) }` : null, | |
].filter( Boolean ).join( ', ' ) : false; | |
await ffmpeg( { | |
'-i': inputFile, | |
'-ss': args.start, | |
'-t': args.duration, | |
'-vf': filters && `"${ filters }"`, | |
'-y': true, | |
'-async': 1, | |
}, tempMP4 ); | |
spinner.stop( '✓ Movie clip created successfully.' ); | |
spinner.start( 'Extracting color palette...' ); | |
await ffmpeg( { | |
'-i': tempMP4, | |
'-filter_complex': '"[0:v] palettegen"', | |
'-y': true, | |
}, tempPalette ); | |
spinner.stop( '✓ Palette extracted successfully.' ); | |
spinner.start( 'Creating gif...' ); | |
await ffmpeg( { | |
'-i': [ | |
tempMP4, | |
tempPalette, | |
], | |
'-filter_complex': `"${ [ | |
`[0:v] fps=12,scale=${ args.scale }:-1,split [a][b]`, | |
'[a] palettegen [p]', | |
'[b][p] paletteuse', | |
].join( ';' ) }"`, | |
'-y': args.overwrite, | |
}, outputFile ); | |
spinner.stop( `✓ ${ outputFile } created successfully.` ); | |
spinner.start( 'Cleaning up temporary files...' ); | |
await exec( `rm ${ tempMP4 }` ); | |
await exec( `rm ${ tempPalette }` ); | |
spinner.stop(); | |
console.log( 'Enjoy!' ); | |
} catch ( e ) { | |
spinner.abort(); | |
if ( e.stderr ) { | |
console.error( e.stderr ); | |
} else { | |
console.error( e ); | |
} | |
process.exit( 1 ); | |
} | |
} )(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment