dsh-hyperframes 0.10.1 → 0.11.0

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 (113) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.en.md +4 -3
  3. package/README.md +4 -3
  4. package/docs/WORKBENCH.md +6 -0
  5. package/lib/client.js +40 -2
  6. package/lib/media-templates.d.ts +1 -1
  7. package/lib/media-templates.js +1 -1
  8. package/package.json +2 -2
  9. package/plugin.json +1 -1
  10. package/skills/NOTICE.md +1 -1
  11. package/skills/UPSTREAM.json +93 -8
  12. package/skills/embedded-captions/SKILL.md +1 -1
  13. package/skills/faceless-explainer/SKILL.md +1 -1
  14. package/skills/general-video/SKILL.md +1 -1
  15. package/skills/hyperframes-animation/SKILL.md +1 -1
  16. package/skills/hyperframes-audio/SKILL.md +1 -1
  17. package/skills/hyperframes-core/SKILL.md +1 -1
  18. package/skills/hyperframes-creative/SKILL.md +1 -1
  19. package/skills/hyperframes-keyframes/SKILL.md +1 -1
  20. package/skills/hyperframes-studio/SKILL.md +1 -1
  21. package/skills/media-use/scripts/lib/adopt.mjs +128 -0
  22. package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  23. package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  24. package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  25. package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  26. package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  27. package/skills/media-use/scripts/lib/cache.mjs +162 -0
  28. package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
  29. package/skills/media-use/scripts/lib/candidates.mjs +91 -0
  30. package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  31. package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  32. package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  33. package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
  34. package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
  35. package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
  36. package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  37. package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  38. package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
  39. package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  40. package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
  41. package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
  42. package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  43. package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  44. package/skills/media-use/scripts/lib/freeze.mjs +72 -0
  45. package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
  46. package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
  47. package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  48. package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
  49. package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
  50. package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  51. package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  52. package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  53. package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  54. package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  55. package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  56. package/skills/media-use/scripts/lib/local-models.mjs +318 -0
  57. package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  58. package/skills/media-use/scripts/lib/local-run.mjs +78 -0
  59. package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  60. package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
  61. package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
  62. package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  63. package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  64. package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  65. package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  66. package/skills/media-use/scripts/lib/main-module.mjs +13 -0
  67. package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
  68. package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
  69. package/skills/media-use/scripts/lib/match.mjs +47 -0
  70. package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
  71. package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  72. package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  73. package/skills/media-use/scripts/lib/media-search.mjs +107 -0
  74. package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  75. package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  76. package/skills/media-use/scripts/lib/misses.mjs +49 -0
  77. package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
  78. package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
  79. package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  80. package/skills/media-use/scripts/lib/openverse.mjs +134 -0
  81. package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  82. package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  83. package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
  84. package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
  85. package/skills/media-use/scripts/lib/probe.mjs +46 -0
  86. package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
  87. package/skills/media-use/scripts/lib/providers.mjs +5 -0
  88. package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
  89. package/skills/media-use/scripts/lib/registry.mjs +214 -0
  90. package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  91. package/skills/media-use/scripts/lib/search.mjs +24 -0
  92. package/skills/media-use/scripts/lib/search.test.mjs +32 -0
  93. package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
  94. package/skills/media-use/scripts/lib/specs.mjs +132 -0
  95. package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  96. package/skills/media-use/scripts/lib/stats.mjs +117 -0
  97. package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  98. package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  99. package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
  100. package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
  101. package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
  102. package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
  103. package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  104. package/skills/media-use/scripts/lib/usage.mjs +57 -0
  105. package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  106. package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  107. package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  108. package/skills/media-use/scripts/resolve.mjs +1360 -27
  109. package/skills/motion-graphics/SKILL.md +1 -1
  110. package/skills/music-to-video/SKILL.md +1 -1
  111. package/skills/pr-to-video/SKILL.md +1 -1
  112. package/skills/product-launch-video/SKILL.md +1 -1
  113. package/skills/talking-head-recut/SKILL.md +1 -1
