Setting the file. One moment. Transcript Cut · Media Use · heygen-com/hyperframes · Skills Docs⋯
scripts/11 files
Lines270scripts/transcript-cut.mjs
JavaScript·270 lines·9 KB
;
9import { fadeFilterFor } from "./lib/transcriptCutFade.mjs";
10import { track } from "./lib/telemetry.mjs";
11
12const { values: args } = parseArgs({
13 options: {
14 input: { type: "string" },
15 transcript: { type: "string" },
16 remove: { type: "string" },
17 "remove-words": { type: "string" },
18 "remove-fillers": { type: "string" },
19 "cut-silence": { type: "string" },
20 keep: { type: "string" },
21 copy: { type: "boolean", default: false },
22 plan: { type: "boolean", default: false },
23 out: { type: "string" },
24 json: { type: "boolean", default: false },
25 help: { type: "boolean", short: "h", default: false },
26 },
27 strict: true,
28});
29
30if (args.help) {
31 console.log(`media-use transcript-cut — compile transcript edits into video cuts
32
33Usage:
34 node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
35
36Options:
37 --input Source video/audio file
38 --transcript JSON word transcript, array or { words: [...] }
39 --remove Time ranges to remove, seconds: a-b,c-d
40 --remove-words Word-index ranges to remove: 12-18,40-41
41 --remove-fillers Comma list of filler words to remove
42 --cut-silence Remove inter-word gaps longer than this many seconds
43 --keep Inverse mode: direct kept ranges, mutually exclusive with removal
44 --copy Use stream copy for faster, keyframe-snapped cuts
45 --plan Print kept segment JSON and exit without ffmpeg
46 --out Output file
47 --json Output JSON status
48 --help, -h Show this help`);
49 process.exit(0);
50}
51
52try {
53 run();
54 await track("media_use_transcript_cut", {
55 mode: args.plan ? "plan" : "encode",
56 remove_fillers: !!args["remove-fillers"],
57 cut_silence: !!args["cut-silence"],
58 ranges: !!args.remove,
59 keep: !!args.keep,
60 });
61} catch (err) {
62 if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
63 else console.error(`error: ${err.message}`);
64 process.exit(1);
65}
66
67function run() {
68 if (!args.transcript) throw new Error("--transcript is required");
69 const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
70 const segments = compileCutList(transcript, {
71 remove: args.remove,
72 removeWords: args["remove-words"],
73 removeFillers: args["remove-fillers"],
74 cutSilence: args["cut-silence"],
75 keep: args.keep,
76 });
77
78 if (args.plan) {
79 console.log(JSON.stringify(segments));
80 return;
81 }
82
83 if (!args.input || !args.out)
84 throw new Error("--input and --out are required unless --plan is set");
85 if (segments.length === 0) throw new Error("cut list has no kept segments");
86
87 const inputPath = resolve(args.input);
88 const outPath = resolve(args.out);
89 mkdirSync(dirname(outPath), { recursive: true });
90 const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
91 const keptSeconds = sumDurations(segments);
92 const totalSeconds = probeDuration(inputPath);
93
94 try {
95 const parts = segments.map((segment, index) => {
96 // Intermediates carry PCM audio, not the final codec. Encoding each
97 // segment to a lossy codec separately makes the encoder pad every segment
98 // with priming silence (~25-35ms for AAC), which concat then bakes in as a
99 // gap at each cut -- a defect distinct from, and surviving, the fades
100 // below. PCM has no priming, so audio is encoded exactly once, at concat.
101 const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" : ".mkv";
102 const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`);
103 // --copy stays fade-free (stream copy cannot filter). A segment's true
104 // start/end (index 0's start, the last segment's end) borders nothing
105 // kept, so only an interior splice edge gets a ramp.
106 const fade = args.copy
107 ? null
108 : fadeFilterFor(segment.end - segment.start, {
109 fadeIn: index > 0,
110 fadeOut: index < segments.length - 1,
111 });
112 cutSegment(inputPath, segment, out, args.copy, fade);
113 return out;
114 });
115 const listPath = join(tmpDir, "list.txt");
116 writeFileSync(
117 listPath,
118 parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
119 );
120 // Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
121 // then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
122 const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
123 // --copy already produced final-codec segments, so concat can stream-copy.
124 // Otherwise the PCM intermediates are encoded here, once, for the whole file.
125 const concatCodecs = args.copy
126 ? ["-c", "copy"]
127 : isAudioOnly(outPath)
128 ? encodeArgsFor(extname(outPath).toLowerCase())
129 : ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
130 execFileSync(
131 "ffmpeg",
132 ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...concatCodecs, tmpOut],
133 {
134 stdio: "ignore",
135 },
136 );
137 renameSync(tmpOut, outPath);
138 } finally {
139 rmSync(tmpDir, { recursive: true, force: true });
140 }
141
142 // Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
143 // can silently swallow the whole cut. Compare, then surface the drift in BOTH
144 // the stderr warning (human) and the --json result (pipelines).
145 let copyDrift = null;
146 if (args.copy) {
147 const outSeconds = probeDuration(outPath);
148 if (Math.abs(outSeconds - keptSeconds) > 1) {
149 copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
150 if (!args.json) {
151 console.error(
152 `warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
153 );
154 }
155 }
156 }
157
158 if (args.json) {
159 console.log(
160 JSON.stringify({
161 ok: true,
162 input: inputPath,
163 out: outPath,
164 segments,
165 kept_s: round3(keptSeconds),
166 total_s: round3(totalSeconds),
167 ...(copyDrift && { copy_drift: copyDrift }),
168 }),
169 );
170 return;
171 }
172
173 console.log(
174 `cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
175 totalSeconds,
176 )}s)`,
177 );
178 console.log(`next: resolve --from ${outPath} --type <type>`);
179}
180
181function cutSegment(inputPath, segment, outPath, copy, fade) {
182 const argv = [
183 "-y",
184 "-nostdin",
185 "-ss",
186 fmt(segment.start),
187 "-i",
188 inputPath,
189 "-to",
190 fmt(segment.end - segment.start),
191 ];
192 if (copy) {
193 argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
194 } else if (extname(outPath).toLowerCase() === ".mkv") {
195 // Concat splices raw segment edges together; without a short ramp the
196 // waveform steps discontinuously at every boundary and you hear a click.
197 if (fade) argv.push("-af", fade);
198 // Video intermediate: keep the picture cheap and the audio uncompressed.
199 argv.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "pcm_s16le");
200 } else {
201 if (fade) argv.push("-af", fade);
202 argv.push("-c:a", "pcm_s16le");
203 }
204 argv.push(outPath);
205 execFileSync("ffmpeg", argv, { stdio: "ignore" });
206}
207
208// Codec set per output container. Audio-only outputs must not get the
209// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
210function isAudioOnly(filePath) {
211 return [".wav", ".mp3", ".m4a", ".aac", ".flac"].includes(extname(filePath).toLowerCase());
212}
213
214function encodeArgsFor(ext) {
215 if (ext === ".wav") return ["-c:a", "pcm_s16le"];
216 if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
217 if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
218 if (ext === ".flac") return ["-c:a", "flac"];
219 return [
220 "-c:v",
221 "libx264",
222 "-preset",
223 "veryfast",
224 "-crf",
225 "18",
226 "-c:a",
227 "aac",
228 "-movflags",
229 "+faststart",
230 ];
231}
232
233function probeDuration(filePath) {
234 const raw = execFileSync(
235 "ffprobe",
236 [
237 "-v",
238 "error",
239 "-show_entries",
240 "format=duration",
241 "-of",
242 "default=noprint_wrappers=1:nokey=1",
243 "--",
244 filePath,
245 ],
246 { encoding: "utf8" },
247 );
248 const duration = Number(raw.trim());
249 if (!Number.isFinite(duration) || duration <= 0)
250 throw new Error(`could not probe duration: ${filePath}`);
251 return duration;
252}
253
254function escapeConcatPath(filePath) {
255 return filePath.replace(/'/g, "'\\''");
256}
257
258function sumDurations(segments) {
259 return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
260}
261
262function fmt(n) {
263 return round3(n)
264 .toFixed(3)
265 .replace(/\.?0+$/, "");
266}
267
268function round3(n) {
269 return Math.round(Number(n) * 1000) / 1000;
270}