Skip to content

Instantly share code, notes, and snippets.

@kadamwhite
Created March 9, 2020 17:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kadamwhite/e79d02b3707852443b1e6c10f0082b91 to your computer and use it in GitHub Desktop.
Save kadamwhite/e79d02b3707852443b1e6c10f0082b91 to your computer and use it in GitHub Desktop.
Gif conversion script
#!/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