Skills › HyperFrames › media-use › transcript-cut.mjs
transcript-cut.mjs
289 lines · skills/media-use/scripts/transcript-cut.mjs at a595755
1#!/usr/bin/env node
3import { execFileSync } from "node:child_process";
4import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5import { tmpdir } from "node:os";
6import { dirname, extname, join, resolve } from "node:path";
7import { parseArgs } from "node:util";
8import { compileCutList } from "./lib/cutlist.mjs";
9import { fadeFilterFor } from "./lib/transcriptCutFade.mjs";
10import { track } from "./lib/telemetry.mjs";
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});
30if (args.help) {
31 console.log(`media-use transcript-cut — compile transcript edits into video cuts
33Usage:
34 node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
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}
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}
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 });
78 if (args.plan) {
79 console.log(JSON.stringify(segments));
80 return;
81 }
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");
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);
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 // The video container is .mov, not .mkv: ffmpeg writes Matroska
102 // timestamps in whole milliseconds, which moves frames off the source's
103 // frame grid, and the concat copy below carries that into the output.
104 const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" : ".mov";
105 const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`);
106 // --copy stays fade-free (stream copy cannot filter). A segment's true
107 // start/end (index 0's start, the last segment's end) borders nothing
108 // kept, so only an interior splice edge gets a ramp.
109 const fade = args.copy
110 ? null
111 : fadeFilterFor(segment.end - segment.start, {
112 fadeIn: index > 0,
113 fadeOut: index < segments.length - 1,
114 });
115 cutSegment(inputPath, segment, out, args.copy, fade);
116 return out;
117 });
118 const listPath = join(tmpDir, "list.txt");
119 writeFileSync(
120 listPath,
121 parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
122 );
123 // Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
124 // then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
125 const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
126 // --copy already produced final-codec segments, so concat can stream-copy.
127 // Otherwise the PCM intermediates are encoded here, once, for the whole file.
128 const concatCodecs = args.copy
129 ? ["-c", "copy"]
130 : isAudioOnly(outPath)
131 ? encodeArgsFor(extname(outPath).toLowerCase())
132 : ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
133 execFileSync(
134 "ffmpeg",
135 ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...concatCodecs, tmpOut],
136 {
137 stdio: "ignore",
138 },
139 );
140 renameSync(tmpOut, outPath);
141 } finally {
142 rmSync(tmpDir, { recursive: true, force: true });
143 }
145 // Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
146 // can silently swallow the whole cut. Compare, then surface the drift in BOTH
147 // the stderr warning (human) and the --json result (pipelines).
148 let copyDrift = null;
149 if (args.copy) {
150 const outSeconds = probeDuration(outPath);
151 if (Math.abs(outSeconds - keptSeconds) > 1) {
152 copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
153 if (!args.json) {
154 console.error(
155 `warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
156 );
157 }
158 }
159 }
161 if (args.json) {
162 console.log(
163 JSON.stringify({
164 ok: true,
165 input: inputPath,
166 out: outPath,
167 segments,
168 kept_s: round3(keptSeconds),
169 total_s: round3(totalSeconds),
170 ...(copyDrift && { copy_drift: copyDrift }),
171 }),
172 );
173 return;
174 }
176 console.log(
177 `cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
178 totalSeconds,
179 )}s)`,
180 );
181 console.log(`next: resolve --from ${outPath} --type <type>`);
182}
184function cutSegment(inputPath, segment, outPath, copy, fade) {
185 const argv = [
186 "-y",
187 "-nostdin",
188 "-ss",
189 seekTime(segment.start),
190 "-i",
191 inputPath,
192 "-to",
193 seekTime(segment.end - segment.start),
194 ];
195 if (copy) {
196 argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
197 } else if (extname(outPath).toLowerCase() === ".mov") {
198 // Concat splices raw segment edges together; without a short ramp the
199 // waveform steps discontinuously at every boundary and you hear a click.
200 if (fade) argv.push("-af", fade);
201 // Video intermediate: keep the picture cheap and the audio uncompressed.
202 argv.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "pcm_s16le");
203 // A keyframe every second. The renderer seeks into this footage, and x264
204 // on its own leaves up to 250 frames between keyframes.
205 argv.push("-force_key_frames", "expr:gte(t,n_forced)");
206 // Constant frame rate from the first frame. Left to itself, ffmpeg 7 starts
207 // a range that begins between two source frames one frame late, and .mov
208 // rounds that lead-in to the millisecond.
209 argv.push("-fps_mode", "cfr");
210 } else {
211 if (fade) argv.push("-af", fade);
212 argv.push("-c:a", "pcm_s16le");
213 }
214 argv.push(outPath);
215 execFileSync("ffmpeg", argv, { stdio: "ignore" });
216}
218// Codec set per output container. Audio-only outputs must not get the
219// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
220function isAudioOnly(filePath) {
221 return [".wav", ".mp3", ".m4a", ".aac", ".flac"].includes(extname(filePath).toLowerCase());
222}
224function encodeArgsFor(ext) {
225 if (ext === ".wav") return ["-c:a", "pcm_s16le"];
226 if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
227 if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
228 if (ext === ".flac") return ["-c:a", "flac"];
229 return [
230 "-c:v",
231 "libx264",
232 "-preset",
233 "veryfast",
234 "-crf",
235 "18",
236 "-c:a",
237 "aac",
238 "-movflags",
239 "+faststart",
240 ];
241}
243function probeDuration(filePath) {
244 const raw = execFileSync(
245 "ffprobe",
246 [
247 "-v",
248 "error",
249 "-show_entries",
250 "format=duration",
251 "-of",
252 "default=noprint_wrappers=1:nokey=1",
253 "--",
254 filePath,
255 ],
256 { encoding: "utf8" },
257 );
258 const duration = Number(raw.trim());
259 if (!Number.isFinite(duration) || duration <= 0)
260 throw new Error(`could not probe duration: ${filePath}`);
261 return duration;
262}
264function escapeConcatPath(filePath) {
265 return filePath.replace(/'/g, "'\\''");
266}
268function sumDurations(segments) {
269 return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
270}
272function fmt(n) {
273 return round3(n)
274 .toFixed(3)
275 .replace(/\.?0+$/, "");
276}
278// -ss/-to name frames, and ffmpeg's accurate seek drops everything before the
279// time it is given: at 30 fps, frame 386 sits at 12.866666 s, and a 3-decimal
280// -ss of 12.867 starts that segment on frame 387 instead, leaving the cut a
281// frame short from the join on. So the values ffmpeg gets keep frame precision,
282// while fmt() above stays at 3 decimals for the times that are only printed.
283function seekTime(n) {
284 return Number(n).toFixed(6);
285}
287function round3(n) {
288 return Math.round(Number(n) * 1000) / 1000;
289}