@@ -1,73 +1,457 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
1
+ import { strict as assert } from "node:assert";
2
+ import { spawn } from "node:child_process";
5
3
  import { test } from "node:test";
6
- import { optedOut } from "./telemetry.mjs";
7
-
8
- // optedOut() reads ~/.hyperframes/config.json and env vars. Point HOME at a temp
9
- // dir so the run never touches the real config, and pin the env opt-outs to a
10
- // neutral state; restore both afterwards.
11
- const ENV_KEYS = [
12
- "HOME",
13
- "USERPROFILE",
14
- "HYPERFRAMES_NO_TELEMETRY",
15
- "DO_NOT_TRACK",
16
- "CI",
17
- "NODE_ENV",
18
- ];
19
- const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
20
- const home = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
21
- const configPath = join(home, ".hyperframes", "config.json");
22
-
23
- function writeConfig(contents) {
24
- if (contents === null) {
25
- rmSync(configPath, { force: true });
26
- } else {
27
- mkdirSync(dirname(configPath), { recursive: true });
28
- writeFileSync(configPath, contents);
29
- }
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ chmodSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { __anonymousIdForTest, __resetTelemetryForTest, optedOut, track } from "./telemetry.mjs";
16
+
17
+ function sandbox() {
18
+ const root = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
19
+ const home = join(root, "home");
20
+ mkdirSync(home, { recursive: true });
21
+ process.env.HOME = home;
22
+ process.env.HYPERFRAMES_MEDIA_HOME = home;
23
+ return { root, home };
24
+ }
25
+
26
+ function restoreEnv(saved) {
27
+ for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
28
+ Object.assign(process.env, saved);
30
29
  }
31
30
 
32
- test.before(() => {
33
- for (const key of ENV_KEYS) delete process.env[key];
34
- process.env.HOME = process.env.USERPROFILE = home;
31
+ function withoutTelemetryOptOut() {
32
+ for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
33
+ delete process.env[k];
34
+ }
35
+
36
+ function parseFetchBodies(calls) {
37
+ return calls.flatMap((call) => JSON.parse(call.options.body).batch);
38
+ }
39
+
40
+ test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => {
41
+ const saved = { ...process.env };
42
+ try {
43
+ for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
44
+ delete process.env[k];
45
+ assert.equal(optedOut(), false, "default: tracking allowed");
46
+ process.env.DO_NOT_TRACK = "1";
47
+ assert.equal(optedOut(), true, "DO_NOT_TRACK opts out");
48
+ delete process.env.DO_NOT_TRACK;
49
+ process.env.HYPERFRAMES_NO_TELEMETRY = "1";
50
+ assert.equal(optedOut(), true, "HYPERFRAMES_NO_TELEMETRY opts out");
51
+ delete process.env.HYPERFRAMES_NO_TELEMETRY;
52
+ process.env.CI = "true";
53
+ assert.equal(optedOut(), true, "CI opts out");
54
+ } finally {
55
+ for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
56
+ Object.assign(process.env, saved);
57
+ }
35
58
  });
36
59
 
37
- test.after(() => {
38
- for (const key of ENV_KEYS) {
39
- if (savedEnv[key] === undefined) delete process.env[key];
40
- else process.env[key] = savedEnv[key];
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 });
41
77
  }
42
- rmSync(home, { recursive: true, force: true });
43
78
  });
44
79
 
