mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 20:37:30 +00:00
* fix(media-use): transcript-cut video renders without repeated frames or a keyframe warning transcript-cut wrote each kept range to a temporary .mkv, and ffmpeg writes Matroska timestamps in whole milliseconds. A 30 fps frame that belongs at 2/30 s ended up at 0.067, the final copy kept it there, and the renderer showed the previous frame wherever a timestamp was late. The temporary files are now .mov, which holds the frame times exactly and still carries PCM audio, so the audio is encoded once, as before. The cut also gets a keyframe every second. x264 on its own left them several seconds apart, and the renderer warned about the footage this script had just written. Fixes #5417 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(media-use): transcript-cut ranges start on their first frame with any ffmpeg ffmpeg 7 and later start a kept range that begins between two source frames one frame late, and .mov rounds that lead-in to the millisecond, which put the frames off the grid again. ffmpeg 6 did not do this, so the tests passed locally and failed in CI. Each temporary file is now written at a constant frame rate, so the first frame of a range is at zero whichever ffmpeg runs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: James <james.russo@heygen.com>
342 lines
9.6 KiB
JavaScript
342 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}`);
|
|
});
|