Skip to content

Instantly share code, notes, and snippets.

@loehx
Last active August 19, 2023 13:29
Show Gist options
  • Save loehx/7cc584e5dabc93e918de3c4e1418b241 to your computer and use it in GitHub Desktop.
Save loehx/7cc584e5dabc93e918de3c4e1418b241 to your computer and use it in GitHub Desktop.
Bulk Video Stabilizer - to fix shaky videos (Node.js)
/**
* Bulk Video Stabilization & Improvement
* Stabilize and improve your videos using FFmpeg (Node.js)
* Author: Alexander Löhn (https://github.com/loehx)
*
* How to?
*
* [1] Add a new folder somewhere on your MAC.
* [2] Create a new file and name it "stabilize.js" and paste in this script (all of it).
* [3] Open a terminal window in the folder. -> https://superuser.com/a/340051
* [4] Run these two commands once:
* brew install ffmpeg
* brew install libvidstab
* [5] Configure the config, or just go with the default parameters
* [6] You can now run the script running the following command in your terminal:
* node stabilize.js
*/
const config = {
action: "stabilize", // 'stabilize' | 'improve'
folderPath: ".", // Path to the folder containing video files
// Supported video file extensions to process
extensions: [".mp4", ".mov"],
// ratio: [2.39, 1], // Aspect ratio for cropping (e.g., [width, height])
// The aspect ratio for cropping the video.
// Expressed as an array of [width, height]. Set to null or omit to disable cropping.
stabilize: {
salt: ".stabilized", // Salt appended to stabilized file names
shakiness: 4, // Maximum shakiness value for vidstabdetect
// The maximum acceptable level of shakiness in the video.
// Lower values indicate that slight shakiness will also be stabilized.
// Shakiness ranges from 1 to 10; lower values stabilize slight shakiness.
// Default shakiness value is 4.
accuracy: 8, // Accuracy of motion analysis for vidstabdetect
// The level of detail in motion analysis.
// Higher values improve accuracy but might slow down the process.
// Accuracy ranges from 1 to 15; higher values provide more detailed analysis.
// Default accuracy value is 8.
smoothing: 8, // Smoothing value for vidstabtransform
// The amount of smoothing applied to the stabilization.
// Higher values result in smoother motion but might reduce the effectiveness of stabilization.
// Smoothing ranges from 1 to 15; higher values result in more smoothing.
// Default smoothing value is 8.
zoom: 2, // Zoom factor for vidstabtransform
// The amount of zoom applied to the stabilized video.
// A value greater than 1 will zoom in, while a value less than 1 will zoom out.
// Zoom ranges from 1 to 5; higher values result in greater zoom.
// Default zoom value is 2.
},
improve: {
salt: ".improved", // Salt appended to improved file names
crf: 18, // Constant Rate Factor (CRF) for video compression
// Influences the quality and file size of the compressed video.
// A lower CRF value results in higher quality but larger file size,
// while a higher CRF value reduces quality but results in smaller file size.
// CRF ranges from 0 to 51; lower values are better quality.
// Default CRF value for libx264 codec is 23.
// Common range for CRF is around 18 to 28.
},
};
const { spawn } = require("child_process");
var fs = require("fs");
var path = require("path");
/**
* Run the video stabilization or improvement process on the specified video files.
*/
async function run() {
const files = getVideoFiles(config.folderPath);
console.log({
config,
files,
});
for (const file of files) {
const logPrefix = ` [#${file.index + 1} ${file.name}]`;
const log = (message, ...args) =>
console.log(
logPrefix,
message.split("\n").join("\n" + logPrefix),
...args
);
let context = { ...file, log };
if (config.action === "stabilize") {
await stabilizeFile(context);
} else if (config.action === "improve") {
await improveFile(context);
} else {
log("Unknown action: " + config.action);
}
}
}
/**
* Stabilize a video file using the specified configuration.
*
* This function applies video stabilization to a given video file using FFmpeg's vidstabdetect
* and vidstabtransform filters based on the configuration settings provided.
*/
async function stabilizeFile({ path, name, index, extension, log }) {
const stabilize = config.stabilize;
// Check if the video file was already improved and if so -> break
if (path.includes(stabilize.salt)) {
return log(
"Skipped. File name contains salt, so it seems to have been processed already."
);
}
const trfFilePath = `${path.replace(extension, "")}${stabilize.salt}.trf`;
const targetFilePath = `${path.replace(extension, "")}${
stabilize.salt
}${extension}`;
if (fs.existsSync(targetFilePath)) {
return log("file already transformed!");
}
if (fs.existsSync(trfFilePath)) {
log(`TRF file already created!`);
} else {
log(`create TRF file ...`);
await spawnPromise(
log,
"ffmpeg",
[
`-i`,
`${path}`,
`-vf`,
`vidstabdetect=shakiness=${stabilize.shakiness}:accuracy=${stabilize.accuracy}:result=${trfFilePath}`,
`-f`,
`null`,
`-`,
],
{ detached: true }
);
}
log("transform file ...");
const crop = config.ratio
? `crop=in_w:in_w/${config.ratio[0] / config.ratio[1]},`
: "";
try {
await spawnPromise(
log,
"ffmpeg",
[
`-i`,
`${path}`,
`-vf`,
`${crop}vidstabtransform=smoothing=${stabilize.smoothing}:zoom=${stabilize.zoom}:input=${trfFilePath}`,
targetFilePath,
],
{ detached: true }
);
// Set the creation and modification dates of the target video to match the source video
syncFileTimestamps(path, targetFilePath);
if (fs.existsSync(trfFilePath)) {
log("delete TRF file ...");
fs.unlinkSync(trfFilePath);
}
} catch (e) {
log("ERROR", e);
log(`delete unfinished video file: "${targetFilePath}"`);
if (fs.existsSync(targetFilePath)) {
fs.unlinkSync(targetFilePath);
}
}
}
/*
* Improve a video file using the specified configuration.
*
* This function applies video compression improvement to a given video file using FFmpeg's
* libx264 codec with a specified Constant Rate Factor (CRF) value based on the configuration settings provided.
*/
async function improveFile({ path, name, index, extension, log }) {
const targetFilePath = `${path.replace(extension, "")}${
config.improve.salt
}${extension}`;
// Check if the video file was already improved and if so -> break
if (path.includes(config.improve.salt)) {
return log(
"Skipped. File name contains salt, so it seems to have been processed already."
);
}
if (fs.existsSync(targetFilePath)) {
return log("file already improved!");
}
log("improve file ...");
const crop = config.ratio
? `crop=iw:iw/${config.ratio[0] / config.ratio[1]}`
: "";
try {
await spawnPromise(
log,
"ffmpeg",
[
`-i`,
`${path}`,
"-vcodec",
"libx264",
crop && "-vf",
crop,
config.improve.crf && "-crf",
config.improve.crf,
targetFilePath,
].filter((l) => l),
{ detached: true }
);
// Set the creation and modification dates of the target video to match the source video
syncFileTimestamps(path, targetFilePath);
} catch (e) {
log("ERROR", e);
log(`delete unfinished video file: "${targetFilePath}"`);
if (fs.existsSync(targetFilePath)) {
fs.unlinkSync(targetFilePath);
}
}
}
/**
* Promisified wrapper for spawning a child process.
*
* @param {Function} log - The logging function for output messages.
* @param {...string} args - Command-line arguments for the child process.
* @returns {Promise<number>} A Promise that resolves with the exit code of the child process.
*/
function spawnPromise(log, ...args) {
return new Promise((resolve, reject) => {
const process = spawn(...args);
process.stderr.on("data", (m) => log(" " + m.toString()));
process.on("exit", function (code) {
if (code > 0) {
log(`FAILED WITH ERROR CODE: ${code}`);
reject(code);
} else {
log(`FINISHED SUCCESSFULLY`);
resolve(code);
}
});
});
}
/**
* Get a list of video files in the specified directory.
*
* @param {string} dir - The directory path to search for video files.
* @returns {Array<{ path: string, name: string, index: number, extension: string }>}
* An array of objects representing video files, each containing the path, name, index,
* and extension of the file.
*/
function getVideoFiles(dir) {
const files = [];
let index = 0;
fs.readdirSync(dir).forEach((filename) => {
const name = path.parse(filename).name;
const ext = path.parse(filename).ext.toLowerCase();
const fpath = path.resolve(dir, filename);
const stat = fs.statSync(fpath);
const isFile = stat.isFile();
if (isFile && config.extensions.includes(ext))
files.push({
path: fpath,
name: name + ext,
index: index++,
extension: ext,
});
});
return files;
}
/**
* Synchronize the creation and modification timestamps of the target file with the source file.
*
* @param {string} sourceFilePath - The path to the source file.
* @param {string} targetFilePath - The path to the target file whose timestamps need to be updated.
* @returns {Promise<void>} A Promise that resolves when the timestamps are successfully updated.
*/
function syncFileTimestamps(sourceFilePath, targetFilePath) {
return new Promise((resolve, reject) => {
const touch = spawn("touch", ["-r", sourceFilePath, targetFilePath]);
touch.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Failed to update timestamps. Exit code: ${code}`));
}
});
});
}
run(); // Run the script
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment