404 lines
11 KiB
JavaScript
404 lines
11 KiB
JavaScript
/**
|
||
* Сканирует папку с .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");
|
||
}
|
||
// Резервируем место под Cues в начале файла, чтобы браузер мог перематывать
|
||
// (иначе Cues пишутся в конец и duration=N/A, seek не работает).
|
||
a.push("-reserve_index_space", "50000");
|
||
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();
|