hyperframes 0.8.58 → 0.8.60

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 +4529 -3220
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +96 -16
  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-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
  173. package/dist/studio/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
  174. package/dist/studio/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
  175. package/dist/studio/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
  176. package/dist/studio/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
  177. package/dist/studio/index.d.ts +549 -13
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +23133 -21941
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ import { statSync } from "node:fs";
2
+ import { readGlobalManifest } from "./cache.mjs";
3
+ import { readManifest } from "./manifest.mjs";
4
+ import { readMisses } from "./misses.mjs";
5
+
6
+ const TOP_MISSES = 5;
7
+
8
+ function emptyReport() {
9
+ return {
10
+ total_resolves: 0,
11
+ by_type: {},
12
+ by_source: {},
13
+ by_provider: {},
14
+ by_via: {},
15
+ misses: 0,
16
+ hit_rate: null,
17
+ top_missed_intents: {},
18
+ global_cache_assets: 0,
19
+ global_cache_disk_bytes: 0,
20
+ cross_project_reuse: 0,
21
+ };
22
+ }
23
+
24
+ function increment(map, key) {
25
+ if (!key) return;
26
+ map[key] = (map[key] || 0) + 1;
27
+ }
28
+
29
+ function timestampOf(record) {
30
+ return record?.ts || record?.timestamp || record?.created_at || record?.createdAt || null;
31
+ }
32
+
33
+ function inWindow(record, cutoff) {
34
+ if (!cutoff) return true;
35
+ const ts = timestampOf(record);
36
+ // Older manifest records may not carry a timestamp; keep them in the report
37
+ // because --days can only window records/misses that carry a ts/timestamp.
38
+ if (!ts) return true;
39
+ const time = Date.parse(ts);
40
+ return Number.isNaN(time) ? true : time >= cutoff;
41
+ }
42
+
43
+ function sourceOf(record) {
44
+ return record?._source || record?.source || record?.provenance?.source || "unknown";
45
+ }
46
+
47
+ function normalizeIntent(intent) {
48
+ return String(intent ?? "")
49
+ .trim()
50
+ .toLowerCase()
51
+ .replace(/\s+/g, " ");
52
+ }
53
+
54
+ function topMissedIntents(misses) {
55
+ const grouped = {};
56
+ for (const miss of misses) {
57
+ const type = miss?.type || "unknown";
58
+ const intent = normalizeIntent(miss?.intent);
59
+ if (!intent) continue;
60
+ grouped[type] ||= {};
61
+ grouped[type][intent] = (grouped[type][intent] || 0) + 1;
62
+ }
63
+ const out = {};
64
+ for (const [type, intents] of Object.entries(grouped)) {
65
+ out[type] = Object.entries(intents)
66
+ .map(([intent, count]) => ({ intent, count }))
67
+ .sort((a, b) => b.count - a.count || a.intent.localeCompare(b.intent))
68
+ .slice(0, TOP_MISSES);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function diskBytes(records) {
74
+ let total = 0;
75
+ for (const record of records) {
76
+ const p = record?.cached_path || record?.path;
77
+ if (!p) continue;
78
+ try {
79
+ total += statSync(p).size;
80
+ } catch {
81
+ // cache entries can outlive files; stats skips missing files
82
+ }
83
+ }
84
+ return total;
85
+ }
86
+
87
+ export function buildStats({ projectDir, days, now = Date.now() } = {}) {
88
+ // Only a positive finite --days windows the report; null / NaN / <= 0 mean
89
+ // "all time" rather than silently excluding everything (a negative cutoff
90
+ // would land in the future and drop every record). The reads below are each
91
+ // best-effort (they return [] / skip on IO errors), so there is no top-level
92
+ // catch masking a real logic bug as an all-zero "no usage" report.
93
+ const n = Number(days);
94
+ const cutoff = Number.isFinite(n) && n > 0 ? Number(now) - n * 24 * 60 * 60 * 1000 : null;
95
+ const records = (projectDir ? readManifest(projectDir) : []).filter((r) => inWindow(r, cutoff));
96
+ const misses = readMisses().filter((miss) => inWindow(miss, cutoff));
97
+ const globalRecords = readGlobalManifest();
98
+ const report = emptyReport();
99
+
100
+ report.total_resolves = records.length;
101
+ report.misses = misses.length;
102
+ for (const record of records) {
103
+ increment(report.by_type, record?.type || "unknown");
104
+ increment(report.by_source, sourceOf(record));
105
+ increment(report.by_provider, record?.provenance?.provider);
106
+ increment(report.by_via, record?.provenance?.via);
107
+ }
108
+
109
+ const attempts = report.total_resolves + report.misses;
110
+ report.hit_rate = attempts === 0 ? null : report.total_resolves / attempts;
111
+ report.top_missed_intents = topMissedIntents(misses);
112
+ report.global_cache_assets = globalRecords.length;
113
+ report.global_cache_disk_bytes = diskBytes(globalRecords);
114
+ report.cross_project_reuse = globalRecords.filter((r) => r?.provenance?.reused_by).length;
115
+
116
+ return report;
117
+ }
@@ -0,0 +1,165 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { buildStats } from "./stats.mjs";
7
+
8
+ function sandbox() {
9
+ const root = mkdtempSync(join(tmpdir(), "mu-stats-"));
10
+ const home = join(root, "home");
11
+ const projectDir = join(root, "project");
12
+ mkdirSync(home, { recursive: true });
13
+ mkdirSync(projectDir, { recursive: true });
14
+ process.env.HOME = home;
15
+ return { root, home, projectDir };
16
+ }
17
+
18
+ function restoreEnv(saved) {
19
+ for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
20
+ Object.assign(process.env, saved);
21
+ }
22
+
23
+ function seedManifest(dir, records) {
24
+ mkdirSync(join(dir, ".media"), { recursive: true });
25
+ writeFileSync(
26
+ join(dir, ".media/manifest.jsonl"),
27
+ records.map((record) => JSON.stringify(record)).join("\n") + "\n",
28
+ );
29
+ }
30
+
31
+ function isoDaysAgo(days) {
32
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
33
+ }
34
+
35
+ test("buildStats aggregates resolves, misses, providers, sources, and reuse", () => {
36
+ const savedEnv = { ...process.env };
37
+ const { root, home, projectDir } = sandbox();
38
+ try {
39
+ seedManifest(projectDir, [
40
+ {
41
+ id: "bgm_001",
42
+ type: "bgm",
43
+ source: "search",
44
+ ts: isoDaysAgo(0),
45
+ provenance: { provider: "heygen.audio.sounds", prompt: "upbeat", via: "url" },
46
+ },
47
+ {
48
+ id: "image_001",
49
+ type: "image",
50
+ _source: "reused-explicit",
51
+ timestamp: isoDaysAgo(0),
52
+ provenance: { provider: "local", reused_by: "agent" },
53
+ },
54
+ {
55
+ id: "bgm_002",
56
+ type: "bgm",
57
+ source: "generated",
58
+ provenance: { provider: "codex" },
59
+ },
60
+ ]);
61
+ const cachedFile = join(home, ".media/mu-v1-cache/asset.wav");
62
+ mkdirSync(join(cachedFile, ".."), { recursive: true });
63
+ writeFileSync(cachedFile, "12345");
64
+ seedManifest(home, [
65
+ {
66
+ id: "bgm_009",
67
+ type: "bgm",
68
+ reusable: true,
69
+ cached_path: cachedFile,
70
+ provenance: { provider: "heygen.audio.sounds", reused_by: "agent" },
71
+ },
72
+ ]);
73
+ writeFileSync(
74
+ join(home, ".media/misses.jsonl"),
75
+ JSON.stringify({
76
+ ts: isoDaysAgo(0),
77
+ type: "bgm",
78
+ intent: "dark cinematic riser",
79
+ }) + "\n",
80
+ { flag: "a" },
81
+ );
82
+
83
+ const stats = buildStats({ projectDir });
84
+ assert.equal(stats.total_resolves, 3);
85
+ assert.deepEqual(stats.by_type, { bgm: 2, image: 1 });
86
+ assert.deepEqual(stats.by_source, { search: 1, "reused-explicit": 1, generated: 1 });
87
+ assert.equal(stats.by_provider["heygen.audio.sounds"], 1);
88
+ assert.equal(stats.by_via.url, 1);
89
+ assert.equal(stats.misses, 1);
90
+ assert.equal(stats.hit_rate, 0.75);
91
+ assert.deepEqual(stats.top_missed_intents.bgm[0], {
92
+ intent: "dark cinematic riser",
93
+ count: 1,
94
+ });
95
+ assert.equal(stats.global_cache_assets, 1);
96
+ assert.equal(stats.global_cache_disk_bytes, 5);
97
+ assert.equal(stats.cross_project_reuse, 1);
98
+ } finally {
99
+ restoreEnv(savedEnv);
100
+ rmSync(root, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ test("buildStats returns a zeroed report when nothing exists", () => {
105
+ const savedEnv = { ...process.env };
106
+ const { root, projectDir } = sandbox();
107
+ try {
108
+ const stats = buildStats({ projectDir });
109
+ assert.equal(stats.total_resolves, 0);
110
+ assert.deepEqual(stats.by_type, {});
111
+ assert.deepEqual(stats.by_source, {});
112
+ assert.deepEqual(stats.by_provider, {});
113
+ assert.deepEqual(stats.by_via, {});
114
+ assert.equal(stats.misses, 0);
115
+ assert.equal(stats.hit_rate, null);
116
+ assert.deepEqual(stats.top_missed_intents, {});
117
+ assert.equal(stats.global_cache_assets, 0);
118
+ assert.equal(stats.global_cache_disk_bytes, 0);
119
+ assert.equal(stats.cross_project_reuse, 0);
120
+ } finally {
121
+ restoreEnv(savedEnv);
122
+ rmSync(root, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test("buildStats returns JSON-safe output", () => {
127
+ const savedEnv = { ...process.env };
128
+ const { root, projectDir } = sandbox();
129
+ try {
130
+ const stats = buildStats({ projectDir });
131
+ assert.deepEqual(JSON.parse(JSON.stringify(stats)), stats);
132
+ } finally {
133
+ restoreEnv(savedEnv);
134
+ rmSync(root, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test("buildStats applies days window to timestamped records and misses", () => {
139
+ const savedEnv = { ...process.env };
140
+ const { root, home, projectDir } = sandbox();
141
+ try {
142
+ seedManifest(projectDir, [
143
+ { id: "bgm_old", type: "bgm", ts: isoDaysAgo(100), provenance: { provider: "old" } },
144
+ { id: "bgm_new", type: "bgm", ts: isoDaysAgo(1), provenance: { provider: "new" } },
145
+ { id: "image_untimed", type: "image", provenance: { provider: "none" } },
146
+ ]);
147
+ mkdirSync(join(home, ".media"), { recursive: true });
148
+ writeFileSync(
149
+ join(home, ".media/misses.jsonl"),
150
+ [
151
+ JSON.stringify({ ts: isoDaysAgo(100), type: "bgm", intent: "old miss" }),
152
+ JSON.stringify({ ts: isoDaysAgo(1), type: "bgm", intent: "new miss" }),
153
+ ].join("\n"),
154
+ );
155
+
156
+ const stats = buildStats({ projectDir, days: 7 });
157
+ assert.equal(stats.total_resolves, 2);
158
+ assert.deepEqual(stats.by_type, { bgm: 1, image: 1 });
159
+ assert.equal(stats.misses, 1);
160
+ assert.equal(stats.top_missed_intents.bgm[0].intent, "new miss");
161
+ } finally {
162
+ restoreEnv(savedEnv);
163
+ rmSync(root, { recursive: true, force: true });
164
+ }
165
+ });
@@ -0,0 +1,193 @@
1
+ // Usage tracking shares the CLI and Studio identity. Properties stay coarse and
2
+ // never carry intent text, file names, or paths.
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
10
+ const POSTHOG_HOST = "https://us.i.posthog.com";
11
+ const TIMEOUT_MS = 1500;
12
+ let identifiedAccount = false;
13
+ let warnedNonDefaultHost = false;
14
+
15
+ function isTestOrCiContext() {
16
+ return (
17
+ process.env.CI === "true" ||
18
+ process.env.CI === "1" ||
19
+ process.env.NODE_ENV === "test" ||
20
+ process.env.NODE_ENV === "development"
21
+ );
22
+ }
23
+
24
+ function posthogHost() {
25
+ const override = process.env.MEDIA_USE_TELEMETRY_HOST;
26
+ if (override && !warnedNonDefaultHost && !isTestOrCiContext()) {
27
+ warnedNonDefaultHost = true;
28
+ console.error(
29
+ `media-use: telemetry is redirected to a non-default host via MEDIA_USE_TELEMETRY_HOST (${override}) — unset it unless this is intentional.`,
30
+ );
31
+ }
32
+ return override || POSTHOG_HOST;
33
+ }
34
+
35
+ /** True when telemetry must NOT be sent (opt-out envs, CI, dev). */
36
+ export function optedOut() {
37
+ return (
38
+ process.env.HYPERFRAMES_NO_TELEMETRY === "1" ||
39
+ process.env.DO_NOT_TRACK === "1" ||
40
+ process.env.CI === "true" ||
41
+ process.env.CI === "1" ||
42
+ process.env.NODE_ENV === "development"
43
+ );
44
+ }
45
+
46
+ // Read and write the shared config so media-use keeps one identity per install.
47
+ function sharedConfigPath() {
48
+ return join(homedir(), ".hyperframes", "config.json");
49
+ }
50
+
51
+ function readSharedConfig() {
52
+ try {
53
+ const file = sharedConfigPath();
54
+ if (existsSync(file)) {
55
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
56
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
57
+ }
58
+ } catch {
59
+ // unreadable config → treat as empty; never throw
60
+ }
61
+ return {};
62
+ }
63
+
64
+ function writeSharedConfig(config) {
65
+ const dir = join(homedir(), ".hyperframes");
66
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
67
+ writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
68
+ }
69
+
70
+ // Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
71
+ // change) so upgraders keep their PostHog persona instead of resetting to a new
72
+ // one — otherwise cross-surface continuity would start over on upgrade.
73
+ function legacyMediaAnonId() {
74
+ try {
75
+ const file = join(homedir(), ".media", "anon-id");
76
+ if (existsSync(file)) {
77
+ const id = readFileSync(file, "utf8").trim();
78
+ if (id) return id;
79
+ }
80
+ } catch {
81
+ // ignore
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Stable per-machine id from the shared config; seeds it (adopting a legacy
87
+ // media-use id when present) if absent.
88
+ function anonymousId() {
89
+ try {
90
+ const config = readSharedConfig();
91
+ if (typeof config.anonymousId === "string" && config.anonymousId.trim()) {
92
+ return config.anonymousId.trim();
93
+ }
94
+ const id = legacyMediaAnonId() || randomUUID();
95
+ writeSharedConfig({ ...config, anonymousId: id });
96
+ return id;
97
+ } catch {
98
+ return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
99
+ }
100
+ }
101
+
102
+ function heygenAccountDistinctId() {
103
+ const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
104
+ try {
105
+ if (!existsSync(file)) return null;
106
+ const raw = readFileSync(file, "utf8").trim();
107
+ if (!raw.startsWith("{")) return null;
108
+ const parsed = JSON.parse(raw);
109
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
110
+ const user = parsed.user;
111
+ if (!user || typeof user !== "object" || Array.isArray(user)) return null;
112
+ const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username;
113
+ // Lowercased so this joins with the CLI's own identify call regardless of
114
+ // the account's stored email casing — two different-case distinct ids
115
+ // would otherwise split one person across two PostHog profiles.
116
+ return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function showTelemetryNotice() {
123
+ if (optedOut()) return;
124
+ try {
125
+ const config = readSharedConfig();
126
+ // Shared with the CLI (config.telemetryNoticeShown): shown once per person
127
+ // across surfaces, not once per tool.
128
+ if (config.telemetryNoticeShown === true) return;
129
+ console.error(
130
+ [
131
+ "media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.",
132
+ "If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
133
+ ].join("\n"),
134
+ );
135
+ writeSharedConfig({ ...config, telemetryNoticeShown: true });
136
+ } catch {
137
+ // notice is best-effort; never surface into the command
138
+ }
139
+ }
140
+
141
+ async function postBatch(batch) {
142
+ try {
143
+ await fetch(`${posthogHost()}/batch/`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json", Connection: "close" },
146
+ body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
147
+ signal: AbortSignal.timeout(TIMEOUT_MS),
148
+ });
149
+ } catch {
150
+ // telemetry is best-effort; never surface into the command
151
+ }
152
+ }
153
+
154
+ async function postEvent(event, properties, distinctId) {
155
+ await postBatch([
156
+ {
157
+ event,
158
+ properties: { ...properties, surface: "media-use", $ip: null },
159
+ distinct_id: distinctId,
160
+ timestamp: new Date().toISOString(),
161
+ },
162
+ ]);
163
+ }
164
+
165
+ async function identifyAccount(anonId) {
166
+ if (optedOut() || identifiedAccount) return;
167
+ const distinctId = heygenAccountDistinctId();
168
+ if (!distinctId) return;
169
+ identifiedAccount = true;
170
+ await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId);
171
+ }
172
+
173
+ /**
174
+ * Fire-and-forget a single event to PostHog. Best-effort: awaited with a short
175
+ * timeout so a short-lived script flushes before exit, but any failure (offline,
176
+ * opted out) is swallowed. `properties` must be non-PII (no intent/paths).
177
+ */
178
+ export async function track(event, properties = {}) {
179
+ if (optedOut()) return;
180
+ showTelemetryNotice();
181
+ const anonId = anonymousId();
182
+ await identifyAccount(anonId);
183
+ await postEvent(event, properties, anonId);
184
+ }
185
+
186
+ export function __anonymousIdForTest() {
187
+ return anonymousId();
188
+ }
189
+
190
+ export function __resetTelemetryForTest() {
191
+ identifiedAccount = false;
192
+ warnedNonDefaultHost = false;
193
+ }