Last active
August 19, 2023 13:29
-
-
Save loehx/7cc584e5dabc93e918de3c4e1418b241 to your computer and use it in GitHub Desktop.
Bulk Video Stabilizer - to fix shaky videos (Node.js)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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