hyperframes 0.8.150 → 0.8.151

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 (174) hide show
  1. package/dist/{add-3ZIFEN2U.js → add-QKZZG7A2.js} +11 -11
  2. package/dist/{animatedGifPrep-77LPFLKJ.js → animatedGifPrep-YQDPYGOD.js} +6 -6
  3. package/dist/api.js +16 -16
  4. package/dist/{asset-EYSZEHM6.js → asset-3Q737Y4H.js} +2 -2
  5. package/dist/{assetCataloger-KX3HD7ZI.js → assetCataloger-BTHEBJJB.js} +2 -2
  6. package/dist/{auth-L6IWNRXY.js → auth-XL7EU3SO.js} +1 -1
  7. package/dist/{autoUpdate-2O3HZB65.js → autoUpdate-PJFLTQ3U.js} +2 -2
  8. package/dist/{backgroundChecks-BH2LMJYE.js → backgroundChecks-T5TIR5WK.js} +3 -3
  9. package/dist/backgroundChecksWorker.js +3 -3
  10. package/dist/{batchRender-ZBKAYLWU.js → batchRender-NFLVIDB4.js} +3 -3
  11. package/dist/{beats-IVLF2566.js → beats-JU4GZ26R.js} +12 -12
  12. package/dist/{benchmark-SYZFW2G7.js → benchmark-7U2YWLTW.js} +8 -8
  13. package/dist/{browser-OSW3L25S.js → browser-D57UPYDA.js} +5 -5
  14. package/dist/{bundleWithLocalizedFonts-I4CWOXLK.js → bundleWithLocalizedFonts-26MXQOZG.js} +2 -2
  15. package/dist/{capture-AZOQ57TM.js → capture-47BV25G3.js} +2 -2
  16. package/dist/{capture-V5ICGSVJ.js → capture-LT252COQ.js} +10 -10
  17. package/dist/{catalog-VBJOA2Y5.js → catalog-BEZXOV4O.js} +11 -11
  18. package/dist/{catch-up-TKSEDNJK.js → catch-up-KVV5X6BF.js} +4 -4
  19. package/dist/{check-3HFISKDB.js → check-6KQ433E6.js} +189 -53
  20. package/dist/{checkBrowser-MXBDNZJB.js → checkBrowser-PRDR6XBY.js} +170 -28
  21. package/dist/{chunk-AFRXL75S.js → chunk-35PWWBGZ.js} +1 -1
  22. package/dist/{chunk-WLIUULZQ.js → chunk-3SVC7SW4.js} +28 -14
  23. package/dist/{chunk-EL5QVJEG.js → chunk-3ZPFRDEJ.js} +1 -1
  24. package/dist/{chunk-UQYQ7TBA.js → chunk-4LE27ST4.js} +1 -1
  25. package/dist/{chunk-DUUFDLC4.js → chunk-4MLYMDQ4.js} +1 -1
  26. package/dist/{chunk-Z6WZDGCS.js → chunk-4PKQEKAI.js} +2 -2
  27. package/dist/{chunk-VQ7YIURZ.js → chunk-4YZYYP7V.js} +5 -5
  28. package/dist/{chunk-XPQSL5VS.js → chunk-54DMWFQZ.js} +8 -1
  29. package/dist/{chunk-UVVI2NJQ.js → chunk-BRF3O35X.js} +6 -6
  30. package/dist/chunk-DHPFXAUF.js +165 -0
  31. package/dist/{chunk-6SFPQ2RZ.js → chunk-DIOBKUBV.js} +2 -2
  32. package/dist/{chunk-XQMEGSHG.js → chunk-FAIYEIZ4.js} +1 -1
  33. package/dist/{chunk-LW5VFBSY.js → chunk-FLVI7FHU.js} +1 -1
  34. package/dist/{chunk-JDB65PPT.js → chunk-GNTVYF7E.js} +8 -7
  35. package/dist/{chunk-423DJVX2.js → chunk-HVXRYMGQ.js} +1 -1
  36. package/dist/{chunk-F2RLIE7P.js → chunk-IP73NMQT.js} +11 -11
  37. package/dist/{chunk-2KAL22B5.js → chunk-IPDS2SWI.js} +1 -1
  38. package/dist/{chunk-6KWA3XPS.js → chunk-IZ7L6V76.js} +1 -1
  39. package/dist/{chunk-5NAYNDBT.js → chunk-IZPN423R.js} +6 -2
  40. package/dist/{chunk-4WFNTWKZ.js → chunk-KQGWP4KH.js} +2 -2
  41. package/dist/{chunk-NFQY7EIC.js → chunk-KXX6NLOT.js} +1 -1
  42. package/dist/{chunk-S5F26DUL.js → chunk-L3N7RJHS.js} +3 -3
  43. package/dist/{chunk-73RBX2E7.js → chunk-L6RIMCMG.js} +3 -3
  44. package/dist/{chunk-7IEGPJ4P.js → chunk-LBT4AMSF.js} +1 -1
  45. package/dist/{chunk-6NPFJ255.js → chunk-LKQS72D5.js} +1 -1
  46. package/dist/{chunk-RW7NLGPD.js → chunk-N3I6RAOR.js} +22 -14
  47. package/dist/{chunk-M6UDJ2E7.js → chunk-N4IXP3MY.js} +2 -2
  48. package/dist/{chunk-7E4VHPKJ.js → chunk-O3DUX6P2.js} +5 -5
  49. package/dist/{chunk-O4DC5CHF.js → chunk-OSAH7S5M.js} +1 -1
  50. package/dist/{chunk-UJB7356Y.js → chunk-OZ4K46OV.js} +3 -3
  51. package/dist/{chunk-6QB7F2II.js → chunk-POEEDPJI.js} +1 -1
  52. package/dist/{chunk-DOVJRUFM.js → chunk-PUIELIFA.js} +1 -1
  53. package/dist/chunk-QBYHNRHU.js +3795 -0
  54. package/dist/{chunk-XANZ4R4P.js → chunk-QF7AE3DB.js} +3 -3
  55. package/dist/{chunk-A6ZO4BX7.js → chunk-QLGBNGXC.js} +2 -2
  56. package/dist/{chunk-FPAT5HTI.js → chunk-RJRGR5KF.js} +10 -10
  57. package/dist/{chunk-AXPNVC3N.js → chunk-RRBKEHST.js} +1 -1
  58. package/dist/{chunk-UAIUVPZE.js → chunk-RYL6ZIJP.js} +6 -6
  59. package/dist/{chunk-TZPZE5PS.js → chunk-S22FKF6G.js} +6 -3
  60. package/dist/{chunk-I6DATIHC.js → chunk-TCWMDCBX.js} +2 -2
  61. package/dist/{chunk-M5B42RL5.js → chunk-WUUOFMHU.js} +3 -3
  62. package/dist/{chunk-BLRJKBKP.js → chunk-XGLNQDLN.js} +1 -1
  63. package/dist/{chunk-FN6KAS7A.js → chunk-XKKBIFE5.js} +7 -7
  64. package/dist/{chunk-ZNHAJCEA.js → chunk-XNDXENJQ.js} +27 -12
  65. package/dist/{chunk-PCWOSS7G.js → chunk-XQKVEQNY.js} +2 -2
  66. package/dist/{chunk-R5A7454P.js → chunk-XYHBVSWD.js} +2 -2
  67. package/dist/{chunk-7JTFPUMA.js → chunk-YEBKEWKB.js} +1 -1
  68. package/dist/{chunk-CZQLCBWY.js → chunk-YG2Y55KF.js} +1 -1
  69. package/dist/{chunk-B3XLIBFL.js → chunk-YIJMVFEJ.js} +1 -1
  70. package/dist/{chunk-OQ7XMM3S.js → chunk-YZTVHRJ2.js} +1 -1
  71. package/dist/{chunk-3UAIVF4L.js → chunk-ZD3RDNZI.js} +1 -1
  72. package/dist/{chunk-AKYQGZLU.js → chunk-ZGCRVM5M.js} +2 -2
  73. package/dist/{clean-7HU7S7DT.js → clean-RLTHFTQA.js} +16 -16
  74. package/dist/cli.js +52 -52
  75. package/dist/{cloud-HAXHBO25.js → cloud-Q5TGBTW3.js} +4 -4
  76. package/dist/{cloudrun-A3PIJ6DX.js → cloudrun-4UZF74MV.js} +3 -3
  77. package/dist/{compare-6IPSQMSM.js → compare-Q5UNR2V4.js} +23 -23
  78. package/dist/{compiler-UQP2VXN6.js → compiler-ZXT27Z62.js} +3 -3
  79. package/dist/{component-FF3AU24M.js → component-H734P32V.js} +3 -3
  80. package/dist/{compositions-JSH4MZXB.js → compositions-YEHDSTDR.js} +10 -10
  81. package/dist/{delete-G763SW64.js → delete-LDBMPWRT.js} +2 -2
  82. package/dist/{dist-Z7Q3JS7K.js → dist-7JXLFD6U.js} +2 -2
  83. package/dist/{dist-HUMFCJ6T.js → dist-RGE4QASU.js} +1 -1
  84. package/dist/{doctor-WZB6WMBY.js → doctor-XNJFIWJ7.js} +12 -12
  85. package/dist/{events-MUWJJCC7.js → events-G6UFLNXR.js} +2 -2
  86. package/dist/{events-T4YFQC5G.js → events-UKCPUWMH.js} +3 -3
  87. package/dist/{feedback-AUTHQNN2.js → feedback-CK3KZ7DE.js} +6 -6
  88. package/dist/{ffmpeg-TZB5WAYE.js → ffmpeg-MJFMBBSF.js} +2 -2
  89. package/dist/{figma-NBG6WBI7.js → figma-3D5C737B.js} +3 -3
  90. package/dist/fontLocalizeCli.js +8 -8
  91. package/dist/{get-OXBJLKLV.js → get-QGTQUWFT.js} +2 -2
  92. package/dist/{gpuPolicy-HBCCEQUS.js → gpuPolicy-G5QAQDYF.js} +6 -6
  93. package/dist/{grade-compare-2LX4FDH7.js → grade-compare-XIHOM62P.js} +25 -25
  94. package/dist/{help-LJI46EDO.js → help-U5UBXT52.js} +1 -1
  95. package/dist/{history-MFKAQEHU.js → history-5J73QZSA.js} +5 -5
  96. package/dist/hyperframe-runtime.js +54 -54
  97. package/dist/hyperframe.manifest.json +1 -1
  98. package/dist/hyperframe.runtime.iife.js +54 -54
  99. package/dist/hyperframes-player.global.js +3 -3
  100. package/dist/{info-D6Y5O2QE.js → info-RJHQQQUP.js} +7 -7
  101. package/dist/{init-47NZPEBI.js → init-M6UQOYYC.js} +28 -28
  102. package/dist/{inspect-U5UXJK5D.js → inspect-PRQWDBTP.js} +24 -24
  103. package/dist/{installer-AW7VFVTQ.js → installer-ZL7UKYK5.js} +4 -4
  104. package/dist/{keyframes-5G334NWB.js → keyframes-VMUJYTCQ.js} +6 -6
  105. package/dist/{lambda-GASGXN3A.js → lambda-ON42DRQD.js} +4 -4
  106. package/dist/{layout-OXAQFLJS.js → layout-QKBFVDVY.js} +24 -24
  107. package/dist/{lint-F36QFSNF.js → lint-ZQHHL2SV.js} +7 -7
  108. package/dist/{lintProject-SSEXWCH6.js → lintProject-3Z6OJCSO.js} +1 -1
  109. package/dist/{list-TR6QCI53.js → list-YBRKIWGF.js} +2 -2
  110. package/dist/{login-6LA2RWTC.js → login-4B2YXGLB.js} +4 -4
  111. package/dist/{manager-ES2UFT4X.js → manager-X44GKUEL.js} +3 -3
  112. package/dist/{manager-GWU5J6RJ.js → manager-ZLJD3EAY.js} +2 -2
  113. package/dist/{media-treatment-BOHYDWVY.js → media-treatment-5C5EJAAP.js} +11 -11
  114. package/dist/{models-6GW5Q3IW.js → models-R7ZDWBJ6.js} +12 -12
  115. package/dist/{motionShot-O7AOMPUN.js → motionShot-ZHESZNUL.js} +13 -13
  116. package/dist/{normalize-audio-UO5B4O76.js → normalize-audio-EVWZVTBM.js} +4 -4
  117. package/dist/{open-JV2EHVKM.js → open-TV6HT4UT.js} +4 -4
  118. package/dist/{parakeetRunner-23DZUF6G.js → parakeetRunner-YVVF4CG6.js} +13 -13
  119. package/dist/{pipeline-KTICSIK7.js → pipeline-ETR5SEBS.js} +8 -8
  120. package/dist/{play-CMUYPXBC.js → play-C6WONRZV.js} +17 -17
  121. package/dist/{preflight-JOD4OXGC.js → preflight-JYOX64YP.js} +4 -4
  122. package/dist/{present-ZDT5FM2H.js → present-RANXTVPA.js} +13 -13
  123. package/dist/{preview-ASHLGKDD.js → preview-KF6UMBT2.js} +18 -18
  124. package/dist/{producer-FFH2KUAZ.js → producer-CY2GXBWN.js} +1 -1
  125. package/dist/{projectConfig-FHIAOPKW.js → projectConfig-KPXCNMBM.js} +6 -6
  126. package/dist/{publication-WO5ZXURT.js → publication-Y36N4A7E.js} +2 -2
  127. package/dist/{publish-QWO2W7FB.js → publish-RCQJO35C.js} +8 -8
  128. package/dist/{remove-background-BO5MQR25.js → remove-background-3ZMXVWLB.js} +9 -9
  129. package/dist/{render-SDHFXL3Y.js → render-FKVRCDT2.js} +57 -34
  130. package/dist/{render-XTJKGT3G.js → render-PTNEFO5U.js} +8 -8
  131. package/dist/{render-HS3YOLSD.js → render-WUSTRMUD.js} +3 -3
  132. package/dist/{render-batch-J64AJHQY.js → render-batch-AQHU2JWZ.js} +3 -3
  133. package/dist/renderSetupWorker.js +4 -4
  134. package/dist/{resolver-IHNWMDJA.js → resolver-L5BDZJKU.js} +4 -4
  135. package/dist/{sherpa-FXBBYMDR.js → sherpa-3S3ISG7G.js} +12 -12
  136. package/dist/skills/hyperframes/references/frame-worker-core.md +11 -9
  137. package/dist/skills/hyperframes/references/subagent-dispatch.md +1 -1
  138. package/dist/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs +175 -0
  139. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +24 -19
  140. package/dist/skills/media-use/references/grading.md +1 -2
  141. package/dist/skills/media-use/references/meta.md +17 -17
  142. package/dist/skills/media-use/references/operations.md +6 -6
  143. package/dist/skills/media-use/references/setup-providers.md +3 -4
  144. package/dist/skills/media-use/scripts/lib/cutlist.mjs +9 -2
  145. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +12 -0
  146. package/dist/skills/media-use/scripts/lib/telemetry.mjs +4 -1
  147. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +20 -0
  148. package/dist/skills/media-use/scripts/transcript-cut.mjs +23 -4
  149. package/dist/skills/media-use/scripts/transcript-cut.test.mjs +224 -0
  150. package/dist/{skills-ALLSN7TB.js → skills-X4WQEKNF.js} +5 -5
  151. package/dist/{skillsUpdateCheck-EIT5NWM6.js → skillsUpdateCheck-UYCVHDZ6.js} +3 -3
  152. package/dist/{snapshot-Y3I76OIZ.js → snapshot-YEYHQEZQ.js} +250 -139
  153. package/dist/{src-7V6LBC56.js → src-2NN2WB6X.js} +7 -5
  154. package/dist/{src-S5IZVJUX.js → src-OLBDPEG6.js} +7 -7
  155. package/dist/{staticProjectServer-QLWWGHZE.js → staticProjectServer-7H35NMWB.js} +13 -13
  156. package/dist/studio/assets/{hyperframes-player-kup62bDR.js → hyperframes-player-DJt8E7k4.js} +1 -1
  157. package/dist/studio/assets/{index-CS_m0TkX.js → index-BGks9KLI.js} +73 -73
  158. package/dist/studio/assets/{index-B8BBYY8L.js → index-DWIHrAY2.js} +1 -1
  159. package/dist/studio/assets/{index-DQ5jGodF.js → index-DgbLblGR.js} +1 -1
  160. package/dist/studio/index.html +1 -1
  161. package/dist/{studio-api-UBFLZ3DA.js → studio-api-HGM2A5I3.js} +7 -7
  162. package/dist/{studioServer-REOVCIFP.js → studioServer-M6XFL7N6.js} +43 -43
  163. package/dist/{telemetry-C3GDIXHO.js → telemetry-KVCZKVTE.js} +3 -3
  164. package/dist/{timeline-H42EGLMT.js → timeline-VYYKZR4V.js} +66 -13
  165. package/dist/{tokens-2K7ISTKV.js → tokens-B5NFFL55.js} +2 -2
  166. package/dist/{transcribe-NUPASAUP.js → transcribe-SBETOIWI.js} +11 -11
  167. package/dist/{transcribe-DVM7UFZZ.js → transcribe-WNFRYZAC.js} +17 -17
  168. package/dist/{updateCheck-3RWFSEZD.js → updateCheck-5MM2DFUC.js} +2 -2
  169. package/dist/{upgrade-YKKL36N7.js → upgrade-LWID54R3.js} +2 -2
  170. package/dist/{urlDownloader-6JNJYLPN.js → urlDownloader-CVLNXFDG.js} +5 -5
  171. package/dist/{validate-Z7KQT7GO.js → validate-V4CGQ6OF.js} +18 -18
  172. package/dist/{video-EXL2JDSL.js → video-VJB2KRSS.js} +6 -6
  173. package/package.json +1 -1
  174. package/dist/chunk-CEND6BLF.js +0 -3795
