#!/usr/bin/env node 'use strict'; const execRaw = require('child_process').exec; const { tmpdir } = require('os'); const { join, extname } = require('path'); const program = require('commander'); const { move, exists, unlink, readdir, mkdir } = require('fs-extra'); const { version } = require('./package.json'); const OUTPUT_RE = new RegExp('{{o}}', 'g'); const INPUT_RE = new RegExp('{{i}}', 'g'); let QUIET = false; let TMPDIR = tmpdir() || '/tmp'; let TMPPATH; /** * Shells out to execute a command with async/await. * Async wrapper to exec module. * * @param {string} cmd Command to execute * * @returns {Promise} Promise containing the complete stdio **/ async function exec(cmd) { return new Promise((resolve, reject) => { return execRaw(cmd, { maxBuffer: 500 * 1024 * 1024 }, (err, stdio, stderr) => { if (err) return reject(err); return resolve(stdio); }); }); } /** * Delays process for specified amount of time in milliseconds. * * @param {integer} ms Milliseconds to delay for * * @returns {Promise} Promise that resolves after set time **/ async function delay(ms) { return new Promise((resolve, reject) => { return setTimeout(resolve, ms); }); } /** * Log function wrapper that can silences logs when * QUIET == true */ function log(msg, err = false) { if (QUIET) return false; if (err) { console.error(msg, err); } else { console.log(msg); } return true; } /** * Pads a numerical value with preceding zeros to make strings same length. * * @param {integer} i Number to pad * @param {integer} max (optional) Maximum length of string to pad to * * @returns {string} Padded number as a string **/ function zeroPad(i, max = 5) { let str = i + ''; let len = str.length; for (let x = 0; x < max - len; x++) { str = '0' + str; } return str; } /** * Shuffles an array into a random state. * * @param {array} a Array to randomize **/ function shuffle(array) { let j; let temp; for (let i = array.length - 1; i > 0; i--) { j = Math.floor(Math.random() * (i + 1)); temp = array[i]; array[i] = array[j]; array[j] = temp; } } function randomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Clears the temporary directory of all files. * Establishes a directory if none exists. **/ async function clear() { let cmd = `rm -r "${TMPPATH}"`; let dirExists; try { dirExists = await exists(TMPPATH); } catch (err) { log('Error checking if file exists', err); } if (dirExists) { log(`Clearing temp directory "${TMPPATH}"`); try { await exec(cmd); } catch (err) { //suppress error console.dir(err); } } try { await mkdir(TMPPATH); } catch (err) { if (err.code !== 'EEXIST') { log('Error making directory', err); } } return true; } /** * Exports all frames from video. Appends number to the string * to keep frames in alternating order to be quickly stitched together * or re-sorted. * * @param {string} video String representing path to video * @param {integer} order Integer to be appended to pathname of file * @param {boolean} avconv Whether or not to use avconv instead of ffmpeg * * @returns {string} String with the export order, not sure why I did this **/ async function frames(video, order, avconv) { let ext = 'tif'; let exe = avconv ? 'avconv' : 'ffmpeg'; let tmpoutput; let cmd; tmpoutput = join(TMPPATH, `export-%05d_${order}.${ext}`); cmd = `${exe} -i "${video}" -compression_algo raw -pix_fmt rgb24 "${tmpoutput}"`; log(`Exporting ${video} as single frames...`); try { await exec(cmd); } catch (err) { log('Error exporting video', err); return process.exit(3); } return join(TMPPATH, `export-%05d_${order}`); } /** * Shells out to run a sub command on every frame to perform effects * * @param {string} cmd Command to execute on every frame * **/ async function subExec(cmd) { let frames; let frameCmd; let framePath; try { frames = await readdir(TMPPATH); } catch (err) { log('Error reading tmp directory', err); } frames = frames.filter(file => { if (file.indexOf('.tif') !== -1) return true; }); for (let frame of frames) { framePath = join(TMPPATH, frame); if (cmd.indexOf('{{i}}') !== -1 || cmd.indexOf('{{o}}')) { frameCmd = cmd.replace(INPUT_RE, framePath) .replace(OUTPUT_RE, framePath); } else { frameCmd = `${cmd} ${framePath}`; } try { await exec(frameCmd); } catch (err) { log('Error executing sub command on frame', err); return process.exit(10); } } } /** * Re-arranges the frames into the order specified in the pattern. * Calls `patternSort()` to perform the rename and unlink actions * * @param {array} pattern Pattern of the frames per input * @param {boolean} realtime Flag to turn on or off realtime behavior (drop frames / number of vids) * @param {boolean} random Whether or not to randomize frames **/ async function weave(pattern, realtime, random) { let frames; let seq; let alt = false; log('Weaving frames...'); try { frames = await readdir(TMPPATH); } catch (err) { log('Error reading tmp directory', err); } //console.dir(frames) frames = frames.filter(file => { if (file.indexOf('.tif') !== -1) return true; }); for (let el of pattern) { if (el !== 1) alt = true; } if (random) { log('Sorting frames randomly...'); try { seq = await randomSort(frames, pattern, realtime); } catch (err) { log('Error sorting frames', err); } } else if (!alt) { log('Sorting frames normally...'); try { seq = await standardSort(frames, pattern, realtime); } catch (err) { log('Error sorting frames', err); } } else if (alt) { //log('This feature is not ready, please check https://github.com/sixteenmillimeter/frameloom.git', {}) //process.exit(10) log('Sorting frames with alternate pattern...'); try { seq = await altSort(frames, pattern, realtime); } catch (err) { log('Error sorting frames', err); } } //console.dir(seq) } /** * Alternate frame sorting method. * * @param {array} list List of frames to group * @param {array} pattern Array representing pattern * @param {boolean} realtime Flag to group with "realtime" behavior **/ async function altSort(list, pattern, realtime) { let groups = []; let newList = []; let loops = 0; let patternIndexes = []; let frameCount = 0; let skipCount; let skip; let oldName; let oldPath; let newName; let newPath; let ext = extname(list[0]); let x; let i; for (x = 0; x < pattern.length; x++) { groups.push([]); for (let i = 0; i < pattern[x]; i++) { patternIndexes.push(x); } } for (i = 0; i < list.length; i++) { groups[i % pattern.length].push(list[i]); } loops = Math.ceil(list.length / patternIndexes.length); if (realtime) { skip = false; skipCount = patternIndexes.length + 1; } for (x = 0; x < loops; x++) { for (i = 0; i < patternIndexes.length; i++) { if (realtime) { skipCount--; if (skipCount === 0) { skip = !skip; skipCount = pattern.length; } } if (typeof groups[patternIndexes[i]][0] === 'undefined') { continue; } oldName = String(groups[patternIndexes[i]][0]); oldPath = join(TMPPATH, oldName); groups[patternIndexes[i]].shift(); if (skip) { log(`Skipping ${oldName}`); try { await unlink(oldPath); } catch (err) { log('Error deleting frame', err); } continue; } newName = `./render_${zeroPad(frameCount)}${ext}`; newPath = join(TMPPATH, newName); log(`Renaming ${oldName} -> ${newName}`); try { await move(oldPath, newPath); newList.push(newName); frameCount++; } catch (err) { log('Error renaming frame', err); return process.exit(10); } } } return newList; } /** * Standard frame sorting method. * * @param {array} list List of frames to group * @param {array} pattern Array representing pattern * @param {boolean} realtime Flag to group with "realtime" behavior **/ async function standardSort(list, pattern, realtime) { let frameCount = 0; let stepCount; let step; let skipCount; let skip; let ext = extname(list[0]); let oldPath; let newName; let newPath; let newList = []; if (realtime) { skip = false; skipCount = pattern.length + 1; } for (let i = 0; i < list.length; i++) { if (realtime) { skipCount--; if (skipCount === 0) { skip = !skip; skipCount = pattern.length; } } oldPath = join(TMPPATH, list[i]); if (skip) { log(`Skipping ${list[i]}`); try { await unlink(oldPath); } catch (err) { log('Error deleting frame', err); } continue; } newName = `./render_${zeroPad(frameCount)}${ext}`; newPath = join(TMPPATH, newName); log(`Renaming ${list[i]} -> ${newName}`); try { await move(oldPath, newPath); newList.push(newName); frameCount++; } catch (err) { log('Error renaming frame', err); return process.exit(10); } } return newList; } /** * Ramdomly sort frames for re-stitching. * * @param {array} list List of frames to group * @param {array} pattern Array representing pattern * @param {boolean} realtime Flag to group with "realtime" behavior **/ async function randomSort(list, pattern, realtime) { let frameCount = 0; let ext = extname(list[0]); let oldPath; let newName; let newPath; let newList = []; let removeLen = 0; let remove = []; shuffle(list); if (realtime) { removeLen = Math.floor(list.length / pattern.length); remove = list.slice(removeLen, list.length); list = list.slice(0, removeLen); log(`Skipping extra frames...`); for (let i = 0; i < remove.length; i++) { oldPath = join(TMPPATH, remove[i]); log(`Skipping ${list[i]}`); try { await unlink(oldPath); } catch (err) { log('Error deleting frame', err); } } } for (let i = 0; i < list.length; i++) { oldPath = join(TMPPATH, list[i]); newName = `./render_${zeroPad(frameCount)}${ext}`; newPath = join(TMPPATH, newName); log(`Renaming ${list[i]} -> ${newName}`); try { await move(oldPath, newPath); newList.push(newName); } catch (err) { log('Error moving frame', err); } frameCount++; } return newList; } async function spinFrames() { let frames; let framePath; let cmd; let flip; let flop; let rotate; console.log('Spinning frames...'); try { frames = await readdir(TMPPATH); } catch (err) { console.error('Error reading tmp directory', err); } //console.dir(frames) frames = frames.filter(file => { if (file.indexOf('.tif') !== -1) return true; }); for (let frame of frames) { framePath = join(TMPPATH, frame); rotate = ''; flip = ''; flop = ''; if (randomInt(0, 1) === 1) { rotate = '-rotate 180 '; } if (randomInt(0, 1) === 1) { flip = '-flip '; } if (randomInt(0, 1) === 1) { flop = '-flop '; } if (flip === '' && flop === '' && rotate === '') { //skip unrotated, unflipped and unflopped frames continue; } cmd = `convert ${framePath} ${rotate}${flip}${flop} ${framePath}`; console.log(cmd); try { await exec(cmd); } catch (err) { console.error(err); process.exit(10); } } } /** * Render the frames into a video using ffmpeg. * * @param {string} output Path to export the video to * @param {boolean} avconv Whether or not to use avconv in place of ffmpeg **/ async function render(output, avconv) { //process.exit() let frames = join(TMPPATH, `render_%05d.tif`); let exe = avconv ? 'avconv' : 'ffmpeg'; let resolution = '1920x1080'; //TODO: make variable/argument //TODO: make object configurable with shorthand names let h264 = `-vcodec libx264 -g 1 -crf 25 -pix_fmt yuv420p`; let prores = `-c:v prores_ks -profile:v 3`; // let format = (output.indexOf('.mov') !== -1) ? prores : h264; let framerate = `24`; const cmd = `${exe} -r ${framerate} -f image2 -s ${resolution} -i ${frames} ${format} -y ${output}`; log(`Exporting video ${output}`); log(cmd); try { await exec(cmd); } catch (err) { log('Error rendering video with ffmpeg', err); } } /** * Parses the arguments and runs the process of exporting, sorting and then * "weaving" the frames back into a video * * @param {object} arg Object containing all arguments **/ async function main(program) { const arg = program.opts(); let input = arg.input.split(':'); let output = arg.output; let pattern = []; let realtime = false; let avconv = false; let random = false; let e = false; let exe = arg.avconv ? 'avconv' : 'ffmpeg'; let fileExists; console.time('frameloom'); if (input.length < 2) { log('Must provide more than 1 input', {}); return process.exit(1); } if (!output) { log('Must provide video output path', {}); return process.exit(2); } if (arg.random) { random = true; } if (arg.avconv) { avconv = true; } if (arg.tmp) { TMPDIR = arg.tmp; } if (arg.exec) { e = arg.exec; } if (arg.quiet) { QUIET = true; } if (arg.pattern) { pattern = arg.pattern.split(':'); pattern = pattern.map(el => { return parseInt(el); }); } else { for (let i = 0; i < input.length; i++) { pattern.push(1); } } try { fileExists = await exec(`which ${exe}`); } catch (err) { log(`Error checking for ${exe}`); process.exit(11); } if (!fileExists || fileExists === '' || fileExists.indexOf(exe) === -1) { log(`${exe} is required and is not installed. Please install ${exe} to use frameloom.`); process.exit(12); } if (pattern.length !== input.length) { log(`Number of inputs (${input.length}) doesn't match the pattern length (${pattern.length})`); process.exit(10); } if (arg.realtime) realtime = true; TMPPATH = join(TMPDIR, 'frameloom'); try { await clear(); } catch (err) { log('Error clearing temp directory', err); return process.exit(3); } log(`Processing video files ${input.join(', ')} into ${output} with pattern ${pattern.join(':')}`); for (let i = 0; i < input.length; i++) { try { await frames(input[i], i, avconv); } catch (err) { log('Error exporting video fie to image sequence', err); return process.exit(4); } } try { await weave(pattern, realtime, random); } catch (err) { log('Error weaving', err); return process.exit(5); } if (arg.spin) { try { await spinFrames(); } catch (err) { log('Error spinning', err); return process.exit(13); } } if (e) { try { await subExec(e); } catch (err) { log('Error performing subcommand', err); return process.exit(7); } } try { await render(output, avconv); } catch (err) { log('Error rendering', err); return process.exit(6); } try { await clear(); } catch (err) { log('Error clearing files', err); return process.exit(7); } console.timeEnd('frameloom'); } program .version(version) .option('-i, --input [files]', 'Specify input videos with paths seperated by colon') .option('-o, --output [file]', 'Specify output path of video') .option('-p, --pattern [pattern]', 'Specify a pattern for the flicker 1:1 is standard') .option('-r, --realtime', 'Specify if videos should preserve realtime speed') .option('-t, --tmp [dir]', 'Specify tmp directory for exporting frames') .option('-a, --avconv', 'Specify avconv if preferred to ffmpeg') .option('-R, --random', 'Randomize frames. Ignores pattern if included') .option('-s, --spin', 'Randomly rotate frames before rendering') .option('-e, --exec', 'Command to execute on every frame. Specify {{i}} and {{o}} if the command requires it, otherwise frame path will be appended to command') .option('-q, --quiet', 'Suppresses all log messages') .parse(process.argv); main(program);