Files
site-portfolio/scripts/convert-videos.mjs
T

401 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Сканирует папку с .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();