Skills › Visual Explainer › visual-explainer › media.mjs
media.mjs
73 lines · plugins/visual-explainer/video/media.mjs at 9d5f880
1import { execFileSync, spawn } from "node:child_process";
2import { createHash } from "node:crypto";
3import { readdirSync, writeFileSync } from "node:fs";
4import { join, parse } from "node:path";
6const RATE = 48000;
7const WPS = 2.5; // caption reading speed in words per second
8const AUDIO = /\.(mp3|wav|m4a|aac|ogg|opus|flac|aiff?)$/i;
10// A narration line's clip is <id>.<ext> in the voice folder. The id hashes the text, so an edited
11// line needs a new clip and an unchanged line keeps its old one.
12export const lineId = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12);
14// Line text -> mono 16-bit PCM. Every line must have a clip.
15export function loadVoice(dir, lines) {
16 // Only finished audio files count, so a leftover download such as <id>.tmp never stands in for a clip.
17 const files = new Map(readdirSync(dir).filter((f) => AUDIO.test(f)).map((f) => [parse(f).name, join(dir, f)]));
18 const missing = [...new Set(lines)].filter((t) => !files.has(lineId(t)));
19 if (missing.length) {
20 throw new Error(`${missing.length} line(s) have no clip in ${dir}. Make <id>.mp3 (or .wav) for each:\n${missing.map((t) => ` ${lineId(t)}\t${t}`).join("\n")}`);
21 }
22 const decode = (file) => {
23 try { return execFileSync("ffmpeg", ["-v", "error", "-i", file, "-f", "s16le", "-ac", "1", "-ar", String(RATE), "pipe:1"], { maxBuffer: 1 << 30, stdio: ["ignore", "pipe", "pipe"] }); }
24 catch (e) { throw new Error(`Not a readable audio clip: ${file}\n${e.stderr?.toString().trim()}`); }
25 };
26 return new Map([...new Set(lines)].map((t) => [t, decode(files.get(lineId(t)))]));
27}
29// Seconds a line lasts: its clip, or the time a viewer needs to read it as a caption.
30export const lineLength = (text, pcm) => (pcm ? pcm.length / 2 / RATE : Math.max(1.5, text.split(/\s+/).length / WPS));
32// Lay each clip at its start time on one silent track and write a WAV file.
33export function writeTrack(file, clips, total) {
34 const data = Buffer.alloc(Math.ceil(total * RATE) * 2);
35 for (const { at, pcm } of clips) pcm.copy(data, Math.round(at * RATE) * 2);
36 const head = Buffer.alloc(44);
37 head.write("RIFF", 0); head.writeUInt32LE(36 + data.length, 4); head.write("WAVE", 8);
38 head.write("fmt ", 12); head.writeUInt32LE(16, 16); head.writeUInt16LE(1, 20); head.writeUInt16LE(1, 22);
39 head.writeUInt32LE(RATE, 24); head.writeUInt32LE(RATE * 2, 28); head.writeUInt16LE(2, 32); head.writeUInt16LE(16, 34);
40 head.write("data", 36); head.writeUInt32LE(data.length, 40);
41 writeFileSync(file, Buffer.concat([head, data]));
42}
44// ffmpeg reading JPEG frames on stdin at a constant rate.
45export function encoder(out, { fps, audio }) {
46 const args = ["-y", "-v", "error", "-f", "image2pipe", "-c:v", "mjpeg", "-framerate", String(fps), "-i", "pipe:0"];
47 if (audio) args.push("-i", audio, "-map", "0:v", "-map", "1:a", "-c:a", "aac", "-b:a", "192k");
48 args.push("-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "medium", "-tune", "animation", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out);
49 const ff = spawn("ffmpeg", args, { stdio: ["pipe", "inherit", "inherit"] });
50 let failure = null;
51 const exited = new Promise((resolve, reject) => {
52 ff.on("error", reject);
53 ff.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code}`))));
54 });
55 exited.catch((e) => { failure = e; });
56 ff.stdin.on("error", () => {}); // EPIPE once ffmpeg has exited; its exit code carries the failure
57 return {
58 async write(buf) {
59 if (failure) throw failure;
60 // A drain never comes from an ffmpeg that has exited, so wait on either.
61 if (!ff.stdin.write(buf)) await Promise.race([new Promise((r) => ff.stdin.once("drain", r)), exited]);
62 },
63 async done() { ff.stdin.end(); await exited; },
64 kill() { ff.kill("SIGKILL"); },
65 };
66}
68export function requireFfmpeg() {
69 try { execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); }
70 catch { throw new Error("ffmpeg is not on PATH. Install it first, for example: brew install ffmpeg"); }
71}
73export const duration = (file) => Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]).toString());