@@ -0,0 +1,175 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const ttsUrl = new URL("./tts.mjs", import.meta.url).href;
9
+ const stub = `#!${process.execPath}
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+ const args = process.argv.slice(2);
13
+ if (args[0] === "--version") {
14
+ console.log("Python 3.12.0");
15
+ process.exit(0);
16
+ }
17
+ const input = args[2];
18
+ const output = args[0] === "-c" ? args[4] : args[args.indexOf("--output") + 1];
19
+ const text = fs.readFileSync(input, "utf8");
20
+ fs.writeFileSync(output + ".started", input);
21
+ const finish = () => {
22
+ if (text === "slow" && !fs.existsSync(process.env.RELEASE_FILE)) {
23
+ setTimeout(finish, 5);
24
+ return;
25
+ }
26
+ const consumed = fs.readFileSync(input, "utf8");
27
+ fs.writeFileSync(output + ".json", JSON.stringify({ input, text, consumed, args }));
28
+ if (process.env.CASE_MODE === "nonzero") process.exit(7);
29
+ if (process.env.CASE_MODE !== "no-output") fs.writeFileSync(output, consumed);
30
+ };
31
+ setTimeout(finish, 10);
32
+ `;
33
+
34
+ const exercise = `
35
+ import assert from "node:assert/strict";
36
+ import fs from "node:fs";
37
+ import childProcess from "node:child_process";
38
+ import { syncBuiltinESMExports } from "node:module";
39
+ import { join, dirname } from "node:path";
40
+ const { CASE_ROOT: root, CASE_MODE: mode, CASE_PROVIDER: provider } = process.env;
41
+ const temp = join(root, "tmp");
42
+ const preexisting = join(temp, "hf-txt-preexisting");
43
+ fs.mkdirSync(preexisting);
44
+ fs.writeFileSync(join(preexisting, "marker"), "keep");
45
+ const injected = Object.assign(new Error("synthetic " + mode), { code: "EACCES" });
46
+ const originalMkdtemp = fs.mkdtempSync;
47
+ const originalWrite = fs.writeFileSync;
48
+ const originalRm = fs.rmSync;
49
+ if (mode === "allocation") fs.mkdtempSync = (prefix, ...args) => {
50
+ if (prefix.startsWith(join(temp, "hf-txt-"))) throw injected;
51
+ return originalMkdtemp(prefix, ...args);
52
+ };
53
+ if (mode === "write") fs.writeFileSync = (file, ...args) => {
54
+ if (String(file).endsWith("line.txt")) {
55
+ originalWrite(file, "partial");
56
+ throw injected;
57
+ }
58
+ return originalWrite(file, ...args);
59
+ };
60
+ if (mode === "spawn-throw") childProcess.spawn = () => { throw injected; };
61
+ if (mode === "cleanup") fs.rmSync = (dir, ...args) => {
62
+ if (String(dir).startsWith(join(temp, "hf-txt-"))) throw injected;
63
+ return originalRm(dir, ...args);
64
+ };
65
+ syncBuiltinESMExports();
66
+ const { synthesizeOne } = await import(${JSON.stringify(ttsUrl)});
67
+ const project = join(root, "project");
68
+ const wavAbs = join(project, "line.wav");
69
+ const request = (chosen = provider, text = "synthetic café", output = wavAbs) =>
70
+ synthesizeOne({ provider: chosen, text, voiceId: "synthetic-voice", lang: "es",
71
+ speed: 0.8, wavAbs: output, hyperframesDir: project });
72
+ const owned = () => fs.readdirSync(temp).filter(name => name !== "hf-txt-preexisting");
73
+ if (["allocation", "write", "spawn-throw"].includes(mode)) {
74
+ await assert.rejects(request(), error => error === injected);
75
+ assert.equal(fs.existsSync(wavAbs), false);
76
+ } else if (mode === "concurrent") {
77
+ const slowWav = join(project, "slow.wav");
78
+ const slow = request("elevenlabs", "slow", slowWav);
79
+ try {
80
+ const deadline = Date.now() + 5000;
81
+ while (!fs.existsSync(slowWav + ".started")) {
82
+ assert.ok(Date.now() < deadline, "slow subprocess must start");
83
+ await new Promise(resolve => setTimeout(resolve, 5));
84
+ }
85
+ const slowInput = fs.readFileSync(slowWav + ".started", "utf8");
86
+ assert.deepEqual(await request("kokoro"), { ok: true, words: null });
87
+ assert.deepEqual(owned(), [dirname(slowInput).split(/[\\\\/]/).pop()]);
88
+ assert.equal(fs.readFileSync(slowInput, "utf8"), "slow");
89
+ } finally {
90
+ fs.writeFileSync(process.env.RELEASE_FILE, "release");
91
+ await slow;
92
+ }
93
+ assert.equal(fs.readFileSync(slowWav, "utf8"), "slow");
94
+ } else {
95
+ const result = await request();
96
+ const label = provider === "elevenlabs" ? "elevenlabs (python)" : "kokoro (npx hyperframes tts)";
97
+ if (["nonzero", "enoent", "no-output"].includes(mode)) {
98
+ const error = mode === "no-output" ? label + " produced no wav file" :
99
+ label + " exited with status " + (mode === "enoent" ? -1 : 7);
100
+ assert.deepEqual(result, { ok: false, words: null, error });
101
+ assert.equal(fs.existsSync(wavAbs), false);
102
+ } else {
103
+ assert.deepEqual(result, { ok: true, words: null });
104
+ assert.equal(fs.readFileSync(wavAbs, "utf8"), "synthetic café");
105
+ }
106
+ if (mode !== "enoent") {
107
+ const log = JSON.parse(fs.readFileSync(wavAbs + ".json", "utf8"));
108
+ assert.equal(log.text, "synthetic café");
109
+ assert.equal(log.consumed, log.text);
110
+ if (provider === "kokoro") assert.deepEqual(log.args.slice(-4), ["--lang", "es", "--speed", "0.8"]);
111
+ }
112
+ }
113
+ if (mode === "cleanup") assert.equal(owned().length, 1);
114
+ else assert.deepEqual(owned(), [], "owned text directories must be removed");
115
+ assert.equal(fs.readFileSync(join(preexisting, "marker"), "utf8"), "keep");
116
+ `;
117
+
118
+ function runCase(provider, mode) {
119
+ const root = mkdtempSync(join(tmpdir(), "tts-cleanup-test-"));
120
+ try {
121
+ for (const dir of ["home", "tmp", "bin", "project"]) mkdirSync(join(root, dir));
122
+ if (mode !== "enoent") {
123
+ for (const name of ["npx", "python3"]) {
124
+ const executable = join(root, "bin", name);
125
+ writeFileSync(executable, stub);
126
+ chmodSync(executable, 0o755);
127
+ }
128
+ }
129
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", exercise], {
130
+ encoding: "utf8",
131
+ timeout: 15000,
132
+ env: {
133
+ HOME: join(root, "home"),
134
+ USERPROFILE: join(root, "home"),
135
+ TMPDIR: join(root, "tmp"),
136
+ TMP: join(root, "tmp"),
137
+ TEMP: join(root, "tmp"),
138
+ PATH: join(root, "bin"),
139
+ CASE_ROOT: root,
140
+ CASE_PROVIDER: provider,
141
+ CASE_MODE: mode,
142
+ RELEASE_FILE: join(root, "release"),
143
+ },
144
+ });
145
+ assert.equal(result.error, undefined);
146
+ assert.equal(result.status, 0, result.stdout + result.stderr);
147
+ } finally {
148
+ rmSync(root, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ for (const provider of ["elevenlabs", "kokoro"]) {
153
+ for (const mode of [
154
+ "success",
155
+ "nonzero",
156
+ "no-output",
157
+ "enoent",
158
+ "spawn-throw",
159
+ "allocation",
160
+ "write",
161
+ "cleanup",
162
+ ]) {
163
+ test(
164
+ `synthesizeOne(${provider}) preserves ${mode} behavior and cleans only its text directory`,
165
+ { skip: process.platform === "win32" },
166
+ () => runCase(provider, mode),
167
+ );
168
+ }
169
+ }
170
+
171
+ test(
172
+ "concurrent providers retain each other's text until their subprocess finishes",
173
+ { skip: process.platform === "win32" },
174
+ () => runCase("kokoro", "concurrent"),
175
+ );
@@ -234,7 +234,7 @@ import os, sys
234
234
  from elevenlabs.client import ElevenLabs
235
235
  from elevenlabs import save
236
236
  client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
237
- text = open(sys.argv[1]).read()
237
+ text = open(sys.argv[1], encoding="utf-8").read()
238
238
  audio = client.text_to_speech.convert(
239
239
  text=text, voice_id=sys.argv[2],
240
240
  model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
@@ -274,23 +274,21 @@ export async function synthesizeOne({
274
274
  } catch {
275
275
  return { ok: false, words: null };
276
276
  }
277
- const { cmd, args } = pythonInvocation([
278
- "-c",
279
- ELEVENLABS_PY,
280
- writeTmpText(text),
281
- voiceId,
282
- wavAbs,
283
- ]);
284
- const r = await spawnP(cmd, args, {});
285
- return synthResult(r, wavAbs, "elevenlabs (python)");
277
+ return withTmpText(text, async (textPath) => {
278
+ const { cmd, args } = pythonInvocation(["-c", ELEVENLABS_PY, textPath, voiceId, wavAbs]);
279
+ const r = await spawnP(cmd, args, {});
280
+ return synthResult(r, wavAbs, "elevenlabs (python)");
281
+ });
286
282
  }
287
283
  // kokoro — via the published CLI; --output is relative to the project dir.
288
284
  const wavRel = relTo(hyperframesDir, wavAbs);
289
- const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel];
290
- if (lang !== "en") args.push("--lang", lang);
291
- if (speed !== 1) args.push("--speed", String(speed));
292
- const r = await spawnP("npx", args, { cwd: hyperframesDir });
293
- return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
285
+ return withTmpText(text, async (textPath) => {
286
+ const args = ["hyperframes", "tts", textPath, "--voice", voiceId, "--output", wavRel];
287
+ if (lang !== "en") args.push("--lang", lang);
288
+ if (speed !== 1) args.push("--speed", String(speed));
289
+ const r = await spawnP("npx", args, { cwd: hyperframesDir });
290
+ return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
291
+ });
294
292
  }
295
293
 
296
294
  // Shape a spawn result into { ok, words, error }, naming why on failure so the
@@ -378,11 +376,18 @@ export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) {
378
376
  }
