#!/usr/bin/env node // Remove a span from a FINISHED video, picture and sound together. // // node slice-out.mjs [fade] // // Used to shorten a long static screen in an intro. Cutting the finished file // rather than re-rendering is not a shortcut, it is the safer edit: everything // after the cut moves earlier by exactly the same amount in BOTH streams, so // every sync already judged by ear -- the shouts against the game's silent gap, // the gong, the fatality, the fanfare -- survives untouched. Re-rendering would // mean re-deriving PREROLL and every absolute time in the finale envelope. // // Two conditions make it safe, and both must be checked before using it: // 1. the PICTURE either side of the cut is the same, so the video splice is // invisible (a static screen; verify, do not assume); // 2. the SOUND either side is rhythmically continuous, so the audio splice // does not stumble. Cut a whole number of musical periods -- measure the // period by autocorrelating the envelope, do not guess it from the tempo. // // LENGTH IS PRESERVED EXACTLY. acrossfade shortens its output by the fade, so // each side is extended by half a fade first; the result is exactly len shorter // than the input, matching the hard video cut. Getting this wrong drifts the // audio against the picture by the fade duration for the whole rest of the film. import { execFileSync } from "node:child_process"; const [IN, OUT, AT, LEN, FADE = "0.060"] = process.argv.slice(2); if (!IN || !OUT || !AT || !LEN) { console.error("usage: node slice-out.mjs [fade]"); process.exit(1); } const at = Number(AT), len = Number(LEN), fade = Number(FADE); const dur = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", IN]).toString().trim()); const half = fade / 2; // Video: hard cut at `at`, resuming at `at+len`. Audio: the same cut, but each // side reaches half a fade further in so the crossfade costs nothing in length. const fc = [ `[0:v]trim=0:${at},setpts=PTS-STARTPTS[v0]`, `[0:v]trim=${at + len}:${dur},setpts=PTS-STARTPTS[v1]`, `[v0][v1]concat=n=2:v=1:a=0[v]`, `[0:a]atrim=0:${at + half},asetpts=PTS-STARTPTS[a0]`, `[0:a]atrim=${at + len - half}:${dur},asetpts=PTS-STARTPTS[a1]`, `[a0][a1]acrossfade=d=${fade}:c1=tri:c2=tri[a]`, ].join(";"); execFileSync("ffmpeg", ["-y", "-v", "warning", "-i", IN, "-filter_complex", fc, "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", OUT], { stdio: "inherit" }); const got = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", OUT]).toString().trim()); const want = dur - len; console.log(`${IN} ${dur.toFixed(3)}s -> ${OUT} ${got.toFixed(3)}s`); console.log(` cut ${len}s at ${at}s; predicted ${want.toFixed(3)}s, off by ${(got - want).toFixed(3)}s`);