341 lines
9.6 KiB
JavaScript
341 lines
9.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|||
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
||
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import { join } from "node:path";
|
||
|
|
import test from "node:test";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
|
||
|
|
const SCRIPT = fileURLToPath(new URL("./transcript-cut.mjs", import.meta.url));
|
||
|
|
const HAS_FFMPEG =
|
||
|
|
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0 &&
|
||
|
|
spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0;
|
||
|
|
|
||
|
|
const SAMPLE_RATE = 44100;
|
||
|
|
// A continuous tone's own slope between samples never exceeds amplitude * 2*pi*f/rate
|
||
|
|
// (~2054 for 440Hz at full scale here); a raw splice between two independently-cut
|
||
|
|
// points on the same tone lands at two unrelated phases, so an unfaded join jumps far
|
||
|
|
// past that. This threshold sits well above normal tone motion and well below a splice.
|
||
|
|
const MAX_CONTINUOUS_STEP = 6000;
|
||
|
|
const MIN_SUSPICIOUS_ZERO_RUN = 20;
|
||
|
|
|
||
|
|
function fixture() {
|
||
|
|
const dir = mkdtempSync(join(tmpdir(), "media-use-transcript-cut-"));
|
||
|
|
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||
|
|
}
|
||
|
|
|
||
|
|
function run(args) {
|
||
|
|
return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
function readPcm(filePath) {
|
||
|
|
const raw = execFileSync("ffmpeg", [
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-i",
|
||
|
|
filePath,
|
||
|
|
"-f",
|
||
|
|
"s16le",
|
||
|
|
"-ac",
|
||
|
|
"1",
|
||
|
|
"-ar",
|
||
|
|
String(SAMPLE_RATE),
|
||
|
|
"-",
|
||
|
|
]);
|
||
|
|
const samples = new Int16Array(raw.buffer, raw.byteOffset, raw.length / 2);
|
||
|
|
return samples;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Three kept ranges, none starting or ending on a frame boundary of any rate below.
|
||
|
|
const UNALIGNED_KEEP = "0.137-1.913,2.845-4.111,4.9-5.47";
|
||
|
|
const UNALIGNED_KEPT_SECONDS = 1.776 + 1.266 + 0.57;
|
||
|
|
|
||
|
|
function cutVideo(t, rate, keep = UNALIGNED_KEEP) {
|
||
|
|
const { dir, cleanup } = fixture();
|
||
|
|
t.after(cleanup);
|
||
|
|
|
||
|
|
const source = join(dir, "source.mp4");
|
||
|
|
execFileSync("ffmpeg", [
|
||
|
|
"-y",
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-f",
|
||
|
|
"lavfi",
|
||
|
|
"-i",
|
||
|
|
`testsrc2=size=160x90:rate=${rate}:duration=6`,
|
||
|
|
"-f",
|
||
|
|
"lavfi",
|
||
|
|
"-i",
|
||
|
|
`sine=frequency=440:duration=6:sample_rate=${SAMPLE_RATE}`,
|
||
|
|
"-c:v",
|
||
|
|
"libx264",
|
||
|
|
"-preset",
|
||
|
|
"ultrafast",
|
||
|
|
"-pix_fmt",
|
||
|
|
"yuv420p",
|
||
|
|
"-c:a",
|
||
|
|
"aac",
|
||
|
|
source,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const transcriptPath = join(dir, "transcript.json");
|
||
|
|
writeFileSync(transcriptPath, JSON.stringify({ words: [{ text: "tone", start: 0, end: 6 }] }));
|
||
|
|
|
||
|
|
const output = join(dir, "out.mp4");
|
||
|
|
const result = run([
|
||
|
|
"--input",
|
||
|
|
source,
|
||
|
|
"--transcript",
|
||
|
|
transcriptPath,
|
||
|
|
"--keep",
|
||
|
|
keep,
|
||
|
|
"--out",
|
||
|
|
output,
|
||
|
|
"--json",
|
||
|
|
]);
|
||
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readVideoTimestamps(filePath) {
|
||
|
|
const probe = JSON.parse(
|
||
|
|
execFileSync(
|
||
|
|
"ffprobe",
|
||
|
|
[
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-select_streams",
|
||
|
|
"v:0",
|
||
|
|
"-show_entries",
|
||
|
|
"stream=time_base:packet=pts",
|
||
|
|
"-of",
|
||
|
|
"json",
|
||
|
|
"--",
|
||
|
|
filePath,
|
||
|
|
],
|
||
|
|
{ encoding: "utf8" },
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const [tickNum, tickDen] = probe.streams[0].time_base.split("/").map(Number);
|
||
|
|
// Packets come in decode order; B-frames put that out of presentation order.
|
||
|
|
const pts = probe.packets.map((packet) => Number(packet.pts)).sort((a, b) => a - b);
|
||
|
|
return { tickNum, tickDen, pts };
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const rate of ["30", "30000/1001", "60"]) {
|
||
|
|
test(
|
||
|
|
`puts every frame of a ${rate} fps video cut on the frame grid, with no gaps`,
|
||
|
|
{ skip: !HAS_FFMPEG },
|
||
|
|
(t) => {
|
||
|
|
const [rateNum, rateDen = 1] = rate.split("/").map(Number);
|
||
|
|
const { tickNum, tickDen, pts } = readVideoTimestamps(cutVideo(t, rate));
|
||
|
|
assert.ok(pts.length > rateNum / rateDen, "expected several seconds of video");
|
||
|
|
|
||
|
|
// Frame i belongs at exactly i / rate seconds. Compared as integers, so a
|
||
|
|
// timestamp rounded to the millisecond (0.067 for 2/30) cannot pass.
|
||
|
|
const offGrid = pts.filter(
|
||
|
|
(value, index) => value * tickNum * rateNum !== index * rateDen * tickDen,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
offGrid.length,
|
||
|
|
0,
|
||
|
|
`${offGrid.length} of ${pts.length} frames are off the ${rate} fps grid (timestamps in units of ${tickNum}/${tickDen} s)`,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
test(
|
||
|
|
"keeps the keyframes of a video cut no more than a second apart",
|
||
|
|
{ skip: !HAS_FFMPEG },
|
||
|
|
(t) => {
|
||
|
|
// One kept range of almost four seconds, so only the encoder decides where keyframes go.
|
||
|
|
const output = cutVideo(t, "30", "0.137-4.111,4.9-5.47");
|
||
|
|
const probe = JSON.parse(
|
||
|
|
execFileSync(
|
||
|
|
"ffprobe",
|
||
|
|
[
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-select_streams",
|
||
|
|
"v:0",
|
||
|
|
"-show_entries",
|
||
|
|
"packet=pts_time,flags",
|
||
|
|
"-of",
|
||
|
|
"json",
|
||
|
|
"--",
|
||
|
|
output,
|
||
|
|
],
|
||
|
|
{ encoding: "utf8" },
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const keyframes = probe.packets
|
||
|
|
.filter((packet) => packet.flags.startsWith("K"))
|
||
|
|
.map((packet) => Number(packet.pts_time))
|
||
|
|
.sort((a, b) => a - b);
|
||
|
|
const gaps = keyframes.slice(1).map((time, index) => time - keyframes[index]);
|
||
|
|
const widest = Math.max(...gaps);
|
||
|
|
// A frame of slack: the forced keyframe is the first frame at or after each second.
|
||
|
|
assert.ok(widest < 1.05, `keyframes are up to ${widest.toFixed(3)}s apart`);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test(
|
||
|
|
"does not pad the audio of a video cut with encoder priming at each splice",
|
||
|
|
{ skip: !HAS_FFMPEG },
|
||
|
|
(t) => {
|
||
|
|
const seconds = readPcm(cutVideo(t, "30")).length / SAMPLE_RATE;
|
||
|
|
// The single AAC encode may round the end up by a frame or two (23 ms each).
|
||
|
|
// Encoding every segment separately instead adds about 25 ms per splice on top.
|
||
|
|
assert.ok(
|
||
|
|
Math.abs(seconds - UNALIGNED_KEPT_SECONDS) < 0.05,
|
||
|
|
`audio runs ${seconds.toFixed(3)}s for ${UNALIGNED_KEPT_SECONDS.toFixed(3)}s kept`,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test(
|
||
|
|
"keeps a spliced tone continuous at every cut, with no raw phase jump or silence gap",
|
||
|
|
{ skip: !HAS_FFMPEG },
|
||
|
|
(t) => {
|
||
|
|
const { dir, cleanup } = fixture();
|
||
|
|
t.after(cleanup);
|
||
|
|
|
||
|
|
const source = join(dir, "tone.wav");
|
||
|
|
execFileSync("ffmpeg", [
|
||
|
|
"-y",
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-f",
|
||
|
|
"lavfi",
|
||
|
|
"-i",
|
||
|
|
`sine=frequency=440:duration=6:sample_rate=${SAMPLE_RATE}`,
|
||
|
|
source,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const transcriptPath = join(dir, "transcript.json");
|
||
|
|
writeFileSync(transcriptPath, JSON.stringify({ words: [{ text: "tone", start: 0, end: 6 }] }));
|
||
|
|
|
||
|
|
const output = join(dir, "out.wav");
|
||
|
|
// Two "ugly" removal ranges: neither aligned to the 440Hz period, so the kept
|
||
|
|
// segments' cut edges land at unrelated phases of the same continuous tone --
|
||
|
|
// exactly the shape that clicks without a fade, and the shape jrusso1020's
|
||
|
|
// review proved this branch never actually fades.
|
||
|
|
const result = run([
|
||
|
|
"--input",
|
||
|
|
source,
|
||
|
|
"--transcript",
|
||
|
|
transcriptPath,
|
||
|
|
"--remove",
|
||
|
|
"1.37-1.83,3.29-3.71",
|
||
|
|
"--out",
|
||
|
|
output,
|
||
|
|
"--json",
|
||
|
|
]);
|
||
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
|
|
|
||
|
|
const samples = readPcm(output);
|
||
|
|
assert.ok(samples.length > SAMPLE_RATE, "expected several seconds of audio");
|
||
|
|
|
||
|
|
let maxStep = 0;
|
||
|
|
let zeroRun = 0;
|
||
|
|
let maxZeroRun = 0;
|
||
|
|
for (let i = 1; i < samples.length; i++) {
|
||
|
|
const step = Math.abs(samples[i] - samples[i - 1]);
|
||
|
|
if (step > maxStep) maxStep = step;
|
||
|
|
if (samples[i] === 0) {
|
||
|
|
zeroRun++;
|
||
|
|
if (zeroRun > maxZeroRun) maxZeroRun = zeroRun;
|
||
|
|
} else {
|
||
|
|
zeroRun = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
assert.ok(
|
||
|
|
maxStep <= MAX_CONTINUOUS_STEP,
|
||
|
|
`largest sample-to-sample step was ${maxStep}, expected <= ${MAX_CONTINUOUS_STEP} (a raw, unfaded splice)`,
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
maxZeroRun < MIN_SUSPICIOUS_ZERO_RUN,
|
||
|
|
`found a run of ${maxZeroRun} consecutive zero samples, expected < ${MIN_SUSPICIOUS_ZERO_RUN} (a priming-silence gap)`,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// Frame 32 at 30 fps sits at 1.0666666 s. A -ss rounded to 1.067 lands just past
|
||
|
|
// it, so ffmpeg's accurate seek starts that segment on frame 33 and the cut comes
|
||
|
|
// out a frame short from the join on.
|
||
|
|
const THIRTY_FPS_FRAME_32 = 32 / 30;
|
||
|
|
|
||
|
|
test("a kept range starting on a 30 fps frame keeps that frame", { skip: !HAS_FFMPEG }, (t) => {
|
||
|
|
const { dir, cleanup } = fixture();
|
||
|
|
t.after(cleanup);
|
||
|
|
|
||
|
|
const source = join(dir, "src.mp4");
|
||
|
|
execFileSync("ffmpeg", [
|
||
|
|
"-y",
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-f",
|
||
|
|
"lavfi",
|
||
|
|
"-i",
|
||
|
|
"testsrc2=size=320x180:rate=30",
|
||
|
|
"-f",
|
||
|
|
"lavfi",
|
||
|
|
"-i",
|
||
|
|
`sine=frequency=440:sample_rate=${SAMPLE_RATE}`,
|
||
|
|
"-t",
|
||
|
|
"4",
|
||
|
|
"-c:v",
|
||
|
|
"libx264",
|
||
|
|
"-pix_fmt",
|
||
|
|
"yuv420p",
|
||
|
|
"-c:a",
|
||
|
|
"aac",
|
||
|
|
source,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const transcriptPath = join(dir, "transcript.json");
|
||
|
|
writeFileSync(transcriptPath, JSON.stringify([{ text: "a", start: 0.1, end: 3.9 }]));
|
||
|
|
|
||
|
|
const output = join(dir, "out.mp4");
|
||
|
|
const result = run([
|
||
|
|
"--input",
|
||
|
|
source,
|
||
|
|
"--transcript",
|
||
|
|
transcriptPath,
|
||
|
|
"--keep",
|
||
|
|
`0-1,${THIRTY_FPS_FRAME_32}-3`,
|
||
|
|
"--out",
|
||
|
|
output,
|
||
|
|
"--json",
|
||
|
|
]);
|
||
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
|
|
|
||
|
|
// Frames 0-29 for 0-1s, then frames 32-89 for the second segment. Rounding
|
||
|
|
// -ss to the millisecond drops frame 32 and yields 87.
|
||
|
|
const frames = Number(
|
||
|
|
execFileSync(
|
||
|
|
"ffprobe",
|
||
|
|
[
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-select_streams",
|
||
|
|
"v",
|
||
|
|
"-count_packets",
|
||
|
|
"-show_entries",
|
||
|
|
"stream=nb_read_packets",
|
||
|
|
"-of",
|
||
|
|
"csv=p=0",
|
||
|
|
"--",
|
||
|
|
output,
|
||
|
|
],
|
||
|
|
{ encoding: "utf8" },
|
||
|
|
).trim(),
|
||
|
|
);
|
||
|
|
assert.equal(frames, 88, `expected 88 frames, got ${frames}`);
|
||
|
|
});
|