diff --git a/bin/index.js b/bin/index.js index 7fe0c5f..4d23da7 100755 --- a/bin/index.js +++ b/bin/index.js @@ -1,174 +1,4 @@ #! /usr/bin/env node -let { exec } = require("child_process"); -const yargs = require("yargs"); -const path = require("path"); -const fs = require("fs/promises"); -const fse = require("fs-extra"); -const dir = require("node-dir"); -const util = require("util"); -const utils = require("./utils.js"); +const { ffopti } = require("../src/index.js"); -exec = util.promisify(exec); - -const ffmpeg = "ffmpeg"; -const pngquant = "pngquant"; - -const CHUNK_IMG = 50; -const CHUNK_VID = 5; -const SUFFIX = "_cmp"; -const imageExt = ["jpg", "png", "webp", "bmp", "tiff", "gif"]; -const videoExt = ["mp4", "mov", "webm", "mkv", "avi", "wmv", "flv"]; -const validExt = [...imageExt, ...videoExt]; - -let { yargsOptions, getExt, addSuffix } = utils; -let argv = yargs - .options(yargsOptions) - .usage( - ` - Compress image or video files. - - usage: $0 [options] [input] [input]... - where input is file or directory. - - Supported formats - Images: ${imageExt.join(", ")} - Videos: ${videoExt.join(", ")} - ` - ) - .demandCommand(1, "Atleast one input is required").argv; - -let { _, replace, suffix, silent, stats } = argv; -suffix = suffix && suffix.length > 0 ? suffix : SUFFIX; -const cstats = { img: 0, vid: 0 }; -const timer = "compression_time"; - -const validFiles = []; -const validFilesChunks = []; - -async function getValidFiles(args) { - if (!args || !Array.isArray(args)) return; - for (const inp of args) { - try { - let fileStat; - try { - fileStat = fse.lstatSync(inp); - } catch (err) { - console.log(`${inp} is not a valid file or directory`); - continue; - } - if (fileStat.isFile()) { - if (validExt.includes(getExt(inp))) { - if (replace) { - validFiles.push(inp); - continue; - } - let out = addSuffix(inp, suffix); - fse.copyFileSync(inp, out); - validFiles.push(out); - } else { - console.log(`${inp} file type not supported`); - } - } else if (fileStat.isDirectory()) { - let out; - if (replace) { - out = inp; - } else { - out = addSuffix(inp, suffix); - fse.emptyDirSync(out); - fse.copySync(inp, out); - } - files = await dir.promiseFiles(out); - files.forEach((file) => { - if (validExt.includes(getExt(file))) validFiles.push(file); - }); - } - } catch (err) { - console.error(err); - } - } -} - -function chunk() { - let tempImg = []; - let tempVid = []; - - for (const inp of validFiles) { - let ext = getExt(inp); - if (imageExt.includes(ext)) { - tempImg.push(inp); - if (tempImg.length >= CHUNK_IMG) { - validFilesChunks.push(tempImg); - tempImg = []; - } - } else if (videoExt.includes(ext)) { - tempVid.push(inp); - if (tempVid.length >= CHUNK_VID) { - validFilesChunks.push(tempVid); - tempVid = []; - } - } - } - if (tempImg.length > 0) validFilesChunks.push(tempImg); - if (tempVid.length > 0) validFilesChunks.push(tempVid); -} - -async function comp(inp) { - try { - let ext = getExt(inp); - let tmp = addSuffix(inp, "#"); - await fs.rename(inp, tmp); - - let cmd; - if (imageExt.includes(ext)) { - cstats.img += 1; - if (ext === "png") - cmd = `${pngquant} -f --strip --quality 0-80 "${tmp}" -o "${inp}"`; - else if (ext === "jpg") - cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -q:v 7 "${inp}"`; - else cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y "${inp}"`; - } else if (videoExt.includes(ext)) { - cstats.vid += 1; - if (ext === "mp4") - cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -map_metadata -1 -movflags +faststart -c:v libx264 -c:a aac -b:a 64k -vf "scale=-2:min(720\\,trunc(ih/2)*2)" -profile:v main -r 30 -b:v 1000k "${inp}"`; - else - cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -map_metadata -1 -vf "scale=-2:min(720\\,trunc(ih/2)*2)" -r 30 -b:v 1000k "${inp}"`; - } - - try { - await exec(cmd); - silent || console.log(`${inp} compressed`); - await fs.unlink(tmp); - } catch (err) { - console.error(`${inp} compression failed: `, err); - replace ? await fs.rename(tmp, inp) : await fs.unlink(tmp); - } - } catch (err) { - console.error(err); - } -} - -async function compressFiles() { - for (const inputFiles of validFilesChunks) { - await Promise.all( - inputFiles.map((inp) => { - return comp(inp); - }) - ); - } -} - -function showStats() { - console.timeEnd(timer); - console.log(`images compressed: ${cstats.img}`); - console.log(`videos compressed: ${cstats.vid}`); -} - -async function compress() { - stats && console.time(timer); - await getValidFiles(_); - chunk(); - await compressFiles(); - stats && showStats(); -} - -if (_ && _.length > 0) compress(); +ffopti(); diff --git a/src/ffopti/compress.js b/src/ffopti/compress.js new file mode 100644 index 0000000..5823ec4 --- /dev/null +++ b/src/ffopti/compress.js @@ -0,0 +1,64 @@ +let { exec } = require("child_process"); +const fs = require("fs/promises"); +const util = require("util"); + +const { replace, silent } = require("../helpers/options.js"); +const { getExt, addSuffix } = require("../helpers/utils.js"); +const { cstats } = require("../helpers/stats.js"); +const { + ffmpeg, + pngquant, + imageExt, + videoExt, +} = require("../helpers/constants.js"); + +exec = util.promisify(exec); + +async function compress(inp) { + try { + let ext = getExt(inp); + let tmp = addSuffix(inp, "#"); + await fs.rename(inp, tmp); + + let cmd; + if (imageExt.includes(ext)) { + cstats.img += 1; + if (ext === "png") + cmd = `${pngquant} -f --strip --quality 0-80 "${tmp}" -o "${inp}"`; + else if (ext === "jpg") + cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -q:v 7 "${inp}"`; + else cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y "${inp}"`; + } else if (videoExt.includes(ext)) { + cstats.vid += 1; + if (ext === "mp4") + cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -map_metadata -1 -movflags +faststart -c:v libx264 -c:a aac -b:a 64k -vf "scale=-2:min(720\\,trunc(ih/2)*2)" -profile:v main -r 30 -b:v 1000k "${inp}"`; + else + cmd = `${ffmpeg} -loglevel 16 -i "${tmp}" -y -map_metadata -1 -vf "scale=-2:min(720\\,trunc(ih/2)*2)" -r 30 -b:v 1000k "${inp}"`; + } + + try { + await exec(cmd); + silent || console.log(`${inp} compressed`); + await fs.unlink(tmp); + } catch (err) { + console.error(`${inp} compression failed: `, err); + replace ? await fs.rename(tmp, inp) : await fs.unlink(tmp); + } + } catch (err) { + console.error(err); + } +} + +async function compressFiles(validFilesChunks = []) { + for (const inputFiles of validFilesChunks) { + await Promise.all( + inputFiles.map((inp) => { + return compress(inp); + }) + ); + } +} + +module.exports = { + compressFiles, +}; diff --git a/src/ffopti/files.js b/src/ffopti/files.js new file mode 100644 index 0000000..8091ef5 --- /dev/null +++ b/src/ffopti/files.js @@ -0,0 +1,89 @@ +const fse = require("fs-extra"); +const dir = require("node-dir"); + +const { replace, suffix } = require("../helpers/options.js"); +const { getExt, addSuffix } = require("../helpers/utils.js"); +const { + CHUNK_IMG, + CHUNK_VID, + imageExt, + videoExt, +} = require("../helpers/constants.js"); + +async function getValidFiles(args) { + const validExt = [...imageExt, ...videoExt]; + let validFiles = []; + + if (!args || !Array.isArray(args)) return; + for (const inp of args) { + try { + let fileStat; + try { + fileStat = fse.lstatSync(inp); + } catch (err) { + console.log(`${inp} is not a valid file or directory`); + continue; + } + if (fileStat.isFile()) { + if (validExt.includes(getExt(inp))) { + if (replace) { + validFiles.push(inp); + continue; + } + let out = addSuffix(inp, suffix); + fse.copyFileSync(inp, out); + validFiles.push(out); + } else { + console.log(`${inp} file type not supported`); + } + } else if (fileStat.isDirectory()) { + let out; + if (replace) { + out = inp; + } else { + out = addSuffix(inp, suffix); + fse.emptyDirSync(out); + fse.copySync(inp, out); + } + files = await dir.promiseFiles(out); + files.forEach((file) => { + if (validExt.includes(getExt(file))) validFiles.push(file); + }); + } + } catch (err) { + console.error(err); + } + } + return validFiles; +} + +function chunkFiles(validFiles) { + let tempImg = []; + let tempVid = []; + let validFilesChunks = []; + + if (!validFiles || validFiles.length <= 0) return []; + + for (const inp of validFiles) { + let ext = getExt(inp); + if (imageExt.includes(ext)) { + tempImg.push(inp); + if (tempImg.length >= CHUNK_IMG) { + validFilesChunks.push(tempImg); + tempImg = []; + } + } else if (videoExt.includes(ext)) { + tempVid.push(inp); + if (tempVid.length >= CHUNK_VID) { + validFilesChunks.push(tempVid); + tempVid = []; + } + } + } + if (tempImg.length > 0) validFilesChunks.push(tempImg); + if (tempVid.length > 0) validFilesChunks.push(tempVid); + + return validFilesChunks; +} + +module.exports = { getValidFiles, chunkFiles }; diff --git a/src/helpers/constants.js b/src/helpers/constants.js new file mode 100644 index 0000000..98dfc1a --- /dev/null +++ b/src/helpers/constants.js @@ -0,0 +1,19 @@ +const ffmpeg = "ffmpeg"; +const pngquant = "pngquant"; + +const CHUNK_IMG = 50; +const CHUNK_VID = 5; +const SUFFIX = "_cmp"; + +const imageExt = ["jpg", "png", "webp", "bmp", "tiff", "gif"]; +const videoExt = ["mp4", "mov", "webm", "mkv", "avi", "wmv", "flv"]; + +module.exports = { + ffmpeg, + pngquant, + CHUNK_IMG, + CHUNK_VID, + SUFFIX, + imageExt, + videoExt, +}; diff --git a/src/helpers/options.js b/src/helpers/options.js new file mode 100644 index 0000000..006c146 --- /dev/null +++ b/src/helpers/options.js @@ -0,0 +1,46 @@ +const yargs = require("yargs"); +const { SUFFIX, imageExt, videoExt } = require("./constants.js"); + +const usage = ` +Compress image or video files. + +usage: $0 [options] [input] [input]... +where input is file or directory. + +Supported formats +Images: ${imageExt.join(", ")} +Videos: ${videoExt.join(", ")} +`; + +const options = { + r: { + alias: "replace", + describe: "Replace original", + type: "boolean", + }, + s: { + alias: "silent", + describe: "Run quietly with no info", + type: "boolean", + }, + suffix: { + default: SUFFIX, + describe: "Final compressed path suffix", + type: "string", + }, + stats: { + describe: "Show compression stats", + type: "boolean", + }, +}; + +const argv = yargs + .options(options) + .usage(usage) + .demandCommand(1, "Atleast one input is required").argv; + +if (!argv.suffix || argv.suffix.length <= 0) { + argv.suffix = SUFFIX; +} + +module.exports = argv; diff --git a/src/helpers/stats.js b/src/helpers/stats.js new file mode 100644 index 0000000..8de1495 --- /dev/null +++ b/src/helpers/stats.js @@ -0,0 +1,18 @@ +const cstats = { img: 0, vid: 0 }; + +function showStats(timer) { + console.timeEnd(timer); + console.log(`images compressed: ${cstats.img}`); + console.log(`videos compressed: ${cstats.vid}`); +} + +function withStats(func) { + return async function (args) { + const timer = "compression_time"; + console.time(timer); + await func(args); + showStats(timer); + }; +} + +module.exports = { withStats, cstats }; diff --git a/src/helpers/utils.js b/src/helpers/utils.js new file mode 100644 index 0000000..98dd523 --- /dev/null +++ b/src/helpers/utils.js @@ -0,0 +1,24 @@ +const path = require("path"); + +function getExt(input) { + let ext = path.extname(input).toLowerCase(); + if (ext && ext[0] === ".") ext = ext.substring(1); + return ext; +} + +function addSuffix(input, suffix) { + if (!input) return ""; + let base = ""; + let { dir, name, ext } = path.parse(input); + if (dir.length > 0 && dir[dir.length - 1] !== "/") { + base = `${dir}/${name}`; + } else { + base = `${dir}${name}`; + } + return `${base}${suffix}${ext}`; +} + +module.exports = { + getExt, + addSuffix, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2e83f4d --- /dev/null +++ b/src/index.js @@ -0,0 +1,19 @@ +const { compressFiles } = require("./ffopti/compress.js"); +const { getValidFiles, chunkFiles } = require("./ffopti/files.js"); +const { _, stats } = require("./helpers/options.js"); +const { withStats } = require("./helpers/stats.js"); + +async function compress() { + const validFiles = await getValidFiles(_); + const validFilesChunks = chunkFiles(validFiles); + await compressFiles(validFilesChunks); +} + +function ffopti() { + if (_ && _.length > 0) { + if (stats) withStats(compress)(); + else compress(); + } +} + +module.exports = { ffopti };