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}`); try { await runFFmpeg(args); console.log(`Finished: ${outPath}`); } catch (e) { console.log(`Failed: ${outPath}`); await fs.unlink(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}.`);