Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped

This commit is contained in:
Carlos Escalante
2026-07-14 21:48:59 -06:00
parent 301a0b687a
commit fbc0816be6
9 changed files with 583 additions and 1 deletions

View File

@@ -0,0 +1,88 @@
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { buildSrt, type Clip } from "./subtitles";
const execFileAsync = promisify(execFile);
function hasFfmpeg() {
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const SUBTITLE_STYLE =
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
function escapeForFilter(p: string) {
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
}
export async function renderFinalVideo({
rawWebmPath,
clips,
outDir,
}: {
rawWebmPath: string;
clips: Clip[];
outDir: string;
}): Promise<{ videoFile: string; degraded: string[] }> {
const degraded: string[] = [];
const srtPath = path.join(outDir, "captions.srt");
fs.writeFileSync(srtPath, buildSrt(clips));
if (!hasFfmpeg()) {
const dest = path.join(outDir, "video.webm");
fs.renameSync(rawWebmPath, dest);
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
return { videoFile: dest, degraded };
}
const narrated = clips.filter((c) => c.narration);
const mp4Path = path.join(outDir, "video.mp4");
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
if (narrated.length === 0) {
degraded.push("No narration available — burned-in subtitles only.");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath,
"-vf", srtArg,
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-an",
mp4Path,
]);
} else {
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
const adelayParts = narrated.map(
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
);
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
const filterComplex = [
`[0:v]${srtArg}[vout]`,
...adelayParts,
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
].join(";");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath, ...audioInputs,
"-filter_complex", filterComplex,
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-c:a", "aac",
"-shortest",
"-movflags", "+faststart",
mp4Path,
]);
}
fs.rmSync(rawWebmPath, { force: true });
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
return { videoFile: mp4Path, degraded };
}