mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-13 20:58:33 +00:00
* fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice transcript-cut extracts each kept range to its own final-codec file, then concat-copies them. That produces two audible artifacts at every cut: 1. No boundary fades. Concat splices raw segment edges together, so the waveform steps discontinuously and each cut clicks. 2. Encoder priming silence. Each segment is encoded to AAC independently, so the encoder pads every one with priming samples. Stream-copy concat bakes that padding in as a silent gap at each splice. Measured 24.4 ms and 24.1 ms at the two splices of a three-range cut. It accumulates: the nth splice lands roughly 25n ms late, so splice positions drift away from the sum of the preceding segment durations. The second survives the first -- adding fades alone leaves the gap. Fix: intermediates carry PCM (.mkv for video, .wav for audio-only) instead of the final codec, so audio is encoded exactly once, at concat. Boundary fades of 30 ms are applied per segment, scaled down on segments shorter than 4x the ramp and skipped on degenerate ones. Measured on the same three-range cut, widest run of digital silence at each splice: before 24.4 ms / 24.1 ms after 0.1 ms / 0.1 ms --copy is unchanged: stream copy cannot filter, so it keeps producing final-codec segments and a copy concat. Audio-only output is byte-identical in duration before and after. scripts/lib test suite: 34/35, the one failure (lut-preset-provider) pre-exists on main and is unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(skills): regenerate manifest for media-use * fix(media-use): honor the fadeIn/fadeOut flags fadeFilterFor was passed The caller already computed {fadeIn, fadeOut} per segment, but fadeFilterFor still took one parameter and always emitted both afade ramps -- so a single kept segment (no splice at all) and every segment's true start/end got faded regardless. Extracted the pure function into lib/transcriptCutFade.mjs, made it honor both flags, and added its first unit test: first/middle/last/single-segment filter strings, no ffmpeg needed, fails against the previous version. Also installs ffmpeg in the Test: skills CI job (same action the Producer job uses), so transcript-cut.test.mjs's real-tone RED/GREEN proof runs in CI instead of skipping silently for lack of the binary. * fix(media-use): fix the packed-CLI import and lint on transcript-cut.mjs build-copy.mjs replaces dist/skills/media-use/scripts/lib with a copy of packages/cli/src/media-use/lib, so transcriptCutFade.mjs needed a byte-identical twin there (and in MEDIA_USE_COPY_NAMES) or the packed CLI's transcript-cut.mjs imported a file that no longer existed. Moved its test alongside, matching every other lib helper's tests. Also drops three redundant Boolean(args.copy) wrappers (a ternary condition already coerces) that oxlint's no-extra-boolean-cast flagged. * style(media-use): reflow the shortened ext ternary to oxfmt's shape Dropping the Boolean() wrapper shortened the line enough that oxfmt 0.41 now collapses the ternary onto one line; format:check was failing on the stale multi-line version. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
271 lines
8.9 KiB
JavaScript
271 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, extname, join, resolve } from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import { compileCutList } from "./lib/cutlist.mjs";
|
|
import { fadeFilterFor } from "./lib/transcriptCutFade.mjs";
|
|
import { track } from "./lib/telemetry.mjs";
|
|
|
|
const { values: args } = parseArgs({
|
|
options: {
|
|
input: { type: "string" },
|
|
transcript: { type: "string" },
|
|
remove: { type: "string" },
|
|
"remove-words": { type: "string" },
|
|
"remove-fillers": { type: "string" },
|
|
"cut-silence": { type: "string" },
|
|
keep: { type: "string" },
|
|
copy: { type: "boolean", default: false },
|
|
plan: { type: "boolean", default: false },
|
|
out: { type: "string" },
|
|
json: { type: "boolean", default: false },
|
|
help: { type: "boolean", short: "h", default: false },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
if (args.help) {
|
|
console.log(`media-use transcript-cut — compile transcript edits into video cuts
|
|
|
|
Usage:
|
|
node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
|
|
|
|
Options:
|
|
--input Source video/audio file
|
|
--transcript JSON word transcript, array or { words: [...] }
|
|
--remove Time ranges to remove, seconds: a-b,c-d
|
|
--remove-words Word-index ranges to remove: 12-18,40-41
|
|
--remove-fillers Comma list of filler words to remove
|
|
--cut-silence Remove inter-word gaps longer than this many seconds
|
|
--keep Inverse mode: direct kept ranges, mutually exclusive with removal
|
|
--copy Use stream copy for faster, keyframe-snapped cuts
|
|
--plan Print kept segment JSON and exit without ffmpeg
|
|
--out Output file
|
|
--json Output JSON status
|
|
--help, -h Show this help`);
|
|
process.exit(0);
|
|
}
|
|
|
|
try {
|
|
run();
|
|
await track("media_use_transcript_cut", {
|
|
mode: args.plan ? "plan" : "encode",
|
|
remove_fillers: !!args["remove-fillers"],
|
|
cut_silence: !!args["cut-silence"],
|
|
ranges: !!args.remove,
|
|
keep: !!args.keep,
|
|
});
|
|
} catch (err) {
|
|
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
|
else console.error(`error: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function run() {
|
|
if (!args.transcript) throw new Error("--transcript is required");
|
|
const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
|
|
const segments = compileCutList(transcript, {
|
|
remove: args.remove,
|
|
removeWords: args["remove-words"],
|
|
removeFillers: args["remove-fillers"],
|
|
cutSilence: args["cut-silence"],
|
|
keep: args.keep,
|
|
});
|
|
|
|
if (args.plan) {
|
|
console.log(JSON.stringify(segments));
|
|
return;
|
|
}
|
|
|
|
if (!args.input || !args.out)
|
|
throw new Error("--input and --out are required unless --plan is set");
|
|
if (segments.length === 0) throw new Error("cut list has no kept segments");
|
|
|
|
const inputPath = resolve(args.input);
|
|
const outPath = resolve(args.out);
|
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
|
|
const keptSeconds = sumDurations(segments);
|
|
const totalSeconds = probeDuration(inputPath);
|
|
|
|
try {
|
|
const parts = segments.map((segment, index) => {
|
|
// Intermediates carry PCM audio, not the final codec. Encoding each
|
|
// segment to a lossy codec separately makes the encoder pad every segment
|
|
// with priming silence (~25-35ms for AAC), which concat then bakes in as a
|
|
// gap at each cut -- a defect distinct from, and surviving, the fades
|
|
// below. PCM has no priming, so audio is encoded exactly once, at concat.
|
|
const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" : ".mkv";
|
|
const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`);
|
|
// --copy stays fade-free (stream copy cannot filter). A segment's true
|
|
// start/end (index 0's start, the last segment's end) borders nothing
|
|
// kept, so only an interior splice edge gets a ramp.
|
|
const fade = args.copy
|
|
? null
|
|
: fadeFilterFor(segment.end - segment.start, {
|
|
fadeIn: index > 0,
|
|
fadeOut: index < segments.length - 1,
|
|
});
|
|
cutSegment(inputPath, segment, out, args.copy, fade);
|
|
return out;
|
|
});
|
|
const listPath = join(tmpDir, "list.txt");
|
|
writeFileSync(
|
|
listPath,
|
|
parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
|
|
);
|
|
// Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
|
|
// then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
|
|
const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
|
|
// --copy already produced final-codec segments, so concat can stream-copy.
|
|
// Otherwise the PCM intermediates are encoded here, once, for the whole file.
|
|
const concatCodecs = args.copy
|
|
? ["-c", "copy"]
|
|
: isAudioOnly(outPath)
|
|
? encodeArgsFor(extname(outPath).toLowerCase())
|
|
: ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
|
|
execFileSync(
|
|
"ffmpeg",
|
|
["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...concatCodecs, tmpOut],
|
|
{
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
renameSync(tmpOut, outPath);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
|
|
// can silently swallow the whole cut. Compare, then surface the drift in BOTH
|
|
// the stderr warning (human) and the --json result (pipelines).
|
|
let copyDrift = null;
|
|
if (args.copy) {
|
|
const outSeconds = probeDuration(outPath);
|
|
if (Math.abs(outSeconds - keptSeconds) > 1) {
|
|
copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
|
|
if (!args.json) {
|
|
console.error(
|
|
`warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (args.json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
ok: true,
|
|
input: inputPath,
|
|
out: outPath,
|
|
segments,
|
|
kept_s: round3(keptSeconds),
|
|
total_s: round3(totalSeconds),
|
|
...(copyDrift && { copy_drift: copyDrift }),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
|
|
totalSeconds,
|
|
)}s)`,
|
|
);
|
|
console.log(`next: resolve --from ${outPath} --type <type>`);
|
|
}
|
|
|
|
function cutSegment(inputPath, segment, outPath, copy, fade) {
|
|
const argv = [
|
|
"-y",
|
|
"-nostdin",
|
|
"-ss",
|
|
fmt(segment.start),
|
|
"-i",
|
|
inputPath,
|
|
"-to",
|
|
fmt(segment.end - segment.start),
|
|
];
|
|
if (copy) {
|
|
argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
|
|
} else if (extname(outPath).toLowerCase() === ".mkv") {
|
|
// Concat splices raw segment edges together; without a short ramp the
|
|
// waveform steps discontinuously at every boundary and you hear a click.
|
|
if (fade) argv.push("-af", fade);
|
|
// Video intermediate: keep the picture cheap and the audio uncompressed.
|
|
argv.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "pcm_s16le");
|
|
} else {
|
|
if (fade) argv.push("-af", fade);
|
|
argv.push("-c:a", "pcm_s16le");
|
|
}
|
|
argv.push(outPath);
|
|
execFileSync("ffmpeg", argv, { stdio: "ignore" });
|
|
}
|
|
|
|
// Codec set per output container. Audio-only outputs must not get the
|
|
// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
|
|
function isAudioOnly(filePath) {
|
|
return [".wav", ".mp3", ".m4a", ".aac", ".flac"].includes(extname(filePath).toLowerCase());
|
|
}
|
|
|
|
function encodeArgsFor(ext) {
|
|
if (ext === ".wav") return ["-c:a", "pcm_s16le"];
|
|
if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
|
|
if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
|
|
if (ext === ".flac") return ["-c:a", "flac"];
|
|
return [
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"veryfast",
|
|
"-crf",
|
|
"18",
|
|
"-c:a",
|
|
"aac",
|
|
"-movflags",
|
|
"+faststart",
|
|
];
|
|
}
|
|
|
|
function probeDuration(filePath) {
|
|
const raw = execFileSync(
|
|
"ffprobe",
|
|
[
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
"--",
|
|
filePath,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
const duration = Number(raw.trim());
|
|
if (!Number.isFinite(duration) || duration <= 0)
|
|
throw new Error(`could not probe duration: ${filePath}`);
|
|
return duration;
|
|
}
|
|
|
|
function escapeConcatPath(filePath) {
|
|
return filePath.replace(/'/g, "'\\''");
|
|
}
|
|
|
|
function sumDurations(segments) {
|
|
return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
|
|
}
|
|
|
|
function fmt(n) {
|
|
return round3(n)
|
|
.toFixed(3)
|
|
.replace(/\.?0+$/, "");
|
|
}
|
|
|
|
function round3(n) {
|
|
return Math.round(Number(n) * 1000) / 1000;
|
|
}
|