Skip to content

Instantly share code, notes, and snippets.

@kadamwhite
Last active January 9, 2019 19:12
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/e03b3cebec50f54f8ffee8e4ae78fcc4 to your computer and use it in GitHub Desktop.
Save kadamwhite/e03b3cebec50f54f8ffee8e4ae78fcc4 to your computer and use it in GitHub Desktop.
Spider a directory and make a short gif from each one. Ensure convert-to-gif is in your path before running `node make-gifs.js`
#!/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',
],
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 = /^\d+:\d+:\d+:\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 }`;
// 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 );
await ffmpeg( {
'-i': inputFile,
'-ss': args.start,
'-t': args.duration,
'-filter:v': args.crop && `crop="${ args.crop }"`,
'-vf': subtitles && `subtitles="${
// See http://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping
inputFile.replace( /\'/g, '\\\\\\\\\\\'' )
}"`,
'-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 );
}
} )();
const util = require( 'util' );
const exec = util.promisify( require( 'child_process' ).exec );
const {
existsSync,
writeFileSync,
promises: {
readdir,
},
} = require( 'fs' );
const { join } = require( 'path' );
const flatten = arrs => arrs.reduce( ( merged, arr ) => merged.concat( arr ), [] );
// Unique ID, from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
const uuid = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, c => {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
} );
// Convenience wrappers for fs functions
const ls = async dirPath => readdir( dirPath, {
withFileTypes: true,
} );
const tmpfile = join( process.cwd(), 'dirlist.tmp.json' );
const logFile = join( process.cwd(), `log.${ uuid() }.json` );
const moviesDir = '/media/kadam/Plex/Anime';
const gifCount = process.argv[3] ? +process.argv[3] : 0;
// Helpers
const isDotfile = file => /^\./.test( file );
const isVideo = file => /\.(mp4|mkv|m4v|avi)/i.test( file );
const getVideoList = async treeRoot => {
if ( treeRoot !== moviesDir ) {
return spider( treeRoot );
}
try {
const videos = require( tmpfile );
console.log( '\nUsing cache file...\n' );
return videos;
} catch ( e ) {
return spider( treeRoot ).then( videos => {
writeFileSync( tmpfile, JSON.stringify( videos ) );
return videos;
} );
}
};
const spider = async dirPath => {
try {
const { files, dirs } = await ls( dirPath )
.then( results => results.reduce( ( memos, dirent ) => {
const name = dirent.name;
if ( isDotfile( name ) ) {
return memos;
}
if ( dirent.isFile() && isVideo( name ) ) {
memos.files.push( join( dirPath, name ) );
} else if ( dirent.isDirectory() ) {
memos.dirs.push( join( dirPath, name ) );
}
return memos;
}, {
files: [],
dirs: [],
} ) );
const subDirs = await Promise.all( dirs.map( subDir => spider( subDir ) ) );
return flatten( subDirs ).concat( files );
} catch ( e ) {
throw e;
}
};
const guaranteeUniqueFilename = fileName => {
if ( ! existsSync( fileName ) ) {
return fileName;
}
// Add a -1 if this is the first conflict.
if ( ! /-\d+\.[^.]+$/.test( fileName ) ) {
return guaranteeUniqueFilename (
fileName.replace( /(\.[^\.]+)$/, '-1$1' )
);
}
// Increment to -n if this is a subsequent conflict.
const match = fileName.match( /-(\d+)\.[^.]+$/ );
const num = match && match[ 1 ];
return guaranteeUniqueFilename(
fileName.replace( /-\d+(\.[^.]+)$/, `-${ num + 1 }$1` )
);
}
const pathEscape = str => str.replace( /([^A-Za-z0-9-_.\/])/g, '\\$1' );
const getFileDuration = inputFile => {
return exec( `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${
pathEscape( inputFile )
}` ).then( ( { stdout } ) => +stdout.trim() );
};
const pad = num => num < 10 ? `0${ num }` : num;
const toTimestamp = seconds => {
const s = +( ( seconds % 60 ).toFixed( 1 ) );
const m = Math.floor( seconds / 60 );
const h = Math.floor( seconds / ( 60 * 60 ) );
return [ h, m, s ].map( num => pad( num ) ).join( ':' );
};
const getRandomTimestamp = async inputFile => {
const duration = await getFileDuration( inputFile );
// Ensure that no gif will start within 4 seconds of end of file.
return toTimestamp( Math.random() * ( duration - 4 ) );
};
const processed = [];
const inputFileToOutputGifName = inputFile => `${ inputFile
.replace( /^.*\//, '' ) // Only process filename
.replace( /['"()]/g, '' ) // No ', ", or parens
.replace( /\.[^.]+$/, '' ) // No filename extension
.replace( /[^A-Za-z0-9-\.]/g, '-' ) // All non-alphanumerics to -
.replace( /-+/g, '-' ) // No repeating -
.replace( /^-/, '' ) // No leading -
.replace( /-$/, '' ) // No trailing -
.toLowerCase()
}.gif`;
// Get random item and remove it from the array, then return that item.
const randomItem = videos => videos.splice(
Math.floor( Math.random() * videos.length ),
1
)[ 0 ];
const spawn = ( command, args ) => new Promise( ( resolve, reject ) => {
const { spawn } = require( 'child_process' );
const spawnedProcess = spawn( command, args, {
stdio: 'inherit',
} );
spawnedProcess.on( 'error', err => reject( err ) );
spawnedProcess.on( 'close', code => ( code ? reject() : resolve() ) );
} );
const convert = async video => {
const start = await getRandomTimestamp( video );
const duration = `00:00:0${ Math.round( Math.random() * 2.5 ) + 2 }`;
const outputFilename = guaranteeUniqueFilename(
join( process.cwd(), inputFileToOutputGifName( video ) )
);
processed.push( {
outputFilename,
start,
duration,
} );
await spawn( 'convert-to-gif', [
'--start',
start,
'--duration',
duration,
video,
outputFilename,
].filter( Boolean ) );
};
getVideoList( process.argv[2] || moviesDir )
.then( async videos => {
// Add a newline to separate experimntal API warning.
if ( gifCount ) {
while ( videos.length < gifCount ) {
// Double array size until there's enough.
videos.push( ...videos );
}
}
let remaining = gifCount || videos.length;
try {
while ( videos.length && remaining > 0 ) {
const video = randomItem( videos );
console.log( `\nProcessing ${ video }` );
try {
await convert( video );
writeFileSync( logFile, JSON.stringify( processed ) );
} catch ( e ) {
const { outputFilename, start, duration } = processed[ processed.length - 1 ];
processed[ processed.length - 1 ].errored = true;
console.error( `Failed to generate ${ outputFilename } (started at ${ start } for ${ duration })` );
// Carry on.
}
writeFileSync( logFile, JSON.stringify( processed ) );
remaining -= 1;
}
} catch ( e ) {
throw e;
}
} )
.catch( e => e && console.error( e.stderr || e ) );
@kadamwhite
Copy link
Author

Observed issues:

  • If the filename has apostrophes, it may not be correctly processed by the subtitle filter. Investigate further

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment