/** * Сканирует папку с .mp4, создаёт рядом .webm и .webp (превью). * Пропускает ролик, если уже есть оба выхода с тем же базовым именем. * Недостающие из пары — догенерирует. * * Требуется: ffmpeg и ffprobe в PATH. * * Использование: node scripts/convert-videos.mjs <папка_с_роликами> */ import { execFile, execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const isTty = process.stdout.isTTY; function hasBin(name) { try { execFileSync(name, ["-version"], { stdio: "ignore" }); return true; } catch { return false; } } function getDurationSec(inputPath) { const out = execFileSync("ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", inputPath, ], { encoding: "utf8" }); const n = parseFloat(String(out).trim()); return Number.isFinite(n) && n > 0 ? n : 0.1; } function hasAudioStream(inputPath) { try { const o = execFileSync( "ffprobe", [ "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index", "-of", "csv=p=0", inputPath, ], { encoding: "utf8" }, ); return o.trim().length > 0; } catch { return false; } } /** * @param {string[]} ffmpegArgs аргументы без префикса `ffmpeg` (с `-i` и в конце выход) * @param {{ durationSec: number, onProgress: (p: number) => void }} o */ function runFfmpegProgress(ffmpegArgs, { durationSec, onProgress }) { const totalMs = Math.max(1, Math.round(durationSec * 1000)); const allArgs = [ "-hide_banner", "-y", "-loglevel", "error", "-progress", "pipe:1", ...ffmpegArgs, ]; return new Promise((resolve, reject) => { const child = spawn("ffmpeg", allArgs, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); onProgress(0); let outBuf = ""; let errBuf = ""; let lastPct = -1; let lastLogTs = 0; if (child.stdout) { child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => { outBuf += chunk; for (;;) { const nl = outBuf.indexOf("\n"); if (nl < 0) break; const line = outBuf.slice(0, nl); outBuf = outBuf.slice(nl + 1); if (line.startsWith("out_time_ms=")) { const ms = parseInt(line.slice("out_time_ms=".length), 10); if (!Number.isFinite(ms)) continue; const pct = Math.min(100, (ms / totalMs) * 100); const now = Date.now(); const tick = isTty ? pct - lastPct >= 0.3 || now - lastLogTs > 200 : Math.floor(pct) > Math.floor(lastPct) && Math.floor(pct) % 5 === 0; if (tick && pct > lastPct) { lastPct = pct; lastLogTs = now; onProgress(pct); } } } }); } if (child.stderr) { child.stderr.setEncoding("utf8"); child.stderr.on("data", (d) => { errBuf += d; }); } child.on("error", reject); child.on("close", (code) => { if (code === 0) { onProgress(100); resolve(); } else { reject( new Error( (errBuf && errBuf.trim()) || `ffmpeg завершился с кодом ${code}`, ), ); } }); }); } function runFfmpegQuiet(ffmpegArgs) { return new Promise((resolve, reject) => { execFile("ffmpeg", ["-hide_banner", "-y", ...ffmpegArgs], (err) => { if (err) reject(err); else resolve(); }); }); } /** * @param {object} p * @param {number} p.fileIndex 1..fileTotal * @param {number} p.fileTotal * @param {number} p.stepIndex текущий шаг 0..totalSteps-1 * @param {number} p.totalSteps * @param {string} p.name basename mp4 * @param {string} p.label webm|webp * @param {number} p.stepPct 0..100 внутри шага */ function renderStatus(p) { const overall = p.totalSteps > 0 ? ((p.stepIndex + p.stepPct / 100) / p.totalSteps) * 100 : 100; const s = `Общий: ${overall.toFixed(0)}% | ролик ${p.fileIndex}/${p.fileTotal}: ${p.name} | ${p.label} ${p.stepPct.toFixed(0)}%`; if (isTty) { process.stdout.write(`\r\x1B[K${s}`); } else { const now = Date.now(); if (!p._last || now - p._last > 500 || p.stepPct >= 100) { p._last = now; console.log(s); } } } async function toWebm(inputPath, outPath, statusCtx) { const a = [ "-i", inputPath, "-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", ]; if (hasAudioStream(inputPath)) { a.push("-c:a", "libopus", "-b:a", "128k"); } else { a.push("-an"); } a.push(outPath); const d = getDurationSec(inputPath); await runFfmpegProgress(a, { durationSec: d, onProgress: (pct) => { if (statusCtx) { statusCtx.stepPct = pct; renderStatus(statusCtx); } }, }); } /** Превью: один кадр, timestamp ~ min(1 c, 10% длительности), max ширина 1280. */ async function toWebpPreview(inputPath, outPath) { const d = getDurationSec(inputPath); const ss = Math.min(1, Math.max(0, d * 0.1)); await runFfmpegQuiet([ "-ss", String(ss), "-i", inputPath, "-vframes", "1", "-vf", "scale=1280:-1:flags=lanczos", "-c:v", "libwebp", "-quality", "85", outPath, ]); } function listMp4(dir) { const names = fs.readdirSync(dir, { withFileTypes: true }); return names .filter((d) => d.isFile() && d.name.toLowerCase().endsWith(".mp4")) .map((d) => path.join(dir, d.name)); } async function main() { const raw = process.argv[2]; if (!raw) { console.error("Укажи папку: node scripts/convert-videos.mjs <папка>"); process.exit(1); } const targetDir = path.resolve(projectRoot, raw); if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { console.error("Папка не найдена или не каталог:", targetDir); process.exit(1); } if (!hasBin("ffmpeg") || !hasBin("ffprobe")) { console.error("Нужны ffmpeg и ffprobe в PATH."); process.exit(1); } const mp4s = listMp4(targetDir); if (mp4s.length === 0) { console.log("В папке нет .mp4:", targetDir); return; } let skipped = 0; /** @type {{ mp4: string, base: string, webm: string, webp: string, needW: boolean, needP: boolean }[]} */ const jobs = []; for (const mp4 of mp4s) { const base = path.join( path.dirname(mp4), path.basename(mp4, path.extname(mp4)), ); const webm = base + ".webm"; const webp = base + ".webp"; const haveWebm = fs.existsSync(webm); const haveWebp = fs.existsSync(webp); if (haveWebm && haveWebp) { skipped += 1; continue; } jobs.push({ mp4, base, webm, webp, needW: !haveWebm, needP: !haveWebp, }); } if (skipped > 0) { console.log( `Пропущено (уже webm+webp): ${skipped} из ${mp4s.length} роликов.`, ); } const totalSteps = jobs.reduce( (n, j) => n + (j.needW ? 1 : 0) + (j.needP ? 1 : 0), 0, ); if (jobs.length === 0) { console.log("Конвертировать нечего."); return; } console.log( `К конвертации: ${jobs.length} роликов, шагов кодирования: ${totalSteps}.`, ); let stepIndex = 0; const fileTotal = jobs.length; for (let fi = 0; fi < jobs.length; fi += 1) { const j = jobs[fi]; const name = path.basename(j.mp4); const fileIndex = fi + 1; const rel = path.relative(projectRoot, j.mp4) || j.mp4; try { if (j.needW) { if (!isTty) { console.log( `Ролик ${fileIndex}/${fileTotal}: ${name} — webm (шаг ${stepIndex + 1}/${totalSteps})`, ); } else { process.stdout.write( `\nРолик ${fileIndex}/${fileTotal}: ${name} — webm\n`, ); } const ctx = isTty ? { fileIndex, fileTotal, stepIndex, totalSteps, name, label: "webm", stepPct: 0, } : { fileIndex, fileTotal, stepIndex, totalSteps, name, label: "webm", stepPct: 0, _last: 0, }; await toWebm(j.mp4, j.webm, ctx); if (isTty) { process.stdout.write("\n"); } stepIndex += 1; } else { if (!isTty) { console.log( `Ролик ${fileIndex}/${fileTotal}: ${name} — webm уже есть.`, ); } } if (j.needP) { if (!isTty) { console.log( `Ролик ${fileIndex}/${fileTotal}: ${name} — webp (шаг ${stepIndex + 1}/${totalSteps})`, ); } else { if (!j.needW) { process.stdout.write( `\nРолик ${fileIndex}/${fileTotal}: ${name} — webp\n`, ); } const overall = (stepIndex / totalSteps) * 100; process.stdout.write( `\r\x1B[KОбщий: ${overall.toFixed(0)}% | ролик ${fileIndex}/${fileTotal}: ${name} | webp …`, ); } await toWebpPreview(j.mp4, j.webp); if (isTty) { const o = ((stepIndex + 1) / totalSteps) * 100; process.stdout.write( `\r\x1B[KОбщий: ${o.toFixed(0)}% | ролик ${fileIndex}/${fileTotal}: ${name} | webp 100%`, ); process.stdout.write("\n"); } else { const o = ((stepIndex + 1) / totalSteps) * 100; console.log(`Общий: ${o.toFixed(0)}% | webp готово — ${rel}`); } stepIndex += 1; } else if (j.needW && !j.needP && !isTty) { console.log( `Ролик ${fileIndex}/${fileTotal}: ${name} — webp уже есть.`, ); } } catch (e) { if (isTty) { process.stdout.write("\n"); } console.error("Ошибка для", rel, e.message || e); process.exitCode = 1; } } if (isTty) { process.stdout.write( `\r\x1B[KГотово: ${jobs.length} роликов, ${totalSteps} шагов.\n`, ); } else { console.log(`Готово: ${jobs.length} роликов, ${totalSteps} шагов.`); } } main();