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.
- package/dist/cli.js +4529 -3220
- package/dist/docs/data-attributes.md +2 -1
- package/dist/fontLocalizeCli.js +96 -16
- package/dist/hyperframe-runtime.js +25 -25
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +25 -25
- package/dist/hyperframes-player.global.js +3 -3
- package/dist/registry/localSemantic.js +548 -0
- package/dist/skills/hyperframes-cli/SKILL.md +2 -2
- package/dist/skills/media-use/SKILL.md +96 -0
- package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
- package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
- package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
- package/dist/skills/media-use/audio/references/bgm.md +74 -0
- package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
- package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
- package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
- package/dist/skills/media-use/audio/references/remove-background.md +143 -0
- package/dist/skills/media-use/audio/references/requirements.md +29 -0
- package/dist/skills/media-use/audio/references/sfx.md +42 -0
- package/dist/skills/media-use/audio/references/transcribe.md +52 -0
- package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
- package/dist/skills/media-use/audio/references/tts.md +165 -0
- package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
- package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
- package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
- package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
- package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
- package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
- package/dist/skills/media-use/luts/README.md +28 -0
- package/dist/skills/media-use/luts/index.json +57 -0
- package/dist/skills/media-use/references/audio.md +19 -0
- package/dist/skills/media-use/references/grading.md +160 -0
- package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
- package/dist/skills/media-use/references/media-treatments.md +202 -0
- package/dist/skills/media-use/references/memory.md +36 -0
- package/dist/skills/media-use/references/meta.md +61 -0
- package/dist/skills/media-use/references/operations.md +325 -0
- package/dist/skills/media-use/references/resolve.md +150 -0
- package/dist/skills/media-use/references/setup-providers.md +80 -0
- package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
- package/dist/skills/media-use/registry/localSemantic.js +548 -0
- package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
- package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
- package/dist/skills/media-use/scripts/dither.mjs +328 -0
- package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
- package/dist/skills/media-use/scripts/eval.mjs +369 -0
- package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
- package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
- package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
- package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
- package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
- package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
- package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
- package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
- package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
- package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
- package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
- package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
- package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
- package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
- package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
- package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
- package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
- package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
- package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
- package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
- package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
- package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
- package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
- package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
- package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
- package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
- package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
- package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
- package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
- package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
- package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
- package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
- package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
- package/dist/skills/media-use/scripts/prefs.mjs +70 -0
- package/dist/skills/media-use/scripts/recipe.mjs +103 -0
- package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
- package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
- package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
- package/dist/skills/registry/localSemantic.js +548 -0
- package/dist/studio/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/studio/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/studio/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/studio/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/studio/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/studio/index.d.ts +549 -13
- package/dist/studio/index.html +2 -2
- package/dist/studio/index.js +23133 -21941
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/styles.css +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
chmodSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { __anonymousIdForTest, __resetTelemetryForTest, optedOut, track } from "./telemetry.mjs";
|
|
15
|
+
|
|
16
|
+
function sandbox() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
|
|
18
|
+
const home = join(root, "home");
|
|
19
|
+
mkdirSync(home, { recursive: true });
|
|
20
|
+
process.env.HOME = home;
|
|
21
|
+
return { root, home };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function restoreEnv(saved) {
|
|
25
|
+
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
|
26
|
+
Object.assign(process.env, saved);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function withoutTelemetryOptOut() {
|
|
30
|
+
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
|
|
31
|
+
delete process.env[k];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFetchBodies(calls) {
|
|
35
|
+
return calls.flatMap((call) => JSON.parse(call.options.body).batch);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => {
|
|
39
|
+
const saved = { ...process.env };
|
|
40
|
+
try {
|
|
41
|
+
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
|
|
42
|
+
delete process.env[k];
|
|
43
|
+
assert.equal(optedOut(), false, "default: tracking allowed");
|
|
44
|
+
process.env.DO_NOT_TRACK = "1";
|
|
45
|
+
assert.equal(optedOut(), true, "DO_NOT_TRACK opts out");
|
|
46
|
+
delete process.env.DO_NOT_TRACK;
|
|
47
|
+
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
|
|
48
|
+
assert.equal(optedOut(), true, "HYPERFRAMES_NO_TELEMETRY opts out");
|
|
49
|
+
delete process.env.HYPERFRAMES_NO_TELEMETRY;
|
|
50
|
+
process.env.CI = "true";
|
|
51
|
+
assert.equal(optedOut(), true, "CI opts out");
|
|
52
|
+
} finally {
|
|
53
|
+
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
|
54
|
+
Object.assign(process.env, saved);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("track is a no-op (no network, resolves) when opted out", async () => {
|
|
59
|
+
const savedEnv = { ...process.env };
|
|
60
|
+
const originalFetch = globalThis.fetch;
|
|
61
|
+
const { root, home } = sandbox();
|
|
62
|
+
const calls = [];
|
|
63
|
+
globalThis.fetch = async (...args) => {
|
|
64
|
+
calls.push(args);
|
|
65
|
+
return { ok: true };
|
|
66
|
+
};
|
|
67
|
+
process.env.DO_NOT_TRACK = "1";
|
|
68
|
+
try {
|
|
69
|
+
// must resolve immediately without throwing or hitting the network
|
|
70
|
+
await track("media_use_resolve", { type: "bgm", source: "search" });
|
|
71
|
+
assert.equal(calls.length, 0);
|
|
72
|
+
assert.equal(existsSync(join(home, ".hyperframes/config.json")), false);
|
|
73
|
+
assert.equal(existsSync(join(home, ".media/telemetry-notice-shown")), false);
|
|
74
|
+
} finally {
|
|
75
|
+
globalThis.fetch = originalFetch;
|
|
76
|
+
restoreEnv(savedEnv);
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
__resetTelemetryForTest();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("anonymous id uses the shared hyperframes config", () => {
|
|
83
|
+
const savedEnv = { ...process.env };
|
|
84
|
+
const { root, home } = sandbox();
|
|
85
|
+
try {
|
|
86
|
+
withoutTelemetryOptOut();
|
|
87
|
+
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(home, ".hyperframes/config.json"),
|
|
90
|
+
JSON.stringify({ anonymousId: "shared-install-id", keep: true }),
|
|
91
|
+
);
|
|
92
|
+
mkdirSync(join(home, ".media"), { recursive: true });
|
|
93
|
+
writeFileSync(join(home, ".media/anon-id"), "old-media-id");
|
|
94
|
+
|
|
95
|
+
assert.equal(__anonymousIdForTest(), "shared-install-id");
|
|
96
|
+
} finally {
|
|
97
|
+
restoreEnv(savedEnv);
|
|
98
|
+
rmSync(root, { recursive: true, force: true });
|
|
99
|
+
__resetTelemetryForTest();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("anonymous id seeds missing config once and reuses it", () => {
|
|
104
|
+
const savedEnv = { ...process.env };
|
|
105
|
+
const { root, home } = sandbox();
|
|
106
|
+
try {
|
|
107
|
+
withoutTelemetryOptOut();
|
|
108
|
+
const first = __anonymousIdForTest();
|
|
109
|
+
const configPath = join(home, ".hyperframes/config.json");
|
|
110
|
+
assert.ok(existsSync(configPath));
|
|
111
|
+
assert.match(
|
|
112
|
+
first,
|
|
113
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
114
|
+
);
|
|
115
|
+
const second = __anonymousIdForTest();
|
|
116
|
+
assert.equal(second, first);
|
|
117
|
+
assert.equal(JSON.parse(readFileSync(configPath, "utf8")).anonymousId, first);
|
|
118
|
+
} finally {
|
|
119
|
+
restoreEnv(savedEnv);
|
|
120
|
+
rmSync(root, { recursive: true, force: true });
|
|
121
|
+
__resetTelemetryForTest();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("anonymous id adopts a legacy ~/.media/anon-id on upgrade (persona continuity)", () => {
|
|
126
|
+
const savedEnv = { ...process.env };
|
|
127
|
+
const { root, home } = sandbox();
|
|
128
|
+
try {
|
|
129
|
+
withoutTelemetryOptOut();
|
|
130
|
+
mkdirSync(join(home, ".media"), { recursive: true });
|
|
131
|
+
writeFileSync(join(home, ".media/anon-id"), "legacy-media-id");
|
|
132
|
+
// no ~/.hyperframes/config.json yet — the old media-use-only id must carry over
|
|
133
|
+
assert.equal(__anonymousIdForTest(), "legacy-media-id");
|
|
134
|
+
// and it is persisted into the shared config so CLI/studio see the same id
|
|
135
|
+
assert.equal(
|
|
136
|
+
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).anonymousId,
|
|
137
|
+
"legacy-media-id",
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
restoreEnv(savedEnv);
|
|
141
|
+
rmSync(root, { recursive: true, force: true });
|
|
142
|
+
__resetTelemetryForTest();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("track identifies a signed-in HeyGen account once and still sends events", async () => {
|
|
147
|
+
const savedEnv = { ...process.env };
|
|
148
|
+
const originalFetch = globalThis.fetch;
|
|
149
|
+
const { root, home } = sandbox();
|
|
150
|
+
const calls = [];
|
|
151
|
+
globalThis.fetch = async (url, options) => {
|
|
152
|
+
calls.push({ url, options });
|
|
153
|
+
return { ok: true };
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
withoutTelemetryOptOut();
|
|
157
|
+
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
|
158
|
+
writeFileSync(
|
|
159
|
+
join(home, ".hyperframes/config.json"),
|
|
160
|
+
JSON.stringify({ anonymousId: "anon-1" }),
|
|
161
|
+
);
|
|
162
|
+
mkdirSync(join(home, ".heygen"), { recursive: true });
|
|
163
|
+
writeFileSync(
|
|
164
|
+
join(home, ".heygen/credentials"),
|
|
165
|
+
JSON.stringify({ user: { email: "alice@example.com", username: "alice" } }),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await track("media_use_resolve", { type: "bgm", source: "search" });
|
|
169
|
+
await track("media_use_resolve", { type: "image", source: "generated" });
|
|
170
|
+
|
|
171
|
+
const batch = parseFetchBodies(calls);
|
|
172
|
+
const identify = batch.filter((item) => item.event === "$identify");
|
|
173
|
+
assert.equal(identify.length, 1);
|
|
174
|
+
assert.equal(identify[0].distinct_id, "alice@example.com");
|
|
175
|
+
assert.equal(identify[0].properties.$anon_distinct_id, "anon-1");
|
|
176
|
+
assert.equal(batch.filter((item) => item.event === "media_use_resolve").length, 2);
|
|
177
|
+
} finally {
|
|
178
|
+
globalThis.fetch = originalFetch;
|
|
179
|
+
restoreEnv(savedEnv);
|
|
180
|
+
rmSync(root, { recursive: true, force: true });
|
|
181
|
+
__resetTelemetryForTest();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("track identifies with a lowercased email regardless of stored casing", async () => {
|
|
186
|
+
const savedEnv = { ...process.env };
|
|
187
|
+
const originalFetch = globalThis.fetch;
|
|
188
|
+
const { root, home } = sandbox();
|
|
189
|
+
const calls = [];
|
|
190
|
+
globalThis.fetch = async (url, options) => {
|
|
191
|
+
calls.push({ url, options });
|
|
192
|
+
return { ok: true };
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
withoutTelemetryOptOut();
|
|
196
|
+
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
|
197
|
+
writeFileSync(
|
|
198
|
+
join(home, ".hyperframes/config.json"),
|
|
199
|
+
JSON.stringify({ anonymousId: "anon-3" }),
|
|
200
|
+
);
|
|
201
|
+
mkdirSync(join(home, ".heygen"), { recursive: true });
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(home, ".heygen/credentials"),
|
|
204
|
+
JSON.stringify({ user: { email: "Alice@Example.com", username: "alice" } }),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await track("media_use_resolve", { type: "bgm", source: "search" });
|
|
208
|
+
|
|
209
|
+
const batch = parseFetchBodies(calls);
|
|
210
|
+
const identify = batch.filter((item) => item.event === "$identify");
|
|
211
|
+
assert.equal(identify.length, 1);
|
|
212
|
+
// Lowercased so this joins with heygen-cli's own identify call regardless
|
|
213
|
+
// of the account's stored email casing -- otherwise the same person could
|
|
214
|
+
// split into two PostHog profiles by email case alone.
|
|
215
|
+
assert.equal(identify[0].distinct_id, "alice@example.com");
|
|
216
|
+
} finally {
|
|
217
|
+
globalThis.fetch = originalFetch;
|
|
218
|
+
restoreEnv(savedEnv);
|
|
219
|
+
rmSync(root, { recursive: true, force: true });
|
|
220
|
+
__resetTelemetryForTest();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("track does not identify when signed out", async () => {
|
|
225
|
+
const savedEnv = { ...process.env };
|
|
226
|
+
const originalFetch = globalThis.fetch;
|
|
227
|
+
const { root, home } = sandbox();
|
|
228
|
+
const calls = [];
|
|
229
|
+
globalThis.fetch = async (url, options) => {
|
|
230
|
+
calls.push({ url, options });
|
|
231
|
+
return { ok: true };
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
withoutTelemetryOptOut();
|
|
235
|
+
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(home, ".hyperframes/config.json"),
|
|
238
|
+
JSON.stringify({ anonymousId: "anon-2" }),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await track("media_use_resolve", { type: "bgm", source: "search" });
|
|
242
|
+
|
|
243
|
+
const batch = parseFetchBodies(calls);
|
|
244
|
+
assert.equal(
|
|
245
|
+
batch.some((item) => item.event === "$identify"),
|
|
246
|
+
false,
|
|
247
|
+
);
|
|
248
|
+
assert.equal(batch[0].event, "media_use_resolve");
|
|
249
|
+
assert.equal(batch[0].distinct_id, "anon-2");
|
|
250
|
+
} finally {
|
|
251
|
+
globalThis.fetch = originalFetch;
|
|
252
|
+
restoreEnv(savedEnv);
|
|
253
|
+
rmSync(root, { recursive: true, force: true });
|
|
254
|
+
__resetTelemetryForTest();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("first run notice prints to stderr once and never stdout", async () => {
|
|
259
|
+
const savedEnv = { ...process.env };
|
|
260
|
+
const originalFetch = globalThis.fetch;
|
|
261
|
+
const originalError = console.error;
|
|
262
|
+
const originalLog = console.log;
|
|
263
|
+
const { root, home } = sandbox();
|
|
264
|
+
const stderr = [];
|
|
265
|
+
const stdout = [];
|
|
266
|
+
globalThis.fetch = async () => ({ ok: true });
|
|
267
|
+
console.error = (...args) => stderr.push(args.join(" "));
|
|
268
|
+
console.log = (...args) => stdout.push(args.join(" "));
|
|
269
|
+
try {
|
|
270
|
+
withoutTelemetryOptOut();
|
|
271
|
+
await track("media_use_resolve", { type: "bgm" });
|
|
272
|
+
await track("media_use_resolve", { type: "sfx" });
|
|
273
|
+
|
|
274
|
+
assert.equal(stderr.length, 1);
|
|
275
|
+
assert.match(stderr[0], /media-use sends usage telemetry/);
|
|
276
|
+
assert.equal(stdout.length, 0);
|
|
277
|
+
// notice-shown lives in the shared config (config.telemetryNoticeShown), so
|
|
278
|
+
// the CLI and media-use show it once per person — not a media-use-only marker.
|
|
279
|
+
assert.equal(
|
|
280
|
+
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).telemetryNoticeShown,
|
|
281
|
+
true,
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
globalThis.fetch = originalFetch;
|
|
285
|
+
console.error = originalError;
|
|
286
|
+
console.log = originalLog;
|
|
287
|
+
restoreEnv(savedEnv);
|
|
288
|
+
rmSync(root, { recursive: true, force: true });
|
|
289
|
+
__resetTelemetryForTest();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("warns once on stderr when MEDIA_USE_TELEMETRY_HOST is set outside a test/CI context", async () => {
|
|
294
|
+
const savedEnv = { ...process.env };
|
|
295
|
+
const originalFetch = globalThis.fetch;
|
|
296
|
+
const originalError = console.error;
|
|
297
|
+
const { root } = sandbox();
|
|
298
|
+
const stderr = [];
|
|
299
|
+
globalThis.fetch = async () => ({ ok: true });
|
|
300
|
+
console.error = (...args) => stderr.push(args.join(" "));
|
|
301
|
+
try {
|
|
302
|
+
// No opt-out, no CI/NODE_ENV signal — mirrors a real user's shell where
|
|
303
|
+
// this test-only var leaked in by accident and tracking is not disabled.
|
|
304
|
+
withoutTelemetryOptOut();
|
|
305
|
+
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1"; // nothing listens; irrelevant here
|
|
306
|
+
await track("media_use_resolve", { type: "bgm" });
|
|
307
|
+
await track("media_use_resolve", { type: "bgm" });
|
|
308
|
+
|
|
309
|
+
const warnings = stderr.filter((line) => line.includes("MEDIA_USE_TELEMETRY_HOST"));
|
|
310
|
+
assert.equal(warnings.length, 1, "expected exactly one warning across repeated calls");
|
|
311
|
+
} finally {
|
|
312
|
+
globalThis.fetch = originalFetch;
|
|
313
|
+
console.error = originalError;
|
|
314
|
+
restoreEnv(savedEnv);
|
|
315
|
+
rmSync(root, { recursive: true, force: true });
|
|
316
|
+
__resetTelemetryForTest();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("does not warn when MEDIA_USE_TELEMETRY_HOST is set the way the U7 interception test sets it", async () => {
|
|
321
|
+
const savedEnv = { ...process.env };
|
|
322
|
+
const originalFetch = globalThis.fetch;
|
|
323
|
+
const originalError = console.error;
|
|
324
|
+
const { root } = sandbox();
|
|
325
|
+
const stderr = [];
|
|
326
|
+
globalThis.fetch = async () => ({ ok: true });
|
|
327
|
+
console.error = (...args) => stderr.push(args.join(" "));
|
|
328
|
+
try {
|
|
329
|
+
withoutTelemetryOptOut();
|
|
330
|
+
// Same env shape resolve.test.mjs's U7 test uses to allow tracking through:
|
|
331
|
+
// DO_NOT_TRACK=0, CI unset/empty, NODE_ENV=test.
|
|
332
|
+
process.env.DO_NOT_TRACK = "0";
|
|
333
|
+
process.env.CI = "";
|
|
334
|
+
process.env.NODE_ENV = "test";
|
|
335
|
+
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1";
|
|
336
|
+
await track("media_use_resolve", { type: "bgm" });
|
|
337
|
+
|
|
338
|
+
assert.equal(
|
|
339
|
+
stderr.some((line) => line.includes("MEDIA_USE_TELEMETRY_HOST")),
|
|
340
|
+
false,
|
|
341
|
+
"the U7 test's own use of the override must not print a spurious warning",
|
|
342
|
+
);
|
|
343
|
+
} finally {
|
|
344
|
+
globalThis.fetch = originalFetch;
|
|
345
|
+
console.error = originalError;
|
|
346
|
+
restoreEnv(savedEnv);
|
|
347
|
+
rmSync(root, { recursive: true, force: true });
|
|
348
|
+
__resetTelemetryForTest();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("read-only telemetry state degrades without throwing", async () => {
|
|
353
|
+
const savedEnv = { ...process.env };
|
|
354
|
+
const originalFetch = globalThis.fetch;
|
|
355
|
+
const { root, home } = sandbox();
|
|
356
|
+
globalThis.fetch = async () => ({ ok: true });
|
|
357
|
+
try {
|
|
358
|
+
withoutTelemetryOptOut();
|
|
359
|
+
writeFileSync(join(home, ".hyperframes"), "not a directory");
|
|
360
|
+
writeFileSync(join(home, ".media"), "not a directory");
|
|
361
|
+
await track("media_use_resolve", { type: "bgm" });
|
|
362
|
+
} finally {
|
|
363
|
+
globalThis.fetch = originalFetch;
|
|
364
|
+
try {
|
|
365
|
+
chmodSync(join(home, ".hyperframes"), 0o600);
|
|
366
|
+
} catch {
|
|
367
|
+
// best effort for cleanup on platforms with different chmod behavior
|
|
368
|
+
}
|
|
369
|
+
restoreEnv(savedEnv);
|
|
370
|
+
rmSync(root, { recursive: true, force: true });
|
|
371
|
+
__resetTelemetryForTest();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
|
|
6
|
+
|
|
7
|
+
// Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
|
|
8
|
+
// the free/private default now that HeyGen TTS costs wallet credits. Kokoro runs
|
|
9
|
+
// on-device (CPU, faster-than-realtime, bundled voices, native word timestamps),
|
|
10
|
+
// so no key and no per-call charge. When Kokoro is not set up, this returns null
|
|
11
|
+
// and the registry falls through to the HeyGen TTS upsell.
|
|
12
|
+
//
|
|
13
|
+
// Delegated to the hyperframes CLI (same as transcribe / remove-background), not
|
|
14
|
+
// re-implemented here. ffprobe reads the duration back for the ledger.
|
|
15
|
+
|
|
16
|
+
function probeDurationSeconds(file) {
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync(
|
|
19
|
+
"ffprobe",
|
|
20
|
+
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file],
|
|
21
|
+
{ encoding: "utf8", timeout: 15000 },
|
|
22
|
+
);
|
|
23
|
+
const d = parseFloat(String(out).trim());
|
|
24
|
+
return Number.isFinite(d) ? d : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `platform`/`execFn`/`env`/`pathExists` params (defaulting to the real
|
|
31
|
+
// values) exist so tests can exercise the win32 branch without mocking
|
|
32
|
+
// node:child_process (its ESM exports are non-configurable) — same idiom as
|
|
33
|
+
// spawnP in ../../audio/scripts/lib/tts.mjs.
|
|
34
|
+
export async function localTtsGenerate(
|
|
35
|
+
intent,
|
|
36
|
+
ctx,
|
|
37
|
+
platform = process.platform,
|
|
38
|
+
execFn = execFileSync,
|
|
39
|
+
env = process.env,
|
|
40
|
+
pathExists = existsSync,
|
|
41
|
+
) {
|
|
42
|
+
const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
|
|
43
|
+
const argv = ["hyperframes", "tts", intent, "--output", outPath];
|
|
44
|
+
if (ctx?.voice) argv.push("--voice", ctx.voice);
|
|
45
|
+
if (ctx?.lang && ctx.lang !== "en") argv.push("--lang", ctx.lang);
|
|
46
|
+
// On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
|
|
47
|
+
// (spawnSync npx ENOENT) — resolveSpawnCommand reroutes it through
|
|
48
|
+
// node + npx-cli.js, same as the audio engine's TTS spawns.
|
|
49
|
+
const resolved = resolveSpawnCommand(
|
|
50
|
+
"npx",
|
|
51
|
+
argv,
|
|
52
|
+
{ encoding: "utf8", timeout: 300000, stdio: ["ignore", "pipe", "pipe"] },
|
|
53
|
+
platform,
|
|
54
|
+
env,
|
|
55
|
+
pathExists,
|
|
56
|
+
);
|
|
57
|
+
if (!resolved) {
|
|
58
|
+
// npx-on-win32 with no resolvable npx-cli.js — same terminal condition
|
|
59
|
+
// spawnP warns about. Fall through to the next provider rather than crash.
|
|
60
|
+
console.error(
|
|
61
|
+
"media-use: local voice not enabled (kokoro). Cannot run npx on Windows: " +
|
|
62
|
+
"npm's npx-cli.js was not found (install npm with Node, or run via npx/npm run so npm_execpath is set).",
|
|
63
|
+
);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
execFn(resolved.cmd, resolved.args, resolved.opts);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// `hyperframes tts` prints its "kokoro-onnx not installed" hint to stdout
|
|
70
|
+
// (clack UI), so read both streams and surface the actionable enable-command
|
|
71
|
+
// rather than a bare "Command failed": otherwise resolve silently falls
|
|
72
|
+
// through to the PAID HeyGen TTS upsell when free local voice was one pip away.
|
|
73
|
+
const out = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim();
|
|
74
|
+
const hint = /not installed|pip install kokoro/i.test(out)
|
|
75
|
+
? "install for free on-device voice: pip install kokoro-onnx soundfile (or set HYPERFRAMES_PYTHON to a venv that has it)"
|
|
76
|
+
: out.slice(-200) || err.message;
|
|
77
|
+
console.error(`media-use: local voice not enabled (kokoro). ${hint}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!existsSync(outPath) || statSync(outPath).size === 0) return null;
|
|
81
|
+
return {
|
|
82
|
+
localPath: outPath,
|
|
83
|
+
ext: ".wav",
|
|
84
|
+
source: "generated",
|
|
85
|
+
metadata: {
|
|
86
|
+
description: intent,
|
|
87
|
+
provider: "kokoro.local",
|
|
88
|
+
duration: probeDurationSeconds(outPath),
|
|
89
|
+
provenance: { engine: "kokoro-82m", prompt: intent },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { localTtsGenerate } from "./tts-local-provider.mjs";
|
|
4
|
+
|
|
5
|
+
// Regression: on Windows, a bare "npx" is npx.cmd, which execFileSync cannot
|
|
6
|
+
// exec — the Kokoro delegation failed with `spawnSync npx ENOENT` instead of
|
|
7
|
+
// synthesizing (or cleanly falling through to the next provider). The spawn
|
|
8
|
+
// must route through node + npx-cli.js on win32, same as the audio engine's
|
|
9
|
+
// TTS spawns (see ../../audio/scripts/lib/tts.spawn.test.mjs).
|
|
10
|
+
|
|
11
|
+
const envWithNpxCli = {
|
|
12
|
+
npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js",
|
|
13
|
+
npm_node_execpath: "C:/Program Files/nodejs/node.exe",
|
|
14
|
+
};
|
|
15
|
+
const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
|
|
16
|
+
const pathExists = (path) => path === npxCliPath;
|
|
17
|
+
|
|
18
|
+
test("win32: routes the hyperframes tts call through node + npx-cli, never bare npx", async () => {
|
|
19
|
+
const captured = [];
|
|
20
|
+
const fakeExec = (cmd, args, opts) => {
|
|
21
|
+
captured.push({ cmd, args, opts });
|
|
22
|
+
// Synthesize nothing — the provider then returns null via the
|
|
23
|
+
// missing-output check, which is fine: we only assert the spawn shape.
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
await localTtsGenerate(
|
|
27
|
+
"hello there",
|
|
28
|
+
{ voice: "am_michael" },
|
|
29
|
+
"win32",
|
|
30
|
+
fakeExec,
|
|
31
|
+
envWithNpxCli,
|
|
32
|
+
pathExists,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
assert.equal(captured.length, 1);
|
|
36
|
+
assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath);
|
|
37
|
+
assert.equal(captured[0].args[0], npxCliPath);
|
|
38
|
+
assert.deepEqual(captured[0].args.slice(1, 4), ["hyperframes", "tts", "hello there"]);
|
|
39
|
+
assert.ok(captured[0].args.includes("--voice"));
|
|
40
|
+
// execFileSync options survive the rerouting (pipes are what let the caller
|
|
41
|
+
// read the "kokoro-onnx not installed" hint back out).
|
|
42
|
+
assert.deepEqual(captured[0].opts.stdio, ["ignore", "pipe", "pipe"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("win32 without a resolvable npx-cli: falls through to the next provider (null), no spawn", async () => {
|
|
46
|
+
const captured = [];
|
|
47
|
+
const fakeExec = (...call) => captured.push(call);
|
|
48
|
+
|
|
49
|
+
const result = await localTtsGenerate(
|
|
50
|
+
"hello",
|
|
51
|
+
{},
|
|
52
|
+
"win32",
|
|
53
|
+
fakeExec,
|
|
54
|
+
{}, // no npm_execpath, and pathExists finds nothing
|
|
55
|
+
() => false,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.equal(result, null);
|
|
59
|
+
assert.equal(captured.length, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("non-win32: spawns plain npx unchanged", async () => {
|
|
63
|
+
const captured = [];
|
|
64
|
+
const fakeExec = (cmd, args) => captured.push({ cmd, args });
|
|
65
|
+
|
|
66
|
+
await localTtsGenerate("hola", { lang: "es" }, "darwin", fakeExec, {}, () => false);
|
|
67
|
+
|
|
68
|
+
assert.equal(captured.length, 1);
|
|
69
|
+
assert.equal(captured[0].cmd, "npx");
|
|
70
|
+
assert.deepEqual(captured[0].args.slice(0, 3), ["hyperframes", "tts", "hola"]);
|
|
71
|
+
assert.ok(captured[0].args.includes("--lang"));
|
|
72
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Asset in-use detection: which manifest assets are actually referenced by the
|
|
5
|
+
// project's compositions. Answers "is this safe to prune?" from the CLI. (The
|
|
6
|
+
// Studio Asset tab computes its in-use filter separately from the live timeline;
|
|
7
|
+
// this is the skill-side equivalent for headless use.)
|
|
8
|
+
//
|
|
9
|
+
// ponytail: substring match of each asset's filename against the .html text
|
|
10
|
+
// (covers src= / href= / url() / data-* without parsing HTML). False positives
|
|
11
|
+
// only if a filename literally appears in prose; fine for a filter. Upgrade to
|
|
12
|
+
// attribute parsing if that ever bites.
|
|
13
|
+
|
|
14
|
+
function compositionHtml(projectDir) {
|
|
15
|
+
let files = [];
|
|
16
|
+
try {
|
|
17
|
+
files = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
|
18
|
+
} catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
const sub = join(projectDir, "compositions");
|
|
22
|
+
if (existsSync(sub)) {
|
|
23
|
+
try {
|
|
24
|
+
for (const f of readdirSync(sub))
|
|
25
|
+
if (f.endsWith(".html")) files.push(join("compositions", f));
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore unreadable subdir
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return files
|
|
31
|
+
.map((f) => {
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(join(projectDir, f), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Tag each record with `inUse` (referenced by some composition). */
|
|
42
|
+
export function tagUsage(records, projectDir) {
|
|
43
|
+
const html = compositionHtml(projectDir);
|
|
44
|
+
return records.map((r) => {
|
|
45
|
+
const file = r.path ? r.path.split("/").pop() : r.id;
|
|
46
|
+
return { ...r, inUse: Boolean(file) && html.includes(file) };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Split records into { used, unused } for the filter. */
|
|
51
|
+
export function partitionUsage(records, projectDir) {
|
|
52
|
+
const tagged = tagUsage(records, projectDir);
|
|
53
|
+
return {
|
|
54
|
+
used: tagged.filter((r) => r.inUse),
|
|
55
|
+
unused: tagged.filter((r) => !r.inUse),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { tagUsage, partitionUsage } from "./usage.mjs";
|
|
7
|
+
|
|
8
|
+
function project(html) {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
|
|
10
|
+
writeFileSync(join(dir, "index.html"), html);
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const records = [
|
|
15
|
+
{ id: "bgm_001", path: ".media/audio/bgm/bgm_001.wav", description: "used track" },
|
|
16
|
+
{ id: "bgm_002", path: ".media/audio/bgm/bgm_002.wav", description: "orphan track" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
test("an asset referenced by a composition is marked in-use", () => {
|
|
20
|
+
const dir = project(`<audio src="assets/bgm_001.wav"></audio>`);
|
|
21
|
+
try {
|
|
22
|
+
const tagged = tagUsage(records, dir);
|
|
23
|
+
assert.equal(tagged.find((r) => r.id === "bgm_001").inUse, true);
|
|
24
|
+
assert.equal(tagged.find((r) => r.id === "bgm_002").inUse, false);
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("partitionUsage splits used vs unused for the filter", () => {
|
|
31
|
+
const dir = project(`<img src="bgm_001.wav">`);
|
|
32
|
+
try {
|
|
33
|
+
const { used, unused } = partitionUsage(records, dir);
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
used.map((r) => r.id),
|
|
36
|
+
["bgm_001"],
|
|
37
|
+
);
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
unused.map((r) => r.id),
|
|
40
|
+
["bgm_002"],
|
|
41
|
+
);
|
|
42
|
+
} finally {
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("no compositions -> everything reads as unused (safe default)", () => {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
|
|
49
|
+
try {
|
|
50
|
+
assert.equal(partitionUsage(records, dir).used.length, 0);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|