379
377
 
380
378
  // ── tiny local utils ──────────────────────────────────────────────────────────
381
- function writeTmpText(text) {
379
+ async function withTmpText(text, run) {
382
380
  const td = mkdtempSync(join(tmpdir(), "hf-txt-"));
383
- const p = join(td, "line.txt");
384
- writeFileSync(p, text);
385
- return p;
381
+ try {
382
+ const p = join(td, "line.txt");
383
+ writeFileSync(p, text);
384
+ return await run(p);
385
+ } finally {
386
+ // Cleanup must preserve the synthesis result or the original write/spawn error.
387
+ try {
388
+ rmSync(td, { recursive: true, force: true });
389
+ } catch {}
390
+ }
386
391
  }
387
392
  function relTo(base, abs) {
388
393
  return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;
@@ -17,7 +17,7 @@ Stop here and use that treatment workflow for requests such as retro, old home
17
17
  video, camcorder, film, print, ASCII, glitch, privacy, or a media reveal. Do not
18
18
  assemble those from a generic LUT plus handmade CSS vignette/grain/opacity.
19
19
 
20
- **Never `cat`/read a `.cube` file into context.** A 3D LUT is ~size^3 lines of raw numbers (33^3 ≈ 36k lines at the default size). It bloats context and carries zero human/agent-legible signal. To understand or choose a LUT, use `hyperframes grade-compare` to see it rendered, or `cube-validate.mjs` for a one-line `{ok,size}` check. Read `.media/index.md` or `luts/index.json` for the description. Never read the LUT body itself.
20
+ **Never `cat`/read a `.cube` file into context.** A 3D LUT is ~size^3 lines of raw numbers (33^3 ≈ 36k lines at the default size). It bloats context and carries zero human/agent-legible signal. To understand or choose a LUT, use `hyperframes grade-compare` to see it rendered. `npx hyperframes media-use resolve --type lut --intent "<look description>" --project .` validates generated or downloaded LUTs before freezing them; the validator is bundled in the CLI, not shipped as a standalone skill script. Read `<project>/.media/index.md` or `luts/index.json` for the description. Never read the LUT body itself.
21
21
 
22
22
  ```bash
23
23
  npx hyperframes media-use resolve --type grade --intent "warm daylight" --project . --json
@@ -158,5 +158,4 @@ validates generated or downloaded cubes as it freezes them under
158
158
 
159
159
  ```bash
160
160
  npx hyperframes media-use resolve --type lut --intent "teal orange blockbuster" --project . --json
161
- node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube
162
161
  ```
@@ -4,22 +4,22 @@ Maintainer-facing reference. Nothing here changes how you resolve or operate on
4
4
 
5
5
  ## What it owns (the gaps HyperFrames leaves)
6
6
 
7
- HyperFrames owns media _playback_; media-use owns everything else. Each row is enforced by `scripts/lib/coverage.test.mjs` so the claim can't rot.
8
-
9
- | HyperFrames gap | media-use owns it via |
10
- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
12
- | No third-party brand logos | `resolve --type logo` (theSVG → GitHub org avatar → domain favicon) |
13
- | No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) |
14
- | Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
15
- | No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
16
- | No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists |
17
- | No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes |
18
- | No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) |
19
- | Grade recipes and LUT freezing | `resolve --type grade` emits a paste-ready recipe and `resolve --type lut` freezes validated `.cube` files; direct element analysis/authoring lives in `hyperframes media-treatment` |
20
- | No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) |
21
- | No video generation | `resolve --type video` — HeyGen avatar video first (free-usage path, sign-in nudge on auth failure), local LTX fallback (`videogen` in `scripts/lib/local-models.mjs`); image-to-video, photo-avatar, dub/translate remain manual `heygen` CLI recipes (`references/operations.md`) |
22
- | Weak local-model defaults | HeyGen free-usage path via the `heygen` CLI; local open-source tools only as opt-in alternatives (`scripts/lib/local-run.mjs`) |
7
+ HyperFrames owns media _playback_; media-use owns everything else. Each row is enforced by `packages/cli/src/media-use/lib/coverage.test.mjs` so the claim can't rot.
8
+
9
+ | HyperFrames gap | media-use owns it via |
10
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
12
+ | No third-party brand logos | `resolve --type logo` (theSVG → GitHub org avatar → domain favicon) |
13
+ | No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) |
14
+ | Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
15
+ | No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
16
+ | No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists |
17
+ | No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes |
18
+ | No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) |
19
+ | Grade recipes and LUT freezing | `resolve --type grade` emits a paste-ready recipe and `resolve --type lut` freezes validated `.cube` files; direct element analysis/authoring lives in `hyperframes media-treatment` |
20
+ | No image generation | `resolve --type image` — RAM-graded local mflux (FLUX), codex `image_gen` upsell |
21
+ | No video generation | `resolve --type video` — HeyGen avatar video first (free-usage path, sign-in nudge on auth failure), local LTX fallback (`videogen` ladder); image-to-video, photo-avatar, dub/translate remain manual `heygen` CLI recipes (`references/operations.md`) |
22
+ | Weak local-model defaults | HeyGen free-usage path via the `heygen` CLI; local open-source tools only as opt-in alternatives |
23
23
 
24
24
  ## Usage stats
25
25
 
@@ -47,7 +47,7 @@ dev). Same public PostHog project key and opt-outs as the `hyperframes` CLI.
47
47
 
48
48
  HeyGen request tagging: every generating `heygen` call (TTS, avatar video, catalog
49
49
  search) carries the allowlisted `X-HeyGen-Client-Source: media-use` header, sourced
50
- from one shared constant (`HEYGEN_CLIENT_SOURCE_ARGV` in `scripts/lib/heygen-cli.mjs`)
50
+ from one shared constant (`HEYGEN_CLIENT_SOURCE_ARGV` in `packages/cli/src/media-use/lib/heygen-cli.mjs`)
51
51
  so a future call site can't silently ship untagged. Read-only discovery calls
52
52
  (`voice list`, `avatar list`) are intentionally left untagged.
53
53
 
@@ -210,9 +210,9 @@ ffmpeg -i mix.wav \
210
210
  GENERATES. Two paths, best-for-the-machine picked automatically:
211
211
 
212
212
  1. **Local (default, free, private): mflux** (FLUX-on-MLX). `resolve` spec-checks
213
- AVAILABLE RAM and runs the best FLUX-class model that fits, via
214
- `scripts/lib/local-models.mjs` (`imagegen` ladder) + `mflux-provider.mjs`.
215
- The RAM ladder (agent sees it via `describeModelLadder("imagegen", specs)`):
213
+ AVAILABLE RAM and runs the best FLUX-class model that fits, using the
214
+ CLI-bundled `imagegen` ladder (its modules are not shipped in the standalone
215
+ skill). The RAM ladder:
216
216
 
217
217
  | Tier | Model | Needs (available RAM) | Notes |
218
218
  | ------ | -------------------- | --------------------- | ----------------------------------- |
@@ -245,7 +245,7 @@ clip), so treat the fallback as "HeyGen wasn't reachable," not "upgrade the
245
245
  quality":
246
246
 
247
247
  - **HeyGen avatar video (default, free for new API users):**
248
- `heygenVideoGenerate` (`scripts/lib/heygen-video-provider.mjs`) shells the
248
+ The CLI-bundled provider shells the
249
249
  `heygen` CLI — never the raw API — auto-picking a public avatar and a
250
250
  starfish voice (override with `--avatar-id`/`--voice-id`, threaded through
251
251
  as `ctx.avatarId`/`ctx.voiceId`). If the CLI reports `not_authenticated`,
@@ -253,13 +253,13 @@ quality":
253
253
  new API users — sign in) to stderr and falls through to LTX instead of
254
254
  hard-failing.
255
255
  - **Local fallback: LTX 2.3 on MLX** via `dgrauet/ltx-2-mlx`, the `videogen`
256
- ladder in `local-models.mjs` (`ltx-video-provider.mjs`). Generative clips
256
+ ladder bundled in the HyperFrames CLI. Generative clips
257
257
  (t2v), spec-gated to RAM. Verified on 24GB: 512x320 x 33f with audio.
258
258
 
259
259
  Every generating `heygen` call from media-use — TTS, avatar video, and
260
260
  catalog search — sends the allowlisted `X-HeyGen-Client-Source: media-use`
261
261
  header (persistent flag, works on every subcommand) via the shared
262
- `HEYGEN_CLIENT_SOURCE_ARGV` constant (`scripts/lib/heygen-cli.mjs`), so usage
262
+ CLI-internal `HEYGEN_CLIENT_SOURCE_ARGV` constant, so usage
263
263
  tags correctly in billing/resource meta and shows up in the API dashboards.
264
264
  Read-only discovery (`avatar list`, `voice list`) doesn't need it.
265
265
 
@@ -36,8 +36,8 @@ before resolving bgm/sfx/image/icon/voice/avatar-video; music and sound effects
36
36
  a host app's own tools need none of it. Local tools are opt-in
37
37
  alternatives where they exist: mflux for image, Kokoro for voice, Parakeet for
38
38
  transcription, and LTX for local video generation. `resolve` spec-checks
39
- AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can
40
- see the ladder and override.
39
+ AVAILABLE RAM for those local ladders inside the CLI (the image tiers are listed
40
+ in `references/operations.md`).
41
41
 
42
42
  | Type | Provider / path |
43
43
  | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -84,8 +84,7 @@ private, on-device path instead of or ahead of HeyGen for that type. Only
84
84
  | `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
85
85
 
86
86
  The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
87
- `scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)`
88
- to see which model fits this machine). Without a tool on PATH, its provider
87
+ the CLI's bundled media-use engine, not in the standalone skill. Without a tool on PATH, its provider
89
88
  prints a one-line diagnostic to stderr and resolve falls through where another
90
89
  provider exists (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp).
91
90
 
@@ -148,7 +148,7 @@ function clampRanges(ranges, duration) {
148
148
 
149
149
  function mergeRanges(ranges) {
150
150
  const sorted = ranges
151
- .map((range) => ({ start: round3(range.start), end: round3(range.end) }))
151
+ .map((range) => ({ start: round6(range.start), end: round6(range.end) }))
152
152
  .sort((a, b) => a.start - b.start || a.end - b.end);
153
153
  const merged = [];
154
154
  for (const range of sorted) {
@@ -173,12 +173,19 @@ function invertRanges(removals, duration) {
173
173
  return kept;
174
174
  }
175
175
 
176
+ // Range bounds are rounded only to shed float noise, never to the millisecond:
177
+ // these values become ffmpeg's -ss/-to, where a millisecond is wide enough to
178
+ // step a boundary onto the next frame at 29.97/30/60 fps.
176
179
  function finalizeKept(ranges) {
177
180
  return mergeRanges(ranges)
178
- .map((range) => ({ start: round3(range.start), end: round3(range.end) }))
181
+ .map((range) => ({ start: round6(range.start), end: round6(range.end) }))
179
182
  .filter((range) => round3(range.end - range.start) >= MIN_SEGMENT_SECONDS);
180
183
  }
181
184
 
185
+ function round6(n) {
186
+ return Math.round(Number(n) * 1e6) / 1e6;
187
+ }
188
+
182
189
  function round3(n) {
183
190
  return Math.round(Number(n) * 1000) / 1000;
184
191
  }
@@ -113,6 +113,18 @@ test("--keep is inverse mode and coalesces direct kept ranges", () => {
113
113
  ]);
114
114
  });
115
115
 
116
+ test("kept ranges keep frame precision instead of rounding to the millisecond", () => {
117
+ const transcript = [word("w0", "start", 0, 1), word("w1", "end", 14, 15)];
118
+
119
+ // Frame 386 at 30 fps is 12.866666 s. These bounds become ffmpeg's -ss/-to, so
120
+ // rounding them to 12.867 hands ffmpeg a time just past the frame and the
121
+ // segment starts one frame late.
122
+ assert.deepEqual(compileCutList(transcript, { keep: "0-1,12.866666666666667-14" }), [
123
+ { start: 0, end: 1 },
124
+ { start: 12.866667, end: 14 },
125
+ ]);
126
+ });
127
+
116
128
  test("--plan on a fixture transcript prints the exact segment JSON", () => {
117
129
  const dir = mkdtempSync(join(tmpdir(), "media-use-cutlist-"));
118
130
  try {
@@ -35,9 +35,12 @@ function posthogHost() {
35
35
  return override || POSTHOG_HOST;
36
36
  }
37
37
 
38
- /** True when telemetry must NOT be sent (opt-out envs, CI, dev). */
38
+ /** True when telemetry must NOT be sent (opt-out envs, CI, dev, shared config). */
39
39
  export function optedOut() {
40
40
  return (
41
+ // `hyperframes telemetry disable` writes telemetryEnabled: false to the
42
+ // shared config; one opt-out must cover the CLI and the media-use scripts.
43
+ readSharedConfig().telemetryEnabled === false ||
41
44
  process.env.HYPERFRAMES_NO_TELEMETRY === "1" ||
42
45
  process.env.DO_NOT_TRACK === "1" ||
43
46
  process.env.CI === "true" ||
@@ -57,6 +57,26 @@ test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => {
57
57
  }
58
58
  });
59
59
 
60
+ test("optedOut honors telemetryEnabled: false from the shared config", () => {
61
+ const savedEnv = { ...process.env };
62
+ const { root, home } = sandbox();
63
+ try {
64
+ withoutTelemetryOptOut();
65
+ const configPath = join(home, ".hyperframes", "config.json");
66
+ assert.equal(optedOut(), false, "no config: tracking allowed");
67
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
68
+ writeFileSync(configPath, JSON.stringify({ telemetryEnabled: false }));
69
+ assert.equal(optedOut(), true, "telemetryEnabled: false opts out");
70
+ writeFileSync(configPath, JSON.stringify({ telemetryEnabled: true }));
71
+ assert.equal(optedOut(), false, "telemetryEnabled: true allows tracking");
72
+ writeFileSync(configPath, "{not json");
73
+ assert.equal(optedOut(), false, "unreadable config: tracking allowed");
74
+ } finally {
75
+ restoreEnv(savedEnv);
76
+ rmSync(root, { recursive: true, force: true });
77
+ }
78
+ });
79
+
60
80
  test("track is a no-op (no network, resolves) when opted out", async () => {
61
81
  const savedEnv = { ...process.env };
62
82
  const originalFetch = globalThis.fetch;
@@ -98,7 +98,10 @@ function run() {
98
98
  // with priming silence (~25-35ms for AAC), which concat then bakes in as a
99
99
  // gap at each cut -- a defect distinct from, and surviving, the fades
100
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";
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";
102
105
  const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`);
103
106
  // --copy stays fade-free (stream copy cannot filter). A segment's true
104
107
  // start/end (index 0's start, the last segment's end) borders nothing
@@ -183,20 +186,27 @@ function cutSegment(inputPath, segment, outPath, copy, fade) {
183
186
  "-y",
184
187
  "-nostdin",
185
188
  "-ss",
186
- fmt(segment.start),
189
+ seekTime(segment.start),
187
190
  "-i",
188
191
  inputPath,
189
192
  "-to",
190
- fmt(segment.end - segment.start),
193
+ seekTime(segment.end - segment.start),
191
194
  ];
192
195
  if (copy) {
193
196
  argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
194
- } else if (extname(outPath).toLowerCase() === ".mkv") {
197
+ } else if (extname(outPath).toLowerCase() === ".mov") {
195
198
  // Concat splices raw segment edges together; without a short ramp the
196
199
  // waveform steps discontinuously at every boundary and you hear a click.
197
200
  if (fade) argv.push("-af", fade);
198
201
  // Video intermediate: keep the picture cheap and the audio uncompressed.
199
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");
200
210
  } else {
201
211
  if (fade) argv.push("-af", fade);
202
212
  argv.push("-c:a", "pcm_s16le");
@@ -265,6 +275,15 @@ function fmt(n) {
265
275
  .replace(/\.?0+$/, "");
266
276
  }
267
277
 
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.
283
+ function seekTime(n) {
284
+ return Number(n).toFixed(6);
285
+ }
286
+
268
287
  function round3(n) {
269
288
  return Math.round(Number(n) * 1000) / 1000;
270
289
  }