hyperframes 0.8.57 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/cli.js +5300 -3428
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +73 -12
  4. package/dist/hyperframe-runtime.js +25 -25
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +25 -25
  7. package/dist/hyperframes-player.global.js +3 -3
  8. package/dist/registry/localSemantic.js +548 -0
  9. package/dist/skills/hyperframes-cli/SKILL.md +2 -2
  10. package/dist/skills/media-use/SKILL.md +96 -0
  11. package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
  12. package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
  13. package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
  14. package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
  15. package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
  16. package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
  17. package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
  18. package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
  19. package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
  20. package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
  21. package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
  22. package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
  23. package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
  24. package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
  25. package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
  26. package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
  27. package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
  28. package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
  29. package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
  30. package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
  31. package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
  32. package/dist/skills/media-use/audio/references/bgm.md +74 -0
  33. package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
  34. package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
  35. package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
  36. package/dist/skills/media-use/audio/references/remove-background.md +143 -0
  37. package/dist/skills/media-use/audio/references/requirements.md +29 -0
  38. package/dist/skills/media-use/audio/references/sfx.md +42 -0
  39. package/dist/skills/media-use/audio/references/transcribe.md +52 -0
  40. package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
  41. package/dist/skills/media-use/audio/references/tts.md +165 -0
  42. package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
  43. package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
  44. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
  45. package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
  46. package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
  47. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
  48. package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
  49. package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
  50. package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
  51. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
  52. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
  53. package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
  54. package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
  55. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
  56. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
  57. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
  58. package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
  59. package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
  60. package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
  61. package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
  62. package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
  63. package/dist/skills/media-use/luts/README.md +28 -0
  64. package/dist/skills/media-use/luts/index.json +57 -0
  65. package/dist/skills/media-use/references/audio.md +19 -0
  66. package/dist/skills/media-use/references/grading.md +160 -0
  67. package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
  68. package/dist/skills/media-use/references/media-treatments.md +202 -0
  69. package/dist/skills/media-use/references/memory.md +36 -0
  70. package/dist/skills/media-use/references/meta.md +61 -0
  71. package/dist/skills/media-use/references/operations.md +325 -0
  72. package/dist/skills/media-use/references/resolve.md +150 -0
  73. package/dist/skills/media-use/references/setup-providers.md +80 -0
  74. package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
  75. package/dist/skills/media-use/registry/localSemantic.js +548 -0
  76. package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
  77. package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
  78. package/dist/skills/media-use/scripts/dither.mjs +328 -0
  79. package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
  80. package/dist/skills/media-use/scripts/eval.mjs +369 -0
  81. package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
  82. package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  83. package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  84. package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  85. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  86. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  87. package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
  88. package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
  89. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  90. package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  91. package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  92. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
  93. package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  94. package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  95. package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
  96. package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  97. package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
  98. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
  99. package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
  100. package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
  101. package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
  102. package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  103. package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
  104. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
  105. package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
  106. package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  107. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
  108. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
  109. package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  110. package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  111. package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  112. package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  113. package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  114. package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
  115. package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  116. package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
  117. package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  118. package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
  119. package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  120. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
  121. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
  122. package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  123. package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  124. package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  125. package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  126. package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
  127. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
  128. package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
  129. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
  130. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
  131. package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  132. package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
  133. package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  134. package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  135. package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
  136. package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
  137. package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
  138. package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  139. package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
  140. package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  141. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
  142. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
  143. package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
  144. package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
  145. package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
  146. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
  147. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
  148. package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
  149. package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  150. package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
  151. package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
  152. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
  153. package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
  154. package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  155. package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
  156. package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  157. package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
  158. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
  159. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
  160. package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  161. package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
  162. package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  163. package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  164. package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  165. package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
  166. package/dist/skills/media-use/scripts/prefs.mjs +70 -0
  167. package/dist/skills/media-use/scripts/recipe.mjs +103 -0
  168. package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
  169. package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
  170. package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
  171. package/dist/skills/registry/localSemantic.js +548 -0
  172. package/dist/studio/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +577 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +22537 -21600
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +3 -3
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ copyFileSync,
9
+ renameSync,
10
+ mkdtempSync,
11
+ rmSync,
12
+ } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { basename, extname, join, resolve } from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import { mergeTokensToWords } from "./lib/parakeet-words.mjs";
17
+ import { track } from "./lib/telemetry.mjs";
18
+ import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
19
+
20
+ // The DEFAULT local transcription path. Prefers NVIDIA Parakeet-TDT via
21
+ // parakeet-mlx, which beats whisper.cpp on the Open ASR Leaderboard (~6.05% vs
22
+ // 7.44% avg WER, and 4.73% vs 5.96% on noisy test-other) and is 5-10x faster
23
+ // with native punctuation. Emits { text, words:[{text,start,end}] } (word
24
+ // timestamps merged from Parakeet's sub-word tokens) for transcript-cut /
25
+ // captions / the audio engine.
26
+ //
27
+ // Parakeet v3 covers English + 25 European languages. For other languages, or
28
+ // when parakeet-mlx is not installed, it falls back to whisper.cpp via
29
+ // `hyperframes transcribe` (99 languages; the CLI resolves/builds whisper.cpp
30
+ // on first use — it is not bundled). `--engine` forces one.
31
+
32
+ const { values: args } = parseArgs({
33
+ options: {
34
+ input: { type: "string", short: "i" },
35
+ out: { type: "string", short: "o" },
36
+ engine: { type: "string", default: "auto" }, // auto | parakeet | whisper
37
+ model: { type: "string", default: "mlx-community/parakeet-tdt-0.6b-v3" },
38
+ json: { type: "boolean", default: false },
39
+ help: { type: "boolean", short: "h", default: false },
40
+ },
41
+ strict: true,
42
+ });
43
+
44
+ if (args.help) {
45
+ console.log(`media-use transcribe: better-than-whisper local ASR (Parakeet), whisper.cpp fallback
46
+
47
+ Usage:
48
+ node transcribe.mjs --input audio.wav [--out audio.transcribe.json] [--engine auto|parakeet|whisper]
49
+
50
+ Parakeet (default) beats whisper.cpp on accuracy + speed for English/European
51
+ languages; whisper.cpp (99 languages) is the fallback. Install Parakeet once:
52
+ uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx`);
53
+ process.exit(0);
54
+ }
55
+
56
+ if (!args.input) {
57
+ console.error("error: --input is required");
58
+ process.exit(2);
59
+ }
60
+ const inputPath = resolve(args.input);
61
+ if (!existsSync(inputPath)) {
62
+ console.error(`error: input not found: ${inputPath}`);
63
+ process.exit(2);
64
+ }
65
+ const outPath = resolve(
66
+ args.out || `${inputPath.slice(0, -extname(inputPath).length)}.transcribe.json`,
67
+ );
68
+
69
+ // Locate the parakeet-mlx runner the same way the CLI does: env override, then
70
+ // the documented ~/.venvs/parakeet install, then PATH. Checking the venv (not
71
+ // just PATH) is what keeps a user who followed the install docs verbatim from
72
+ // silently falling through to whisper. Returns the runner path, or null.
73
+ function resolveParakeet() {
74
+ for (const p of [
75
+ process.env.HYPERFRAMES_PARAKEET,
76
+ join(homedir(), ".venvs", "parakeet", "bin", "parakeet-mlx"),
77
+ ]) {
78
+ if (p && existsSync(p)) return p;
79
+ }
80
+ try {
81
+ execFileSync("parakeet-mlx", ["--help"], {
82
+ stdio: ["ignore", "ignore", "ignore"],
83
+ timeout: 20000,
84
+ });
85
+ return "parakeet-mlx";
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // Write via a sibling temp + atomic rename so a SIGKILL mid-write can't leave a
92
+ // truncated transcript at outPath (downstream reads it as valid JSON).
93
+ function atomicWrite(target, data) {
94
+ const tmp = `${target}.tmp-${process.pid}`;
95
+ writeFileSync(tmp, data);
96
+ renameSync(tmp, target);
97
+ }
98
+
99
+ function report(engine, wordCount) {
100
+ if (args.json) console.log(JSON.stringify({ ok: true, out: outPath, engine, words: wordCount }));
101
+ else
102
+ console.log(
103
+ `transcribed ${basename(inputPath)} -> ${outPath}${wordCount != null ? ` (${wordCount} words,` : " ("}${engine})`,
104
+ );
105
+ }
106
+
107
+ function runParakeet(runner) {
108
+ const workDir = mkdtempSync(join(tmpdir(), "media-use-asr-"));
109
+ try {
110
+ execFileSync(
111
+ runner,
112
+ [inputPath, "--model", args.model, "--output-format", "json", "--output-dir", workDir],
113
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
114
+ );
115
+ const jsonPath = join(workDir, `${basename(inputPath, extname(inputPath))}.json`);
116
+ if (!existsSync(jsonPath)) throw new Error("parakeet produced no JSON");
117
+ const merged = mergeTokensToWords(JSON.parse(readFileSync(jsonPath, "utf8")));
118
+ atomicWrite(outPath, JSON.stringify(merged, null, 2));
119
+ report("parakeet", merged.words.length);
120
+ } finally {
121
+ rmSync(workDir, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ // whisper.cpp via the hyperframes CLI (fetched/built on first use — see
126
+ // SKILL.md): writes transcript.json into --dir; relocate to --out.
127
+ function runWhisper() {
128
+ const workDir = mkdtempSync(join(tmpdir(), "media-use-whisper-"));
129
+ try {
130
+ // On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
131
+ // (spawnSync npx ENOENT) — resolveNpxInvocation reroutes it through
132
+ // node + npx-cli.js (and throws actionably when it can't), same
133
+ // mechanism as the audio engine's TTS spawns.
134
+ const resolved = resolveNpxInvocation(
135
+ ["hyperframes", "transcribe", inputPath, "--dir", workDir],
136
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
137
+ );
138
+ execFileSync(resolved.cmd, resolved.args, resolved.opts);
139
+ const produced = join(workDir, "transcript.json");
140
+ if (!existsSync(produced)) throw new Error("whisper produced no transcript.json");
141
+ const tmp = `${outPath}.tmp-${process.pid}`;
142
+ copyFileSync(produced, tmp);
143
+ renameSync(tmp, outPath); // atomic publish
144
+ let words;
145
+ try {
146
+ const t = JSON.parse(readFileSync(outPath, "utf8"));
147
+ words = Array.isArray(t?.words) ? t.words.length : undefined;
148
+ } catch {
149
+ /* leave undefined */
150
+ }
151
+ report("whisper", words);
152
+ } finally {
153
+ rmSync(workDir, { recursive: true, force: true });
154
+ }
155
+ }
156
+
157
+ try {
158
+ const parakeetBin = resolveParakeet();
159
+ const engine =
160
+ args.engine === "parakeet" || args.engine === "whisper"
161
+ ? args.engine
162
+ : parakeetBin
163
+ ? "parakeet"
164
+ : "whisper";
165
+ if (engine === "parakeet") {
166
+ if (!parakeetBin) {
167
+ throw new Error(
168
+ "parakeet-mlx not found (checked $HYPERFRAMES_PARAKEET, ~/.venvs/parakeet, and PATH). Install: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (or use --engine whisper)",
169
+ );
170
+ }
171
+ runParakeet(parakeetBin);
172
+ } else {
173
+ runWhisper();
174
+ }
175
+ await track("media_use_transcribe", { engine });
176
+ } catch (err) {
177
+ if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
178
+ else console.error(`error: transcription failed: ${err.message}`);
179
+ process.exit(1);
180
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, extname, join, resolve } from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { compileCutList } from "./lib/cutlist.mjs";
9
+ import { track } from "./lib/telemetry.mjs";
10
+
11
+ const { values: args } = parseArgs({
12
+ options: {
13
+ input: { type: "string" },
14
+ transcript: { type: "string" },
15
+ remove: { type: "string" },
16
+ "remove-words": { type: "string" },
17
+ "remove-fillers": { type: "string" },
18
+ "cut-silence": { type: "string" },
19
+ keep: { type: "string" },
20
+ copy: { type: "boolean", default: false },
21
+ plan: { type: "boolean", default: false },
22
+ out: { type: "string" },
23
+ json: { type: "boolean", default: false },
24
+ help: { type: "boolean", short: "h", default: false },
25
+ },
26
+ strict: true,
27
+ });
28
+
29
+ if (args.help) {
30
+ console.log(`media-use transcript-cut — compile transcript edits into video cuts
31
+
32
+ Usage:
33
+ node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
34
+
35
+ Options:
36
+ --input Source video/audio file
37
+ --transcript JSON word transcript, array or { words: [...] }
38
+ --remove Time ranges to remove, seconds: a-b,c-d
39
+ --remove-words Word-index ranges to remove: 12-18,40-41
40
+ --remove-fillers Comma list of filler words to remove
41
+ --cut-silence Remove inter-word gaps longer than this many seconds
42
+ --keep Inverse mode: direct kept ranges, mutually exclusive with removal
43
+ --copy Use stream copy for faster, keyframe-snapped cuts
44
+ --plan Print kept segment JSON and exit without ffmpeg
45
+ --out Output file
46
+ --json Output JSON status
47
+ --help, -h Show this help`);
48
+ process.exit(0);
49
+ }
50
+
51
+ try {
52
+ run();
53
+ await track("media_use_transcript_cut", {
54
+ mode: args.plan ? "plan" : "encode",
55
+ remove_fillers: !!args["remove-fillers"],
56
+ cut_silence: !!args["cut-silence"],
57
+ ranges: !!args.remove,
58
+ keep: !!args.keep,
59
+ });
60
+ } catch (err) {
61
+ if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
62
+ else console.error(`error: ${err.message}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ function run() {
67
+ if (!args.transcript) throw new Error("--transcript is required");
68
+ const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
69
+ const segments = compileCutList(transcript, {
70
+ remove: args.remove,
71
+ removeWords: args["remove-words"],
72
+ removeFillers: args["remove-fillers"],
73
+ cutSilence: args["cut-silence"],
74
+ keep: args.keep,
75
+ });
76
+
77
+ if (args.plan) {
78
+ console.log(JSON.stringify(segments));
79
+ return;
80
+ }
81
+
82
+ if (!args.input || !args.out)
83
+ throw new Error("--input and --out are required unless --plan is set");
84
+ if (segments.length === 0) throw new Error("cut list has no kept segments");
85
+
86
+ const inputPath = resolve(args.input);
87
+ const outPath = resolve(args.out);
88
+ mkdirSync(dirname(outPath), { recursive: true });
89
+ const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
90
+ const keptSeconds = sumDurations(segments);
91
+ const totalSeconds = probeDuration(inputPath);
92
+
93
+ try {
94
+ const parts = segments.map((segment, index) => {
95
+ const out = join(
96
+ tmpDir,
97
+ `segment-${String(index).padStart(4, "0")}${extname(outPath) || ".mp4"}`,
98
+ );
99
+ cutSegment(inputPath, segment, out, Boolean(args.copy));
100
+ return out;
101
+ });
102
+ const listPath = join(tmpDir, "list.txt");
103
+ writeFileSync(
104
+ listPath,
105
+ parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
106
+ );
107
+ // Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
108
+ // then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
109
+ const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
110
+ execFileSync(
111
+ "ffmpeg",
112
+ ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", tmpOut],
113
+ {
114
+ stdio: "ignore",
115
+ },
116
+ );
117
+ renameSync(tmpOut, outPath);
118
+ } finally {
119
+ rmSync(tmpDir, { recursive: true, force: true });
120
+ }
121
+
122
+ // Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
123
+ // can silently swallow the whole cut. Compare, then surface the drift in BOTH
124
+ // the stderr warning (human) and the --json result (pipelines).
125
+ let copyDrift = null;
126
+ if (args.copy) {
127
+ const outSeconds = probeDuration(outPath);
128
+ if (Math.abs(outSeconds - keptSeconds) > 1) {
129
+ copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
130
+ if (!args.json) {
131
+ console.error(
132
+ `warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
133
+ );
134
+ }
135
+ }
136
+ }
137
+
138
+ if (args.json) {
139
+ console.log(
140
+ JSON.stringify({
141
+ ok: true,
142
+ input: inputPath,
143
+ out: outPath,
144
+ segments,
145
+ kept_s: round3(keptSeconds),
146
+ total_s: round3(totalSeconds),
147
+ ...(copyDrift && { copy_drift: copyDrift }),
148
+ }),
149
+ );
150
+ return;
151
+ }
152
+
153
+ console.log(
154
+ `cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
155
+ totalSeconds,
156
+ )}s)`,
157
+ );
158
+ console.log(`next: resolve --from ${outPath} --type <type>`);
159
+ }
160
+
161
+ function cutSegment(inputPath, segment, outPath, copy) {
162
+ const argv = [
163
+ "-y",
164
+ "-nostdin",
165
+ "-ss",
166
+ fmt(segment.start),
167
+ "-i",
168
+ inputPath,
169
+ "-to",
170
+ fmt(segment.end - segment.start),
171
+ ];
172
+ if (copy) {
173
+ argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
174
+ } else {
175
+ argv.push(...encodeArgsFor(extname(outPath).toLowerCase()));
176
+ }
177
+ argv.push(outPath);
178
+ execFileSync("ffmpeg", argv, { stdio: "ignore" });
179
+ }
180
+
181
+ // Codec set per output container. Audio-only outputs must not get the
182
+ // video-centric aac/x264 set (aac inside .wav breaks timing entirely).
183
+ function encodeArgsFor(ext) {
184
+ if (ext === ".wav") return ["-c:a", "pcm_s16le"];
185
+ if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
186
+ if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
187
+ if (ext === ".flac") return ["-c:a", "flac"];
188
+ return [
189
+ "-c:v",
190
+ "libx264",
191
+ "-preset",
192
+ "veryfast",
193
+ "-crf",
194
+ "18",
195
+ "-c:a",
196
+ "aac",
197
+ "-movflags",
198
+ "+faststart",
199
+ ];
200
+ }
201
+
202
+ function probeDuration(filePath) {
203
+ const raw = execFileSync(
204
+ "ffprobe",
205
+ [
206
+ "-v",
207
+ "error",
208
+ "-show_entries",
209
+ "format=duration",
210
+ "-of",
211
+ "default=noprint_wrappers=1:nokey=1",
212
+ "--",
213
+ filePath,
214
+ ],
215
+ { encoding: "utf8" },
216
+ );
217
+ const duration = Number(raw.trim());
218
+ if (!Number.isFinite(duration) || duration <= 0)
219
+ throw new Error(`could not probe duration: ${filePath}`);
220
+ return duration;
221
+ }
222
+
223
+ function escapeConcatPath(filePath) {
224
+ return filePath.replace(/'/g, "'\\''");
225
+ }
226
+
227
+ function sumDurations(segments) {
228
+ return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
229
+ }
230
+
231
+ function fmt(n) {
232
+ return round3(n)
233
+ .toFixed(3)
234
+ .replace(/\.?0+$/, "");
235
+ }
236
+
237
+ function round3(n) {
238
+ return Math.round(Number(n) * 1000) / 1000;
239
+ }