45
- test("optedOut is false with no config and no opt-out env", () => {
46
- writeConfig(null);
47
- assert.equal(optedOut(), false);
80
+ test("track is a no-op (no network, resolves) when opted out", async () => {
81
+ const savedEnv = { ...process.env };
82
+ const originalFetch = globalThis.fetch;
83
+ const { root, home } = sandbox();
84
+ const calls = [];
85
+ globalThis.fetch = async (...args) => {
86
+ calls.push(args);
87
+ return { ok: true };
88
+ };
89
+ process.env.DO_NOT_TRACK = "1";
90
+ try {
91
+ // must resolve immediately without throwing or hitting the network
92
+ await track("media_use_resolve", { type: "bgm", source: "search" });
93
+ assert.equal(calls.length, 0);
94
+ assert.equal(existsSync(join(home, ".hyperframes/config.json")), false);
95
+ assert.equal(existsSync(join(home, ".media/telemetry-notice-shown")), false);
96
+ } finally {
97
+ globalThis.fetch = originalFetch;
98
+ restoreEnv(savedEnv);
99
+ rmSync(root, { recursive: true, force: true });
100
+ __resetTelemetryForTest();
101
+ }
48
102
  });
49
103
 
50
- test("optedOut honors telemetryEnabled: false from the shared config", () => {
51
- writeConfig(JSON.stringify({ telemetryEnabled: false }));
52
- assert.equal(optedOut(), true);
104
+ test("anonymous id uses the shared hyperframes config", () => {
105
+ const savedEnv = { ...process.env };
106
+ const { root, home } = sandbox();
107
+ try {
108
+ withoutTelemetryOptOut();
109
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
110
+ writeFileSync(
111
+ join(home, ".hyperframes/config.json"),
112
+ JSON.stringify({ anonymousId: "shared-install-id", keep: true }),
113
+ );
114
+ mkdirSync(join(home, ".media"), { recursive: true });
115
+ writeFileSync(join(home, ".media/anon-id"), "old-media-id");
116
+
117
+ assert.equal(__anonymousIdForTest(), "shared-install-id");
118
+ } finally {
119
+ restoreEnv(savedEnv);
120
+ rmSync(root, { recursive: true, force: true });
121
+ __resetTelemetryForTest();
122
+ }
53
123
  });
54
124
 
55
- test("optedOut is false when the shared config enables telemetry", () => {
56
- writeConfig(JSON.stringify({ telemetryEnabled: true }));
57
- assert.equal(optedOut(), false);
125
+ test("anonymous id seeds missing config once and reuses it", () => {
126
+ const savedEnv = { ...process.env };
127
+ const { root, home } = sandbox();
128
+ try {
129
+ withoutTelemetryOptOut();
130
+ const first = __anonymousIdForTest();
131
+ const configPath = join(home, ".hyperframes/config.json");
132
+ assert.ok(existsSync(configPath));
133
+ assert.match(
134
+ first,
135
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
136
+ );
137
+ const second = __anonymousIdForTest();
138
+ assert.equal(second, first);
139
+ assert.equal(JSON.parse(readFileSync(configPath, "utf8")).anonymousId, first);
140
+ } finally {
141
+ restoreEnv(savedEnv);
142
+ rmSync(root, { recursive: true, force: true });
143
+ __resetTelemetryForTest();
144
+ }
58
145
  });
59
146
 
60
- test("optedOut treats an unreadable shared config as no opt-out", () => {
61
- writeConfig("{not json");
62
- assert.equal(optedOut(), false);
147
+ test("anonymous id adopts a legacy ~/.media/anon-id on upgrade (persona continuity)", () => {
148
+ const savedEnv = { ...process.env };
149
+ const { root, home } = sandbox();
150
+ try {
151
+ withoutTelemetryOptOut();
152
+ mkdirSync(join(home, ".media"), { recursive: true });
153
+ writeFileSync(join(home, ".media/anon-id"), "legacy-media-id");
154
+ // no ~/.hyperframes/config.json yet — the old media-use-only id must carry over
155
+ assert.equal(__anonymousIdForTest(), "legacy-media-id");
156
+ // and it is persisted into the shared config so CLI/studio see the same id
157
+ assert.equal(
158
+ JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).anonymousId,
159
+ "legacy-media-id",
160
+ );
161
+ } finally {
162
+ restoreEnv(savedEnv);
163
+ rmSync(root, { recursive: true, force: true });
164
+ __resetTelemetryForTest();
165
+ }
63
166
  });
64
167
 
65
- test("optedOut still honors the env opt-outs", () => {
66
- writeConfig(JSON.stringify({ telemetryEnabled: true }));
67
- process.env.DO_NOT_TRACK = "1";
168
+ test("track identifies a signed-in HeyGen account once and still sends events", async () => {
169
+ const savedEnv = { ...process.env };
170
+ const originalFetch = globalThis.fetch;
171
+ const { root, home } = sandbox();
172
+ const calls = [];
173
+ globalThis.fetch = async (url, options) => {
174
+ calls.push({ url, options });
175
+ return { ok: true };
176
+ };
68
177
  try {
69
- assert.equal(optedOut(), true);
178
+ withoutTelemetryOptOut();
179
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
180
+ writeFileSync(
181
+ join(home, ".hyperframes/config.json"),
182
+ JSON.stringify({ anonymousId: "anon-1" }),
183
+ );
184
+ mkdirSync(join(home, ".heygen"), { recursive: true });
185
+ writeFileSync(
186
+ join(home, ".heygen/credentials"),
187
+ JSON.stringify({ user: { email: "alice@example.com", username: "alice" } }),
188
+ );
189
+
190
+ await track("media_use_resolve", { type: "bgm", source: "search" });
191
+ await track("media_use_resolve", { type: "image", source: "generated" });
192
+
193
+ const batch = parseFetchBodies(calls);
194
+ const identify = batch.filter((item) => item.event === "$identify");
195
+ assert.equal(identify.length, 1);
196
+ assert.equal(identify[0].distinct_id, "alice@example.com");
197
+ assert.equal(identify[0].properties.$anon_distinct_id, "anon-1");
198
+ assert.equal(batch.filter((item) => item.event === "media_use_resolve").length, 2);
70
199
  } finally {
71
- delete process.env.DO_NOT_TRACK;
200
+ globalThis.fetch = originalFetch;
201
+ restoreEnv(savedEnv);
202
+ rmSync(root, { recursive: true, force: true });
203
+ __resetTelemetryForTest();
204
+ }
205
+ });
206
+
207
+ test("track identifies with a lowercased email regardless of stored casing", async () => {
208
+ const savedEnv = { ...process.env };
209
+ const originalFetch = globalThis.fetch;
210
+ const { root, home } = sandbox();
211
+ const calls = [];
212
+ globalThis.fetch = async (url, options) => {
213
+ calls.push({ url, options });
214
+ return { ok: true };
215
+ };
216
+ try {
217
+ withoutTelemetryOptOut();
218
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
219
+ writeFileSync(
220
+ join(home, ".hyperframes/config.json"),
221
+ JSON.stringify({ anonymousId: "anon-3" }),
222
+ );
223
+ mkdirSync(join(home, ".heygen"), { recursive: true });
224
+ writeFileSync(
225
+ join(home, ".heygen/credentials"),
226
+ JSON.stringify({ user: { email: "Alice@Example.com", username: "alice" } }),
227
+ );
228
+
229
+ await track("media_use_resolve", { type: "bgm", source: "search" });
230
+
231
+ const batch = parseFetchBodies(calls);
232
+ const identify = batch.filter((item) => item.event === "$identify");
233
+ assert.equal(identify.length, 1);
234
+ // Lowercased so this joins with heygen-cli's own identify call regardless
235
+ // of the account's stored email casing -- otherwise the same person could
236
+ // split into two PostHog profiles by email case alone.
237
+ assert.equal(identify[0].distinct_id, "alice@example.com");
238
+ } finally {
239
+ globalThis.fetch = originalFetch;
240
+ restoreEnv(savedEnv);
241
+ rmSync(root, { recursive: true, force: true });
242
+ __resetTelemetryForTest();
243
+ }
244
+ });
245
+
246
+ test("track does not identify when signed out", async () => {
247
+ const savedEnv = { ...process.env };
248
+ const originalFetch = globalThis.fetch;
249
+ const { root, home } = sandbox();
250
+ const calls = [];
251
+ globalThis.fetch = async (url, options) => {
252
+ calls.push({ url, options });
253
+ return { ok: true };
254
+ };
255
+ try {
256
+ withoutTelemetryOptOut();
257
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
258
+ writeFileSync(
259
+ join(home, ".hyperframes/config.json"),
260
+ JSON.stringify({ anonymousId: "anon-2" }),
261
+ );
262
+
263
+ await track("media_use_resolve", { type: "bgm", source: "search" });
264
+
265
+ const batch = parseFetchBodies(calls);
266
+ assert.equal(
267
+ batch.some((item) => item.event === "$identify"),
268
+ false,
269
+ );
270
+ assert.equal(batch[0].event, "media_use_resolve");
271
+ assert.equal(batch[0].distinct_id, "anon-2");
272
+ } finally {
273
+ globalThis.fetch = originalFetch;
274
+ restoreEnv(savedEnv);
275
+ rmSync(root, { recursive: true, force: true });
276
+ __resetTelemetryForTest();
277
+ }
278
+ });
279
+
280
+ test("first run notice prints to stderr once and never stdout", async () => {
281
+ const savedEnv = { ...process.env };
282
+ const originalFetch = globalThis.fetch;
283
+ const originalError = console.error;
284
+ const originalLog = console.log;
285
+ const { root, home } = sandbox();
286
+ const stderr = [];
287
+ const stdout = [];
288
+ globalThis.fetch = async () => ({ ok: true });
289
+ console.error = (...args) => stderr.push(args.join(" "));
290
+ console.log = (...args) => stdout.push(args.join(" "));
291
+ try {
292
+ withoutTelemetryOptOut();
293
+ await track("media_use_resolve", { type: "bgm" });
294
+ await track("media_use_resolve", { type: "sfx" });
295
+
296
+ assert.equal(stderr.length, 1);
297
+ assert.match(stderr[0], /media-use sends usage telemetry/);
298
+ assert.equal(stdout.length, 0);
299
+ // notice-shown lives in the shared config (config.telemetryNoticeShown), so
300
+ // the CLI and media-use show it once per person — not a media-use-only marker.
301
+ assert.equal(
302
+ JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).telemetryNoticeShown,
303
+ true,
304
+ );
305
+ } finally {
306
+ globalThis.fetch = originalFetch;
307
+ console.error = originalError;
308
+ console.log = originalLog;
309
+ restoreEnv(savedEnv);
310
+ rmSync(root, { recursive: true, force: true });
311
+ __resetTelemetryForTest();
312
+ }
313
+ });
314
+
315
+ test("warns once on stderr when MEDIA_USE_TELEMETRY_HOST is set outside a test/CI context", async () => {
316
+ const savedEnv = { ...process.env };
317
+ const originalFetch = globalThis.fetch;
318
+ const originalError = console.error;
319
+ const { root } = sandbox();
320
+ const stderr = [];
321
+ globalThis.fetch = async () => ({ ok: true });
322
+ console.error = (...args) => stderr.push(args.join(" "));
323
+ try {
324
+ // No opt-out, no CI/NODE_ENV signal — mirrors a real user's shell where
325
+ // this test-only var leaked in by accident and tracking is not disabled.
326
+ withoutTelemetryOptOut();
327
+ process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1"; // nothing listens; irrelevant here
328
+ await track("media_use_resolve", { type: "bgm" });
329
+ await track("media_use_resolve", { type: "bgm" });
330
+
331
+ const warnings = stderr.filter((line) => line.includes("MEDIA_USE_TELEMETRY_HOST"));
332
+ assert.equal(warnings.length, 1, "expected exactly one warning across repeated calls");
333
+ } finally {
334
+ globalThis.fetch = originalFetch;
335
+ console.error = originalError;
336
+ restoreEnv(savedEnv);
337
+ rmSync(root, { recursive: true, force: true });
338
+ __resetTelemetryForTest();
339
+ }
340
+ });
341
+
342
+ test("does not warn when MEDIA_USE_TELEMETRY_HOST is set the way the U7 interception test sets it", async () => {
343
+ const savedEnv = { ...process.env };
344
+ const originalFetch = globalThis.fetch;
345
+ const originalError = console.error;
346
+ const { root } = sandbox();
347
+ const stderr = [];
348
+ globalThis.fetch = async () => ({ ok: true });
349
+ console.error = (...args) => stderr.push(args.join(" "));
350
+ try {
351
+ withoutTelemetryOptOut();
352
+ // Same env shape resolve.test.mjs's U7 test uses to allow tracking through:
353
+ // DO_NOT_TRACK=0, CI unset/empty, NODE_ENV=test.
354
+ process.env.DO_NOT_TRACK = "0";
355
+ process.env.CI = "";
356
+ process.env.NODE_ENV = "test";
357
+ process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1";
358
+ await track("media_use_resolve", { type: "bgm" });
359
+
360
+ assert.equal(
361
+ stderr.some((line) => line.includes("MEDIA_USE_TELEMETRY_HOST")),
362
+ false,
363
+ "the U7 test's own use of the override must not print a spurious warning",
364
+ );
365
+ } finally {
366
+ globalThis.fetch = originalFetch;
367
+ console.error = originalError;
368
+ restoreEnv(savedEnv);
369
+ rmSync(root, { recursive: true, force: true });
370
+ __resetTelemetryForTest();
371
+ }
372
+ });
373
+
374
+ test("read-only telemetry state degrades without throwing", async () => {
375
+ const savedEnv = { ...process.env };
376
+ const originalFetch = globalThis.fetch;
377
+ const { root, home } = sandbox();
378
+ globalThis.fetch = async () => ({ ok: true });
379
+ try {
380
+ withoutTelemetryOptOut();
381
+ writeFileSync(join(home, ".hyperframes"), "not a directory");
382
+ writeFileSync(join(home, ".media"), "not a directory");
383
+ await track("media_use_resolve", { type: "bgm" });
384
+ } finally {
385
+ globalThis.fetch = originalFetch;
386
+ try {
387
+ chmodSync(join(home, ".hyperframes"), 0o600);
388
+ } catch {
389
+ // best effort for cleanup on platforms with different chmod behavior
390
+ }
391
+ restoreEnv(savedEnv);
392
+ rmSync(root, { recursive: true, force: true });
393
+ __resetTelemetryForTest();
394
+ }
395
+ });
396
+
397
+ async function trackQuietly(home) {
398
+ const originalFetch = globalThis.fetch;
399
+ const originalError = console.error;
400
+ globalThis.fetch = async () => ({ ok: true });
401
+ console.error = () => {};
402
+ try {
403
+ withoutTelemetryOptOut();
404
+ await track("media_use_resolve", { type: "bgm" });
405
+ return readFileSync(join(home, ".hyperframes/config.json"), "utf8");
406
+ } finally {
407
+ globalThis.fetch = originalFetch;
408
+ console.error = originalError;
409
+ }
410
+ }
411
+
412
+ test("waits for another process's locked settings write, then keeps the no it saved", async () => {
413
+ const savedEnv = { ...process.env };
414
+ const { root, home } = sandbox();
415
+ const dir = join(home, ".hyperframes");
416
+ mkdirSync(dir, { recursive: true });
417
+ const holder = spawn(process.execPath, [
418
+ "-e",
419
+ `const fs = require("fs");
420
+ const dir = process.argv[1];
421
+ fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
422
+ process.stdout.write("locked\\n");
423
+ setTimeout(() => {
424
+ fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
425
+ fs.renameSync(dir + "/other.tmp", dir + "/config.json");
426
+ fs.rmSync(dir + "/config.json.lock");
427
+ }, 1000);`,
428
+ dir,
429
+ ]);
430
+ const exited = new Promise((done) => holder.on("exit", done));
431
+ try {
432
+ await new Promise((locked) => holder.stdout.once("data", locked));
433
+ const saved = JSON.parse(await trackQuietly(home));
434
+ await exited;
435
+ assert.equal(saved.localEmbeddingEnabled, false);
436
+ assert.equal(saved.telemetryNoticeShown, true);
437
+ assert.equal(typeof saved.anonymousId, "string");
438
+ } finally {
439
+ restoreEnv(savedEnv);
440
+ rmSync(root, { recursive: true, force: true });
441
+ __resetTelemetryForTest();
442
+ }
443
+ });
444
+
445
+ test("leaves a settings file it cannot read untouched", async () => {
446
+ const savedEnv = { ...process.env };
447
+ const { root, home } = sandbox();
448
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
449
+ writeFileSync(join(home, ".hyperframes/config.json"), "{not json");
450
+ try {
451
+ assert.equal(await trackQuietly(home), "{not json");
452
+ } finally {
453
+ restoreEnv(savedEnv);
454
+ rmSync(root, { recursive: true, force: true });
455
+ __resetTelemetryForTest();
72
456
  }
73
457
  });
@@ -0,0 +1,10 @@
1
+ import assert from "node:assert/strict";
2
+ import { realpathSync } from "node:fs";
3
+ import { homedir, tmpdir, userInfo } from "node:os";
4
+ import { sep } from "node:path";
5
+ import { test } from "node:test";
6
+
7
+ test("the skills test run has a home folder of its own, not the user's", () => {
8
+ assert.ok(realpathSync(homedir()).startsWith(realpathSync(tmpdir()) + sep));
9
+ assert.notEqual(homedir(), userInfo().homedir);
10
+ });
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fadeFilterFor } from "./transcriptCutFade.mjs";
4
+
5
+ test("fades in only at the first segment's trailing splice", () => {
6
+ assert.equal(fadeFilterFor(2, { fadeIn: false, fadeOut: true }), "afade=t=out:st=1.97:d=0.03");
7
+ });
8
+
9
+ test("fades both edges of a middle segment's two splices", () => {
10
+ assert.equal(
11
+ fadeFilterFor(2, { fadeIn: true, fadeOut: true }),
12
+ "afade=t=in:st=0:d=0.03,afade=t=out:st=1.97:d=0.03",
13
+ );
14
+ });
15
+
16
+ test("fades out only at the last segment's leading splice", () => {
17
+ assert.equal(fadeFilterFor(2, { fadeIn: true, fadeOut: false }), "afade=t=in:st=0:d=0.03");
18
+ });
19
+
20
+ test("a single kept segment borders no splice at all", () => {
21
+ assert.equal(fadeFilterFor(2, { fadeIn: false, fadeOut: false }), null);
22
+ });
23
+
24
+ test("still scales the ramp down on a short segment with both edges faded", () => {
25
+ assert.equal(
26
+ fadeFilterFor(0.08, { fadeIn: true, fadeOut: true }),
27
+ "afade=t=in:st=0:d=0.02,afade=t=out:st=0.06:d=0.02",
28
+ );
29
+ });
30
+
31
+ test("skips a degenerate segment even when a splice is requested", () => {
32
+ assert.equal(fadeFilterFor(0.005, { fadeIn: true, fadeOut: true }), null);
33
+ });
@@ -0,0 +1,94 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ffprobeBinary } from "./ff-binaries.mjs";
6
+ import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
7
+
8
+ // Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
9
+ // the free/private default now that HeyGen TTS costs wallet credits. Kokoro runs
10
+ // on-device (CPU, faster-than-realtime, bundled voices, native word timestamps),
11
+ // so no key and no per-call charge. When Kokoro is not set up, this returns null
12
+ // and the registry falls through to the HeyGen TTS upsell.
13
+ //
14
+ // Delegated to the hyperframes CLI (same as transcribe / remove-background), not
15
+ // re-implemented here. ffprobe reads the duration back for the ledger.
16
+
17
+ function probeDurationSeconds(ffprobe, file) {
18
+ try {
19
+ const out = execFileSync(
20
+ ffprobe,
21
+ ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file],
22
+ { encoding: "utf8", timeout: 15000 },
23
+ );
24
+ const d = parseFloat(String(out).trim());
25
+ return Number.isFinite(d) ? d : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ // `platform`/`execFn`/`env`/`pathExists` params (defaulting to the real
32
+ // values) exist so tests can exercise the win32 branch without mocking
33
+ // node:child_process (its ESM exports are non-configurable) — same idiom as
34
+ // spawnP in ../../audio/scripts/lib/tts.mjs.
35
+ export async function localTtsGenerate(
36
+ intent,
37
+ ctx,
38
+ platform = process.platform,
39
+ execFn = execFileSync,
40
+ env = process.env,
41
+ pathExists = existsSync,
42
+ ) {
43
+ const ffprobe = ffprobeBinary();
44
+ const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
45
+ const argv = ["hyperframes", "tts", intent, "--output", outPath];
46
+ if (ctx?.voice) argv.push("--voice", ctx.voice);
47
+ if (ctx?.lang && ctx.lang !== "en") argv.push("--lang", ctx.lang);
48
+ // On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
49
+ // (spawnSync npx ENOENT) — resolveSpawnCommand reroutes it through
50
+ // node + npx-cli.js, same as the audio engine's TTS spawns.
51
+ const resolved = resolveSpawnCommand(
52
+ "npx",
53
+ argv,
54
+ { encoding: "utf8", timeout: 300000, stdio: ["ignore", "pipe", "pipe"] },
55
+ platform,
56
+ env,
57
+ pathExists,
58
+ );
59
+ if (!resolved) {
60
+ // npx-on-win32 with no resolvable npx-cli.js — same terminal condition
61
+ // spawnP warns about. Fall through to the next provider rather than crash.
62
+ console.error(
63
+ "media-use: local voice not enabled (kokoro). Cannot run npx on Windows: " +
64
+ "npm's npx-cli.js was not found (install npm with Node, or run via npx/npm run so npm_execpath is set).",
65
+ );
66
+ return null;
67
+ }
68
+ try {
69
+ execFn(resolved.cmd, resolved.args, resolved.opts);
70
+ } catch (err) {
71
+ // `hyperframes tts` prints its "kokoro-onnx not installed" hint to stdout
72
+ // (clack UI), so read both streams and surface the actionable enable-command
73
+ // rather than a bare "Command failed": otherwise resolve silently falls
74
+ // through to the PAID HeyGen TTS upsell when free local voice was one pip away.
75
+ const out = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim();
76
+ const hint = /not installed|pip install kokoro/i.test(out)
77
+ ? "install for free on-device voice: pip install kokoro-onnx soundfile (or set HYPERFRAMES_PYTHON to a venv that has it)"
78
+ : out.slice(-200) || err.message;
79
+ console.error(`media-use: local voice not enabled (kokoro). ${hint}`);
80
+ return null;
81
+ }
82
+ if (!existsSync(outPath) || statSync(outPath).size === 0) return null;
83
+ return {
84
+ localPath: outPath,
85
+ ext: ".wav",
86
+ source: "generated",
87
+ metadata: {
88
+ description: intent,
89
+ provider: "kokoro.local",
90
+ duration: probeDurationSeconds(ffprobe, outPath),
91
+ provenance: { engine: "kokoro-82m", prompt: intent },
92
+ },
93
+ };
94
+ }