frameloom/frameloom

651 lines
18 KiB
JavaScript

#!/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);