153 lines
3.6 KiB
JavaScript
153 lines
3.6 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const INPUT_DIR = "docs/public"; // change if needed
|
|
const OUTPUT_DIR = "docs/public/videos"; // change if needed
|
|
|
|
// Pick one: VP9 is more widely supported, AV1 is smaller but slower.
|
|
const CODEC = "vp9"; // "vp9" or "av1"
|
|
|
|
const MAX_PARALLEL = 2; // limit concurrency
|
|
const AUDIO_BITRATE = "128k";
|
|
|
|
// Target-ish size approach:
|
|
// - For VP9: use -b:v (bitrate cap) for predictable sizes.
|
|
// - For AV1: use -b:v too (still more variable, but workable).
|
|
const VIDEO_BITRATE = "1.0M"; // increase/decrease based on your desired size/quality
|
|
|
|
function isVideo(file) {
|
|
return /\.(mp4|mkv|mov|webm|avi|m4v)$/i.test(file);
|
|
}
|
|
|
|
async function walk(dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const out = [];
|
|
for (const e of entries) {
|
|
const p = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
} else if (isVideo(e.name)) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function runFFmpeg(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const p = spawn("ffmpeg", args, { stdio: "inherit" });
|
|
p.on("close", (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)),
|
|
);
|
|
p.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function processOne(inPath) {
|
|
const rel = path.relative(INPUT_DIR, inPath);
|
|
const outPath = path
|
|
.join(OUTPUT_DIR, rel)
|
|
.replace(path.extname(rel), ".webm");
|
|
try {
|
|
await fs.access(outPath);
|
|
console.log(`Already existed: ${outPath}`);
|
|
return;
|
|
} catch (e) {}
|
|
|
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
|
|
if (/\.webm$/i.test(inPath)) {
|
|
console.log(`Copying: ${inPath} -> ${outPath}`);
|
|
await fs.copyFile(inPath, outPath);
|
|
return;
|
|
}
|
|
|
|
const filter = [
|
|
// optional: keep within reasonable resolution (prevents huge re-encodes)
|
|
// comment out if you don't want scaling
|
|
"scale='min(1280,iw)':-2",
|
|
].join(",");
|
|
|
|
const common = [
|
|
"-loglevel",
|
|
"error", // or "warning"
|
|
"-y",
|
|
"-i",
|
|
inPath,
|
|
"-vf",
|
|
filter,
|
|
"-c:a",
|
|
"libopus",
|
|
"-b:a",
|
|
AUDIO_BITRATE,
|
|
"-movflags",
|
|
"+faststart",
|
|
];
|
|
|
|
let videoArgs;
|
|
if (CODEC === "vp9") {
|
|
// VP9: good size, moderate speed
|
|
// -b:v is the easiest "size control"
|
|
videoArgs = [
|
|
"-c:v",
|
|
"libvpx-vp9",
|
|
"-b:v",
|
|
VIDEO_BITRATE,
|
|
"-preset",
|
|
"good",
|
|
"-row-mt",
|
|
"1",
|
|
"-threads",
|
|
"0",
|
|
];
|
|
} else if (CODEC === "av1") {
|
|
// AV1 (libaom-av1): usually smaller, slower
|
|
// If AV1 is too slow, switch CODEC to "vp9".
|
|
videoArgs = [
|
|
"-c:v",
|
|
"libaom-av1",
|
|
"-b:v",
|
|
VIDEO_BITRATE,
|
|
"-cpu-used",
|
|
"6",
|
|
"-threads",
|
|
"0",
|
|
"-row-mt",
|
|
"1",
|
|
];
|
|
} else {
|
|
throw new Error("CODEC must be vp9 or av1");
|
|
}
|
|
|
|
// Ensure reproducible-ish output and speed
|
|
const args = [...common, ...videoArgs, outPath];
|
|
|
|
console.log(`Compressing: ${inPath} -> ${outPath}`);
|
|
await runFFmpeg(args);
|
|
console.log(`Finished: ${outPath}`);
|
|
}
|
|
|
|
async function runWithLimit(items, limit, fn) {
|
|
const queue = items.slice();
|
|
const workers = Array.from(
|
|
{ length: Math.min(limit, items.length) },
|
|
async () => {
|
|
while (queue.length) {
|
|
const item = queue.shift();
|
|
if (!item) break;
|
|
await fn(item);
|
|
}
|
|
},
|
|
);
|
|
await Promise.all(workers);
|
|
}
|
|
|
|
const files = await walk(INPUT_DIR);
|
|
if (!files.length) {
|
|
console.log("No videos found.");
|
|
process.exit(0);
|
|
}
|
|
|
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
|
|
|
await runWithLimit(files, MAX_PARALLEL, processOne);
|
|
console.log(`Done. Wrote ${files.length} file(s) to ${OUTPUT_DIR}.`);
|