hyperframes 0.8.57 → 0.8.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5300 -3428
- package/dist/docs/data-attributes.md +2 -1
- package/dist/fontLocalizeCli.js +73 -12
- 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-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/studio/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/studio/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/studio/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/studio/index.d.ts +577 -8
- package/dist/studio/index.html +2 -2
- package/dist/studio/index.js +22537 -21600
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/styles.css +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { mergeTokensToWords } from "./parakeet-words.mjs";
|
|
4
|
+
|
|
5
|
+
test("mergeTokensToWords joins sub-word tokens on the space boundary", () => {
|
|
6
|
+
const parakeet = {
|
|
7
|
+
text: "Hello everyone. Um,",
|
|
8
|
+
sentences: [
|
|
9
|
+
{
|
|
10
|
+
tokens: [
|
|
11
|
+
{ text: " H", start: 0.0, end: 0.24 },
|
|
12
|
+
{ text: "ello", start: 0.24, end: 0.48 },
|
|
13
|
+
{ text: " everyone.", start: 0.48, end: 1.28 },
|
|
14
|
+
{ text: " Um,", start: 1.28, end: 1.92 },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const { words } = mergeTokensToWords(parakeet);
|
|
20
|
+
assert.deepEqual(words, [
|
|
21
|
+
{ text: "Hello", start: 0.0, end: 0.48 },
|
|
22
|
+
{ text: "everyone.", start: 0.48, end: 1.28 },
|
|
23
|
+
{ text: "Um,", start: 1.28, end: 1.92 },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("mergeTokensToWords spans multiple sentences and drops empties", () => {
|
|
28
|
+
const parakeet = {
|
|
29
|
+
text: "Hi there",
|
|
30
|
+
sentences: [
|
|
31
|
+
{ tokens: [{ text: "Hi", start: 0, end: 0.2 }] },
|
|
32
|
+
{ tokens: [{ text: " there", start: 0.5, end: 0.9 }] },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
const { words } = mergeTokensToWords(parakeet);
|
|
36
|
+
assert.equal(words.length, 2);
|
|
37
|
+
assert.equal(words[1].text, "there");
|
|
38
|
+
assert.equal(words[1].start, 0.5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("mergeTokensToWords tolerates missing sentences/tokens", () => {
|
|
42
|
+
assert.deepEqual(mergeTokensToWords({}).words, []);
|
|
43
|
+
assert.deepEqual(mergeTokensToWords({ sentences: [{}] }).words, []);
|
|
44
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Remembered defaults — the lightweight tier of HyperFrames user memory.
|
|
7
|
+
*
|
|
8
|
+
* Two files, same shape as the rest of media-use's storage split:
|
|
9
|
+
* - project `.media/preferences.json` — committed with the repo, so the whole
|
|
10
|
+
* team inherits it; written every time a brief answer is confirmed.
|
|
11
|
+
* - user `~/.media/preferences.json` — personal, cross-repo. A key is promoted
|
|
12
|
+
* here only once the same value has been confirmed in two different projects
|
|
13
|
+
* (`PROMOTE_AT`), so a one-off choice never pollutes the global defaults.
|
|
14
|
+
* Pre-promotion evidence accumulates in the user file's `sightings` ledger —
|
|
15
|
+
* project files can't see each other, so the cross-project count has to live
|
|
16
|
+
* user-side.
|
|
17
|
+
*
|
|
18
|
+
* Consumption contract (brief-contract § 2, Remembered defaults): a remembered
|
|
19
|
+
* value becomes the recommended option with a receipt naming its source — it
|
|
20
|
+
* never skips a question, and explicit request content always wins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PREFS_FILE = "preferences.json";
|
|
24
|
+
|
|
25
|
+
/** Keys the brief contract records; `style_preset` is stored per workflow. */
|
|
26
|
+
export const PREFERENCE_KEYS = [
|
|
27
|
+
"destination",
|
|
28
|
+
"aspect",
|
|
29
|
+
"language",
|
|
30
|
+
"flow",
|
|
31
|
+
"storyboard",
|
|
32
|
+
"voice",
|
|
33
|
+
"style_preset",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** A value must be confirmed in this many distinct projects to go user-tier. */
|
|
37
|
+
export const PROMOTE_AT = 2;
|
|
38
|
+
|
|
39
|
+
export function projectPrefsPath(projectDir) {
|
|
40
|
+
return join(resolve(projectDir), ".media", PREFS_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function userPrefsPath() {
|
|
44
|
+
return join(homedir(), ".media", PREFS_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emptyFile() {
|
|
48
|
+
return { version: 1, preferences: {}, sightings: {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isRecord(value) {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Tolerant read — a missing or malformed file counts as empty. */
|
|
56
|
+
function readPrefsFile(path) {
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(path)) return emptyFile();
|
|
59
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (!isRecord(parsed)) return emptyFile();
|
|
61
|
+
return {
|
|
62
|
+
version: 1,
|
|
63
|
+
preferences: isRecord(parsed.preferences) ? parsed.preferences : {},
|
|
64
|
+
sightings: isRecord(parsed.sightings) ? parsed.sightings : {},
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return emptyFile();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Atomic write (tmp + rename) so a crash never leaves a torn file. */
|
|
72
|
+
function writePrefsFile(path, file) {
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
const tmp = `${path}.tmp`;
|
|
75
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`);
|
|
76
|
+
renameSync(tmp, path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `style_preset` entries are stored per workflow as `style_preset.<workflow>`. */
|
|
80
|
+
export function preferenceKeyFor(key, workflow) {
|
|
81
|
+
return key === "style_preset" && workflow ? `style_preset.${workflow}` : key;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validEntry(entry) {
|
|
85
|
+
return isRecord(entry) && typeof entry.value === "string" && entry.value.length > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The merged view the brief reads: user-tier promoted entries first, project
|
|
90
|
+
* entries on top (project wins). Each entry carries `source` plus the receipt
|
|
91
|
+
* material (`confirmed_in`, `updated_at`).
|
|
92
|
+
*/
|
|
93
|
+
export function mergedPreferences(projectDir) {
|
|
94
|
+
const user = readPrefsFile(userPrefsPath());
|
|
95
|
+
const project = readPrefsFile(projectPrefsPath(projectDir));
|
|
96
|
+
const merged = {};
|
|
97
|
+
for (const [key, entry] of Object.entries(user.preferences)) {
|
|
98
|
+
if (validEntry(entry)) merged[key] = { ...entry, source: "user" };
|
|
99
|
+
}
|
|
100
|
+
for (const [key, entry] of Object.entries(project.preferences)) {
|
|
101
|
+
if (validEntry(entry)) merged[key] = { ...entry, source: "project" };
|
|
102
|
+
}
|
|
103
|
+
return merged;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function dedupe(list) {
|
|
107
|
+
return [...new Set(list)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Project tier: same value accumulates confirmations; a changed value starts
|
|
112
|
+
* provenance over (the old confirmations vouched for the old value).
|
|
113
|
+
*/
|
|
114
|
+
function recordProjectTier(projectDir, fullKey, value, projectName, now) {
|
|
115
|
+
const path = projectPrefsPath(projectDir);
|
|
116
|
+
const file = readPrefsFile(path);
|
|
117
|
+
const previous = file.preferences[fullKey];
|
|
118
|
+
const keepProvenance = validEntry(previous) && previous.value === value;
|
|
119
|
+
const confirmedIn = keepProvenance
|
|
120
|
+
? dedupe([...(Array.isArray(previous.confirmed_in) ? previous.confirmed_in : []), projectName])
|
|
121
|
+
: [projectName];
|
|
122
|
+
file.preferences[fullKey] = { value, confirmed_in: confirmedIn, updated_at: now };
|
|
123
|
+
writePrefsFile(path, file);
|
|
124
|
+
return confirmedIn;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* User tier: accumulate this sighting in the ledger, and promote the key once
|
|
129
|
+
* the same value has been confirmed in PROMOTE_AT distinct projects.
|
|
130
|
+
*/
|
|
131
|
+
function recordUserSighting(fullKey, value, projectName, now) {
|
|
132
|
+
const path = userPrefsPath();
|
|
133
|
+
const file = readPrefsFile(path);
|
|
134
|
+
const keySightings = isRecord(file.sightings[fullKey]) ? file.sightings[fullKey] : {};
|
|
135
|
+
const seenIn = dedupe([
|
|
136
|
+
...(Array.isArray(keySightings[value]) ? keySightings[value] : []),
|
|
137
|
+
projectName,
|
|
138
|
+
]);
|
|
139
|
+
keySightings[value] = seenIn;
|
|
140
|
+
file.sightings[fullKey] = keySightings;
|
|
141
|
+
const promoted = seenIn.length >= PROMOTE_AT;
|
|
142
|
+
if (promoted) {
|
|
143
|
+
file.preferences[fullKey] = { value, confirmed_in: seenIn, updated_at: now };
|
|
144
|
+
}
|
|
145
|
+
writePrefsFile(path, file);
|
|
146
|
+
return promoted;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Record one confirmed brief answer. Always writes the project tier; feeds the
|
|
151
|
+
* user tier's sightings ledger and promotes once the same value has been
|
|
152
|
+
* confirmed in PROMOTE_AT distinct projects. Idempotent per project.
|
|
153
|
+
*/
|
|
154
|
+
export function recordPreference({ projectDir, key, value, workflow }) {
|
|
155
|
+
if (!PREFERENCE_KEYS.includes(key)) {
|
|
156
|
+
throw new Error(`unknown preference key: "${key}" (known: ${PREFERENCE_KEYS.join(", ")})`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
159
|
+
throw new Error("a preference needs a non-empty string value");
|
|
160
|
+
}
|
|
161
|
+
if (key === "style_preset" && (!workflow || !String(workflow).trim())) {
|
|
162
|
+
throw new Error("style_preset is stored per workflow — pass --workflow <w>");
|
|
163
|
+
}
|
|
164
|
+
const fullKey = preferenceKeyFor(key, workflow);
|
|
165
|
+
const projectName = basename(resolve(projectDir));
|
|
166
|
+
const trimmed = value.trim();
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
|
|
169
|
+
const confirmedIn = recordProjectTier(projectDir, fullKey, trimmed, projectName, now);
|
|
170
|
+
|
|
171
|
+
// Best-effort — a read-only home directory must never fail a brief.
|
|
172
|
+
let promoted = false;
|
|
173
|
+
try {
|
|
174
|
+
promoted = recordUserSighting(fullKey, trimmed, projectName, now);
|
|
175
|
+
} catch {
|
|
176
|
+
// The project record already landed; promotion just waits for next time.
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { key: fullKey, value: trimmed, confirmed_in: confirmedIn, promoted };
|
|
180
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
mergedPreferences,
|
|
8
|
+
preferenceKeyFor,
|
|
9
|
+
projectPrefsPath,
|
|
10
|
+
recordPreference,
|
|
11
|
+
userPrefsPath,
|
|
12
|
+
} from "./prefs-store.mjs";
|
|
13
|
+
|
|
14
|
+
function sandbox() {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), "mu-prefs-"));
|
|
16
|
+
const home = join(root, "home");
|
|
17
|
+
const projectA = join(root, "proj-a");
|
|
18
|
+
const projectB = join(root, "proj-b");
|
|
19
|
+
mkdirSync(home, { recursive: true });
|
|
20
|
+
mkdirSync(projectA, { recursive: true });
|
|
21
|
+
mkdirSync(projectB, { recursive: true });
|
|
22
|
+
process.env.HOME = home;
|
|
23
|
+
return { root, home, projectA, projectB };
|
|
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);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withSandbox(fn) {
|
|
32
|
+
const savedEnv = { ...process.env };
|
|
33
|
+
const box = sandbox();
|
|
34
|
+
try {
|
|
35
|
+
fn(box);
|
|
36
|
+
} finally {
|
|
37
|
+
restoreEnv(savedEnv);
|
|
38
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("record writes the project tier and get merges it as source=project", () => {
|
|
43
|
+
withSandbox(({ projectA }) => {
|
|
44
|
+
const result = recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
45
|
+
assert.equal(result.promoted, false);
|
|
46
|
+
assert.deepEqual(result.confirmed_in, ["proj-a"]);
|
|
47
|
+
|
|
48
|
+
const merged = mergedPreferences(projectA);
|
|
49
|
+
assert.equal(merged.destination.value, "x-feed");
|
|
50
|
+
assert.equal(merged.destination.source, "project");
|
|
51
|
+
|
|
52
|
+
const onDisk = JSON.parse(readFileSync(projectPrefsPath(projectA), "utf8"));
|
|
53
|
+
assert.equal(onDisk.preferences.destination.value, "x-feed");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("one project never promotes: a fresh project sees nothing user-tier", () => {
|
|
58
|
+
withSandbox(({ projectA, projectB }) => {
|
|
59
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
60
|
+
// proj-b has no project entry, and the value was only confirmed once.
|
|
61
|
+
assert.equal(mergedPreferences(projectB).destination, undefined);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the same value confirmed in two projects promotes to the user tier", () => {
|
|
66
|
+
withSandbox(({ projectA, projectB }) => {
|
|
67
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
68
|
+
const second = recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
|
|
69
|
+
assert.equal(second.promoted, true);
|
|
70
|
+
|
|
71
|
+
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
|
|
72
|
+
assert.deepEqual(userFile.preferences.destination.confirmed_in.sort(), ["proj-a", "proj-b"]);
|
|
73
|
+
|
|
74
|
+
// A third project with no local entry now inherits the promoted default.
|
|
75
|
+
const projectC = join(projectA, "..", "proj-c");
|
|
76
|
+
mkdirSync(projectC, { recursive: true });
|
|
77
|
+
const merged = mergedPreferences(projectC);
|
|
78
|
+
assert.equal(merged.destination.value, "x-feed");
|
|
79
|
+
assert.equal(merged.destination.source, "user");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("record is idempotent per project — confirmed_in stays deduped", () => {
|
|
84
|
+
withSandbox(({ projectA }) => {
|
|
85
|
+
recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
86
|
+
const again = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
87
|
+
assert.deepEqual(again.confirmed_in, ["proj-a"]);
|
|
88
|
+
assert.equal(again.promoted, false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a changed value restarts provenance instead of inheriting confirmations", () => {
|
|
93
|
+
withSandbox(({ projectA, projectB }) => {
|
|
94
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
95
|
+
recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
|
|
96
|
+
const switched = recordPreference({
|
|
97
|
+
projectDir: projectA,
|
|
98
|
+
key: "destination",
|
|
99
|
+
value: "youtube",
|
|
100
|
+
});
|
|
101
|
+
assert.deepEqual(switched.confirmed_in, ["proj-a"]);
|
|
102
|
+
assert.equal(switched.promoted, false);
|
|
103
|
+
// The previously promoted value keeps serving other projects until youtube
|
|
104
|
+
// earns its own two confirmations.
|
|
105
|
+
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
|
|
106
|
+
assert.equal(userFile.preferences.destination.value, "x-feed");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("project tier overrides a promoted user-tier value", () => {
|
|
111
|
+
withSandbox(({ projectA, projectB }) => {
|
|
112
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
|
|
113
|
+
recordPreference({ projectDir: projectB, key: "storyboard", value: "yes" });
|
|
114
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "no" });
|
|
115
|
+
const merged = mergedPreferences(projectA);
|
|
116
|
+
assert.equal(merged.storyboard.value, "no");
|
|
117
|
+
assert.equal(merged.storyboard.source, "project");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the retired mode key is rejected", () => {
|
|
122
|
+
withSandbox(({ projectA }) => {
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => recordPreference({ projectDir: projectA, key: "mode", value: "collaborative" }),
|
|
125
|
+
/unknown preference key/,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the run-shape keys record and promote like any field", () => {
|
|
131
|
+
withSandbox(({ projectA, projectB }) => {
|
|
132
|
+
recordPreference({ projectDir: projectA, key: "flow", value: "automation" });
|
|
133
|
+
const promoted = recordPreference({ projectDir: projectB, key: "flow", value: "automation" });
|
|
134
|
+
assert.equal(promoted.promoted, true);
|
|
135
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
|
|
136
|
+
const merged = mergedPreferences(projectA);
|
|
137
|
+
assert.equal(merged.flow.value, "automation");
|
|
138
|
+
assert.equal(merged.storyboard.value, "yes");
|
|
139
|
+
assert.equal(merged.storyboard.source, "project");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("style_preset is keyed per workflow", () => {
|
|
144
|
+
withSandbox(({ projectA }) => {
|
|
145
|
+
assert.equal(
|
|
146
|
+
preferenceKeyFor("style_preset", "faceless-explainer"),
|
|
147
|
+
"style_preset.faceless-explainer",
|
|
148
|
+
);
|
|
149
|
+
assert.equal(preferenceKeyFor("destination", "faceless-explainer"), "destination");
|
|
150
|
+
recordPreference({
|
|
151
|
+
projectDir: projectA,
|
|
152
|
+
key: "style_preset",
|
|
153
|
+
value: "pin-and-paper",
|
|
154
|
+
workflow: "faceless-explainer",
|
|
155
|
+
});
|
|
156
|
+
const merged = mergedPreferences(projectA);
|
|
157
|
+
assert.equal(merged["style_preset.faceless-explainer"].value, "pin-and-paper");
|
|
158
|
+
assert.equal(merged["style_preset"], undefined);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("style_preset without a workflow is rejected, never stored bare", () => {
|
|
163
|
+
withSandbox(({ projectA }) => {
|
|
164
|
+
assert.throws(
|
|
165
|
+
() => recordPreference({ projectDir: projectA, key: "style_preset", value: "pin-and-paper" }),
|
|
166
|
+
/pass --workflow/,
|
|
167
|
+
);
|
|
168
|
+
assert.deepEqual(mergedPreferences(projectA), {});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("unknown keys and empty values are rejected", () => {
|
|
173
|
+
withSandbox(({ projectA }) => {
|
|
174
|
+
assert.throws(() => recordPreference({ projectDir: projectA, key: "vibe", value: "x" }));
|
|
175
|
+
assert.throws(() => recordPreference({ projectDir: projectA, key: "voice", value: " " }));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("corrupt files are treated as empty instead of crashing", () => {
|
|
180
|
+
withSandbox(({ projectA, home }) => {
|
|
181
|
+
mkdirSync(join(projectA, ".media"), { recursive: true });
|
|
182
|
+
writeFileSync(projectPrefsPath(projectA), "not json");
|
|
183
|
+
mkdirSync(join(home, ".media"), { recursive: true });
|
|
184
|
+
writeFileSync(userPrefsPath(), '{"preferences": 42}');
|
|
185
|
+
assert.deepEqual(mergedPreferences(projectA), {});
|
|
186
|
+
const result = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
187
|
+
assert.equal(result.value, "zh");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
|
|
5
|
+
|
|
6
|
+
export function probe(filePath) {
|
|
7
|
+
const ext = extname(filePath).toLowerCase();
|
|
8
|
+
if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
|
|
12
|
+
// can't break out of the quoting — filePath is passed as a literal argv entry.
|
|
13
|
+
const raw = execFileSync(
|
|
14
|
+
"ffprobe",
|
|
15
|
+
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],
|
|
16
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
17
|
+
);
|
|
18
|
+
const info = JSON.parse(raw);
|
|
19
|
+
const stream = info.streams?.[0];
|
|
20
|
+
const format = info.format;
|
|
21
|
+
|
|
22
|
+
const isImage = IMAGE_EXT.has(ext);
|
|
23
|
+
const duration = isImage
|
|
24
|
+
? null
|
|
25
|
+
: parseFloat(format?.duration) || parseFloat(stream?.duration) || null;
|
|
26
|
+
const width = parseInt(stream?.width, 10) || null;
|
|
27
|
+
const height = parseInt(stream?.height, 10) || null;
|
|
28
|
+
const codec = stream?.codec_name || null;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
duration: duration != null ? Math.round(duration * 10) / 10 : null,
|
|
32
|
+
width,
|
|
33
|
+
height,
|
|
34
|
+
codec,
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return { duration: null, width: null, height: null, codec: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { probe } from "./probe.mjs";
|
|
7
|
+
|
|
8
|
+
// Regression for the shell-injection fix: probe() must pass the path as a literal
|
|
9
|
+
// argv entry, never through a shell. A filename containing shell metacharacters
|
|
10
|
+
// must NOT execute. Under the old execSync(`ffprobe ... "${path}"`) the embedded
|
|
11
|
+
// `touch` ran and created the marker; under execFileSync it cannot, regardless of
|
|
12
|
+
// whether ffprobe is installed (the injected command never reaches a shell).
|
|
13
|
+
test("probe does not execute shell metacharacters in a filename", () => {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-inject-"));
|
|
15
|
+
const marker = join(dir, "INJECTED");
|
|
16
|
+
// Slash-free basename (a real on-disk filename) that breaks out of the old
|
|
17
|
+
// double-quoted interpolation and would `touch INJECTED` in the cwd.
|
|
18
|
+
const evil = join(dir, `clip"; touch INJECTED; echo ".mp4`);
|
|
19
|
+
const prevCwd = process.cwd();
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(evil, "not real media");
|
|
22
|
+
process.chdir(dir); // so a leaked `touch INJECTED` would land next to `marker`
|
|
23
|
+
const meta = probe(evil);
|
|
24
|
+
assert.equal(existsSync(marker), false, "injected `touch` must not have run");
|
|
25
|
+
// Bogus/unreadable media still returns the null-shaped result, never throws.
|
|
26
|
+
assert.deepEqual(Object.keys(meta).sort(), ["codec", "duration", "height", "width"]);
|
|
27
|
+
} finally {
|
|
28
|
+
process.chdir(prevCwd);
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Back-compat surface for the v1 provider API. The ordered, capability-based
|
|
2
|
+
// registry now lives in registry.mjs; this re-exports the v1 helpers so existing
|
|
3
|
+
// callers keep working. New code should import from registry.mjs directly
|
|
4
|
+
// (getProviders / runCapability).
|
|
5
|
+
export { getProvider, listTypes } from "./registry.mjs";
|