Add HyperFrames skills (from heygen-com/hyperframes, Apache-2.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXVsD1DgdzZLiPuwtKzMSY
This commit is contained in:
300 files changed
+49175
No files matched your search
@@ -0,0 +1,341 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in new issue
Block a user