dsh-hyperframes 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.en.md +4 -3
- package/README.md +4 -3
- package/docs/WORKBENCH.md +6 -0
- package/lib/client.js +40 -2
- package/lib/media-templates.d.ts +1 -1
- package/lib/media-templates.js +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/NOTICE.md +1 -1
- package/skills/UPSTREAM.json +93 -8
- package/skills/embedded-captions/SKILL.md +1 -1
- package/skills/faceless-explainer/SKILL.md +1 -1
- package/skills/general-video/SKILL.md +1 -1
- package/skills/hyperframes-animation/SKILL.md +1 -1
- package/skills/hyperframes-audio/SKILL.md +1 -1
- package/skills/hyperframes-core/SKILL.md +1 -1
- package/skills/hyperframes-creative/SKILL.md +1 -1
- package/skills/hyperframes-keyframes/SKILL.md +1 -1
- package/skills/hyperframes-studio/SKILL.md +1 -1
- package/skills/media-use/scripts/lib/adopt.mjs +128 -0
- package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
- package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
- package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
- package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
- package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
- package/skills/media-use/scripts/lib/cache.mjs +162 -0
- package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
- package/skills/media-use/scripts/lib/candidates.mjs +91 -0
- package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
- package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
- package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
- package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
- package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
- package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
- package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
- package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
- package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
- package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
- package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
- package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
- package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
- package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
- package/skills/media-use/scripts/lib/freeze.mjs +72 -0
- package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
- package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
- package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
- package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
- package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
- package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
- package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
- package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
- package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
- package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
- package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
- package/skills/media-use/scripts/lib/local-models.mjs +318 -0
- package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
- package/skills/media-use/scripts/lib/local-run.mjs +78 -0
- package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
- package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
- package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
- package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
- package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
- package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
- package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
- package/skills/media-use/scripts/lib/main-module.mjs +13 -0
- package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
- package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
- package/skills/media-use/scripts/lib/match.mjs +47 -0
- package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
- package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
- package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
- package/skills/media-use/scripts/lib/media-search.mjs +107 -0
- package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
- package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
- package/skills/media-use/scripts/lib/misses.mjs +49 -0
- package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
- package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
- package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
- package/skills/media-use/scripts/lib/openverse.mjs +134 -0
- package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
- package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
- package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
- package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
- package/skills/media-use/scripts/lib/probe.mjs +46 -0
- package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
- package/skills/media-use/scripts/lib/providers.mjs +5 -0
- package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
- package/skills/media-use/scripts/lib/registry.mjs +214 -0
- package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
- package/skills/media-use/scripts/lib/search.mjs +24 -0
- package/skills/media-use/scripts/lib/search.test.mjs +32 -0
- package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
- package/skills/media-use/scripts/lib/specs.mjs +132 -0
- package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
- package/skills/media-use/scripts/lib/stats.mjs +117 -0
- package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
- package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
- package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
- package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
- package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
- package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
- package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
- package/skills/media-use/scripts/lib/usage.mjs +57 -0
- package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
- package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
- package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
- package/skills/media-use/scripts/resolve.mjs +1360 -27
- package/skills/motion-graphics/SKILL.md +1 -1
- package/skills/music-to-video/SKILL.md +1 -1
- package/skills/pr-to-video/SKILL.md +1 -1
- package/skills/product-launch-video/SKILL.md +1 -1
- package/skills/talking-head-recut/SKILL.md +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { globalMediaDir } from "./media-home.mjs";
|
|
4
|
+
|
|
5
|
+
const MISSES_FILE = "misses.jsonl";
|
|
6
|
+
|
|
7
|
+
function missesPath() {
|
|
8
|
+
return join(globalMediaDir(), MISSES_FILE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function recordMiss({ type, intent, provider_override, local_only }) {
|
|
12
|
+
try {
|
|
13
|
+
const path = missesPath();
|
|
14
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
15
|
+
appendFileSync(
|
|
16
|
+
path,
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
ts: new Date().toISOString(),
|
|
19
|
+
type,
|
|
20
|
+
intent,
|
|
21
|
+
provider_override: !!provider_override,
|
|
22
|
+
local_only: !!local_only,
|
|
23
|
+
}) + "\n",
|
|
24
|
+
);
|
|
25
|
+
} catch {
|
|
26
|
+
// local miss logging is best-effort; never surface into resolve
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readMisses() {
|
|
31
|
+
const p = missesPath();
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(p)) return [];
|
|
34
|
+
const raw = readFileSync(p, "utf8");
|
|
35
|
+
const records = [];
|
|
36
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) continue;
|
|
39
|
+
try {
|
|
40
|
+
records.push(JSON.parse(trimmed));
|
|
41
|
+
} catch {
|
|
42
|
+
// skip malformed local lines, don't crash stats
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return records;
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readMisses, recordMiss } from "./misses.mjs";
|
|
7
|
+
|
|
8
|
+
function sandbox() {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), "mu-misses-"));
|
|
10
|
+
const home = join(root, "home");
|
|
11
|
+
mkdirSync(home, { recursive: true });
|
|
12
|
+
process.env.HYPERFRAMES_MEDIA_HOME = home;
|
|
13
|
+
return { root, home };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function restoreEnv(saved) {
|
|
17
|
+
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
|
18
|
+
Object.assign(process.env, saved);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("recordMiss appends a well-formed local miss", () => {
|
|
22
|
+
const savedEnv = { ...process.env };
|
|
23
|
+
const { root, home } = sandbox();
|
|
24
|
+
try {
|
|
25
|
+
recordMiss({ type: "bgm", intent: "moody synth pulse", provider_override: true });
|
|
26
|
+
const misses = readMisses();
|
|
27
|
+
assert.equal(misses.length, 1);
|
|
28
|
+
assert.equal(misses[0].type, "bgm");
|
|
29
|
+
assert.equal(misses[0].intent, "moody synth pulse");
|
|
30
|
+
assert.equal(misses[0].provider_override, true);
|
|
31
|
+
assert.equal(misses[0].local_only, false);
|
|
32
|
+
assert.ok(!Number.isNaN(Date.parse(misses[0].ts)));
|
|
33
|
+
|
|
34
|
+
const raw = readFileSync(join(home, ".media/misses.jsonl"), "utf8");
|
|
35
|
+
assert.match(raw, /moody synth pulse/);
|
|
36
|
+
} finally {
|
|
37
|
+
restoreEnv(savedEnv);
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("recordMiss swallows filesystem failures", () => {
|
|
43
|
+
const savedEnv = { ...process.env };
|
|
44
|
+
const { root, home } = sandbox();
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(join(home, ".media"), "not a directory");
|
|
47
|
+
assert.doesNotThrow(() =>
|
|
48
|
+
recordMiss({ type: "image", intent: "unwritable", local_only: true }),
|
|
49
|
+
);
|
|
50
|
+
assert.equal(existsSync(join(home, ".media/misses.jsonl")), false);
|
|
51
|
+
} finally {
|
|
52
|
+
restoreEnv(savedEnv);
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("recordMiss stays best-effort when the media home cannot be resolved", () => {
|
|
58
|
+
const savedEnv = { ...process.env };
|
|
59
|
+
delete process.env.HYPERFRAMES_MEDIA_HOME;
|
|
60
|
+
process.env.HYPERFRAMES_MEDIA_HOME_REQUIRED = "1";
|
|
61
|
+
try {
|
|
62
|
+
assert.doesNotThrow(() => recordMiss({ type: "sfx", intent: "no home", local_only: true }));
|
|
63
|
+
} finally {
|
|
64
|
+
restoreEnv(savedEnv);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("readMisses skips corrupt lines", () => {
|
|
69
|
+
const savedEnv = { ...process.env };
|
|
70
|
+
const { root, home } = sandbox();
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(join(home, ".media"), { recursive: true });
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(home, ".media/misses.jsonl"),
|
|
75
|
+
[
|
|
76
|
+
JSON.stringify({ ts: "2026-07-09T00:00:00.000Z", type: "bgm", intent: "one" }),
|
|
77
|
+
"{not json",
|
|
78
|
+
JSON.stringify({ ts: "2026-07-09T00:00:01.000Z", type: "sfx", intent: "two" }),
|
|
79
|
+
].join("\n"),
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
readMisses().map((miss) => miss.intent),
|
|
83
|
+
["one", "two"],
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
restoreEnv(savedEnv);
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
function resolveNpxCliPath(env, pathExists) {
|
|
5
|
-
const npmExecPath = env.npm_execpath;
|
|
6
|
-
const nodeExecPath = env.npm_node_execpath || process.execPath;
|
|
7
|
-
if (npmExecPath) {
|
|
8
|
-
const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
|
|
9
|
-
const candidate =
|
|
10
|
-
fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js");
|
|
11
|
-
if (pathExists(candidate)) return candidate;
|
|
12
|
-
}
|
|
13
|
-
const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js");
|
|
14
|
-
return pathExists(besideNode) ? besideNode : null;
|
|
15
|
-
}
|
|
2
|
+
import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
|
|
16
3
|
|
|
17
4
|
// Sync-spawn analog of the audio engine's spawnP, for execFileSync call sites
|
|
18
5
|
// that must hard-fail (rather than fall through to another provider) when npx
|
|
@@ -31,20 +18,7 @@ export function resolveNpxInvocation(
|
|
|
31
18
|
env = process.env,
|
|
32
19
|
pathExists = existsSync,
|
|
33
20
|
) {
|
|
34
|
-
const resolved =
|
|
35
|
-
platform !== "win32"
|
|
36
|
-
? { cmd: "npx", args: argv, opts: { stdio: "ignore", ...opts } }
|
|
37
|
-
: (() => {
|
|
38
|
-
const nodeExecPath = env.npm_node_execpath || process.execPath;
|
|
39
|
-
const npxCliPath = resolveNpxCliPath(env, pathExists);
|
|
40
|
-
return npxCliPath
|
|
41
|
-
? {
|
|
42
|
-
cmd: nodeExecPath,
|
|
43
|
-
args: [npxCliPath, ...argv.map((arg) => String(arg))],
|
|
44
|
-
opts: { stdio: "ignore", windowsHide: true, ...opts },
|
|
45
|
-
}
|
|
46
|
-
: null;
|
|
47
|
-
})();
|
|
21
|
+
const resolved = resolveSpawnCommand("npx", argv, opts, platform, env, pathExists);
|
|
48
22
|
if (!resolved) {
|
|
49
23
|
// npx-on-win32 with no resolvable npx-cli.js — same terminal condition
|
|
50
24
|
// spawnP warns about, surfaced as a throw for callers with no fallback.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveNpxInvocation } from "./npx-sync.mjs";
|
|
4
|
+
|
|
5
|
+
// Coverage parity with tts-local-provider.test.mjs, for the whisper.cpp
|
|
6
|
+
// fallback's call-site shape (transcribe.mjs runWhisper): the same three
|
|
7
|
+
// branches, but with the hard-fail contract — no fallback provider exists,
|
|
8
|
+
// so an unresolvable npx must throw actionably instead of returning null.
|
|
9
|
+
|
|
10
|
+
const envWithNpxCli = {
|
|
11
|
+
npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js",
|
|
12
|
+
npm_node_execpath: "C:/Program Files/nodejs/node.exe",
|
|
13
|
+
};
|
|
14
|
+
const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
|
|
15
|
+
const pathExists = (path) => path === npxCliPath;
|
|
16
|
+
|
|
17
|
+
const WHISPER_ARGV = ["hyperframes", "transcribe", "C:\\media\\input.wav", "--dir", "C:\\work"];
|
|
18
|
+
const WHISPER_OPTS = { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 };
|
|
19
|
+
|
|
20
|
+
test("win32: routes the whisper fallback through node + npx-cli, never bare npx", () => {
|
|
21
|
+
const resolved = resolveNpxInvocation(
|
|
22
|
+
WHISPER_ARGV,
|
|
23
|
+
WHISPER_OPTS,
|
|
24
|
+
"win32",
|
|
25
|
+
envWithNpxCli,
|
|
26
|
+
pathExists,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
|
|
30
|
+
assert.equal(resolved.args[0], npxCliPath);
|
|
31
|
+
assert.deepEqual(resolved.args.slice(1, 3), ["hyperframes", "transcribe"]);
|
|
32
|
+
// execFileSync options survive the rerouting (the timeout bounds the
|
|
33
|
+
// whisper build/model download; the pipes surface its errors).
|
|
34
|
+
assert.deepEqual(resolved.opts.stdio, ["ignore", "pipe", "pipe"]);
|
|
35
|
+
assert.equal(resolved.opts.timeout, 1_800_000);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("win32 without a resolvable npx-cli: throws the actionable install hint", () => {
|
|
39
|
+
assert.throws(
|
|
40
|
+
() => resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "win32", {}, () => false),
|
|
41
|
+
/npx-cli\.js was not found/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("non-win32: spawns plain npx unchanged", () => {
|
|
46
|
+
const resolved = resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "darwin", {}, () => false);
|
|
47
|
+
|
|
48
|
+
assert.equal(resolved.cmd, "npx");
|
|
49
|
+
assert.deepEqual(resolved.args, WHISPER_ARGV);
|
|
50
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Sound effects from Openverse (https://api.openverse.org): free, no key.
|
|
2
|
+
// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
|
|
3
|
+
// back to the bundled library when it is unavailable.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
|
|
9
|
+
// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
|
|
10
|
+
export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
|
|
11
|
+
const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
|
|
12
|
+
const MAX_SFX_MS = 15000;
|
|
13
|
+
const IDEAL_SFX_MS = 4000;
|
|
14
|
+
const BY_PENALTY = 2;
|
|
15
|
+
const SEARCH_TIMEOUT_MS = 10000;
|
|
16
|
+
|
|
17
|
+
// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
|
|
18
|
+
const oneLine = (text) =>
|
|
19
|
+
String(text ?? "")
|
|
20
|
+
.replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, 300);
|
|
23
|
+
// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
|
|
24
|
+
const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
|
|
25
|
+
function httpsUrl(url, hosts) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
|
|
29
|
+
? oneLine(parsed.href)
|
|
30
|
+
: "";
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
|
|
37
|
+
export class OpenverseUnavailableError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "OpenverseUnavailableError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usable(result) {
|
|
45
|
+
return (
|
|
46
|
+
OPENVERSE_SFX_LICENSES.has(result?.license) &&
|
|
47
|
+
PLAYABLE_FILETYPES.has(result.filetype) &&
|
|
48
|
+
typeof result.url === "string" &&
|
|
49
|
+
result.url.startsWith("https://") &&
|
|
50
|
+
Number.isFinite(result.duration) &&
|
|
51
|
+
result.duration > 0 &&
|
|
52
|
+
result.duration <= MAX_SFX_MS
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
|
|
57
|
+
function penalty(result, position) {
|
|
58
|
+
const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
|
|
59
|
+
return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Best usable sound in Openverse's relevance-ordered results, or null. */
|
|
63
|
+
export function pickOpenverseSfx(results) {
|
|
64
|
+
let best = null;
|
|
65
|
+
results.forEach((result, position) => {
|
|
66
|
+
if (!usable(result)) return;
|
|
67
|
+
const score = penalty(result, position);
|
|
68
|
+
if (!best || score < best.score) best = { result, score };
|
|
69
|
+
});
|
|
70
|
+
if (!best) return null;
|
|
71
|
+
const r = best.result;
|
|
72
|
+
return {
|
|
73
|
+
id: r.id,
|
|
74
|
+
url: r.url,
|
|
75
|
+
ext: `.${r.filetype}`,
|
|
76
|
+
title: oneLine(r.title),
|
|
77
|
+
creator: oneLine(r.creator),
|
|
78
|
+
license: r.license,
|
|
79
|
+
licenseUrl: httpsUrl(r.license_url),
|
|
80
|
+
sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
|
|
81
|
+
credit: oneLine(r.attribution),
|
|
82
|
+
durationS: Math.round(r.duration) / 1000,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
|
|
87
|
+
export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
|
|
88
|
+
const params = new URLSearchParams({
|
|
89
|
+
q: query,
|
|
90
|
+
page_size: "20",
|
|
91
|
+
license: [...OPENVERSE_SFX_LICENSES].join(","),
|
|
92
|
+
});
|
|
93
|
+
let response;
|
|
94
|
+
try {
|
|
95
|
+
response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
|
|
96
|
+
headers: { Accept: "application/json" },
|
|
97
|
+
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 429)
|
|
103
|
+
throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
|
|
104
|
+
if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
|
|
105
|
+
let body;
|
|
106
|
+
try {
|
|
107
|
+
body = await response.json();
|
|
108
|
+
} catch {
|
|
109
|
+
throw new OpenverseUnavailableError("Openverse returned non-JSON");
|
|
110
|
+
}
|
|
111
|
+
return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
|
|
115
|
+
export function writeSfxCredit(creditsPath, file, sound) {
|
|
116
|
+
if (sound.license !== "by") return;
|
|
117
|
+
// The sound may come from a saved record, not this run's pick: clean it again here.
|
|
118
|
+
const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
|
|
119
|
+
const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
|
|
120
|
+
const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
|
|
121
|
+
let prior = "";
|
|
122
|
+
try {
|
|
123
|
+
prior = readFileSync(creditsPath, "utf8");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code !== "ENOENT") throw error;
|
|
126
|
+
}
|
|
127
|
+
const kept = prior
|
|
128
|
+
.split("\n")
|
|
129
|
+
.filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
|
|
130
|
+
const header =
|
|
131
|
+
"# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
|
|
132
|
+
mkdirSync(dirname(creditsPath), { recursive: true });
|
|
133
|
+
writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);
|
|
134
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
OpenverseUnavailableError,
|
|
8
|
+
pickOpenverseSfx,
|
|
9
|
+
searchOpenverseSfx,
|
|
10
|
+
writeSfxCredit,
|
|
11
|
+
} from "./openverse.mjs";
|
|
12
|
+
|
|
13
|
+
const result = (overrides = {}) => ({
|
|
14
|
+
id: "ov-1",
|
|
15
|
+
title: "Deep Whoosh",
|
|
16
|
+
creator: "Kinoton",
|
|
17
|
+
license: "cc0",
|
|
18
|
+
license_url: "https://creativecommons.org/publicdomain/zero/1.0/",
|
|
19
|
+
foreign_landing_url: "https://freesound.org/people/Kinoton/sounds/351256",
|
|
20
|
+
url: "https://cdn.freesound.org/previews/351/351256_2247456-hq.mp3",
|
|
21
|
+
filetype: "mp3",
|
|
22
|
+
duration: 1200,
|
|
23
|
+
attribution: '"Deep Whoosh" by Kinoton is marked with CC0 1.0.',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("only CC0 and CC BY sounds are ever picked", () => {
|
|
28
|
+
for (const license of ["by-nc", "by-sa", "by-nd", "by-nc-sa", "by-nc-nd", "pdm", "sampling+"])
|
|
29
|
+
assert.equal(pickOpenverseSfx([result({ license })]), null, license);
|
|
30
|
+
assert.equal(pickOpenverseSfx([result({ license: "by" })]).license, "by");
|
|
31
|
+
assert.equal(pickOpenverseSfx([result({ license: "cc0" })]).license, "cc0");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("sounds too long for a cue, or not playable, are skipped", () => {
|
|
35
|
+
assert.equal(pickOpenverseSfx([result({ duration: 15001 })]), null);
|
|
36
|
+
assert.equal(pickOpenverseSfx([result({ duration: null })]), null);
|
|
37
|
+
assert.equal(pickOpenverseSfx([result({ filetype: "aiff" })]), null);
|
|
38
|
+
assert.equal(pickOpenverseSfx([result({ url: "http://insecure.test/a.mp3" })]), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("ranking keeps relevance order, then prefers short clips and CC0 over CC BY", () => {
|
|
42
|
+
const pick = (results) => pickOpenverseSfx(results).id;
|
|
43
|
+
assert.equal(pick([result({ id: "first" }), result({ id: "second" })]), "first");
|
|
44
|
+
assert.equal(pick([result({ id: "long", duration: 12000 }), result({ id: "short" })]), "short");
|
|
45
|
+
assert.equal(pick([result({ id: "by", license: "by" }), result({ id: "cc0" })]), "cc0");
|
|
46
|
+
assert.equal(
|
|
47
|
+
pick([
|
|
48
|
+
result({ id: "by", license: "by" }),
|
|
49
|
+
...Array(3).fill(result({ license: "by-nc" })),
|
|
50
|
+
result({ id: "cc0" }),
|
|
51
|
+
]),
|
|
52
|
+
"by",
|
|
53
|
+
"a much more relevant CC BY sound still wins",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a pick carries what the project needs to play and credit it", () => {
|
|
58
|
+
assert.deepEqual(pickOpenverseSfx([result({ license: "by", filetype: "wav", duration: 450 })]), {
|
|
59
|
+
id: "ov-1",
|
|
60
|
+
url: "https://cdn.freesound.org/previews/351/351256_2247456-hq.mp3",
|
|
61
|
+
ext: ".wav",
|
|
62
|
+
title: "Deep Whoosh",
|
|
63
|
+
creator: "Kinoton",
|
|
64
|
+
license: "by",
|
|
65
|
+
licenseUrl: "https://creativecommons.org/publicdomain/zero/1.0/",
|
|
66
|
+
sourceUrl: "https://freesound.org/people/Kinoton/sounds/351256",
|
|
67
|
+
credit: '"Deep Whoosh" by Kinoton is marked with CC0 1.0.',
|
|
68
|
+
durationS: 0.45,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Openverse text is kept to one bounded line, and only https links survive", () => {
|
|
73
|
+
const pick = pickOpenverseSfx([
|
|
74
|
+
result({
|
|
75
|
+
license: "by",
|
|
76
|
+
title: "Pop\n# Heading",
|
|
77
|
+
attribution: `"Pop" by Ana.\n- \`assets/sfx/other.mp3\`: forged credit\r\u0007${"x".repeat(400)}`,
|
|
78
|
+
foreign_landing_url: "javascript:alert(1)",
|
|
79
|
+
license_url: "http://creativecommons.org/licenses/by/4.0/",
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
assert.equal(pick.title, "Pop # Heading");
|
|
83
|
+
assert.equal(pick.credit.length, 300);
|
|
84
|
+
assert.ok(!/\p{Cc}/u.test(pick.credit), "no line breaks or control characters");
|
|
85
|
+
assert.equal(pick.sourceUrl, "");
|
|
86
|
+
assert.equal(pick.licenseUrl, "");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("search asks Openverse for JSON and only the allowed licences", async () => {
|
|
90
|
+
const calls = [];
|
|
91
|
+
const sound = await searchOpenverseSfx("ui click", {
|
|
92
|
+
fetchImpl: async (url, init) => {
|
|
93
|
+
calls.push({ url: new URL(url), init });
|
|
94
|
+
return Response.json({ results: [result({ license: "by-nc" }), result({ id: "ok" })] });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
assert.equal(sound.id, "ok", "the licence field is checked even on a filtered response");
|
|
98
|
+
assert.equal(calls.length, 1);
|
|
99
|
+
assert.equal(calls[0].url.origin + calls[0].url.pathname, "https://api.openverse.org/v1/audio/");
|
|
100
|
+
assert.equal(calls[0].url.searchParams.get("q"), "ui click");
|
|
101
|
+
assert.equal(calls[0].url.searchParams.get("license"), "cc0,by");
|
|
102
|
+
assert.equal(calls[0].init.headers.Accept, "application/json");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("no usable result is a miss, not an error", async () => {
|
|
106
|
+
const fetchImpl = async () => Response.json({ results: [] });
|
|
107
|
+
assert.equal(await searchOpenverseSfx("nothing", { fetchImpl }), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("rate limits, server errors, bad bodies and network failures make Openverse unavailable", async () => {
|
|
111
|
+
const failures = [
|
|
112
|
+
[async () => new Response("slow down", { status: 429 }), /rate limit/],
|
|
113
|
+
[async () => new Response("down", { status: 503 }), /HTTP 503/],
|
|
114
|
+
[async () => new Response("<html>"), /non-JSON/],
|
|
115
|
+
[
|
|
116
|
+
async () => {
|
|
117
|
+
throw new TypeError("fetch failed");
|
|
118
|
+
},
|
|
119
|
+
/unreachable \(fetch failed\)/,
|
|
120
|
+
],
|
|
121
|
+
];
|
|
122
|
+
for (const [fetchImpl, message] of failures)
|
|
123
|
+
await assert.rejects(searchOpenverseSfx("whoosh", { fetchImpl }), (error) => {
|
|
124
|
+
assert.ok(error instanceof OpenverseUnavailableError);
|
|
125
|
+
assert.match(error.message, message);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("credits list each CC BY file once and leave CC0 out", () => {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
|
|
132
|
+
try {
|
|
133
|
+
const path = join(dir, "assets", "sfx", "CREDITS.md");
|
|
134
|
+
const by = {
|
|
135
|
+
license: "by",
|
|
136
|
+
credit: '"Pop" by Ana is licensed under CC BY 4.0.',
|
|
137
|
+
sourceUrl: "https://freesound.org/s/1",
|
|
138
|
+
};
|
|
139
|
+
writeSfxCredit(path, "assets/sfx/zap.mp3", { ...by, license: "cc0" });
|
|
140
|
+
assert.throws(() => readFileSync(path), /ENOENT/);
|
|
141
|
+
writeSfxCredit(path, "assets/sfx/pop.mp3", by);
|
|
142
|
+
writeSfxCredit(path, "assets/sfx/pop.mp3", {
|
|
143
|
+
...by,
|
|
144
|
+
credit: '"Pop 2" by Ana is licensed under CC BY 4.0.',
|
|
145
|
+
});
|
|
146
|
+
writeSfxCredit(path, "assets/sfx/ding.mp3", by);
|
|
147
|
+
const lines = readFileSync(path, "utf8")
|
|
148
|
+
.split("\n")
|
|
149
|
+
.filter((l) => l.startsWith("- "));
|
|
150
|
+
assert.deepEqual(lines, [
|
|
151
|
+
'- `assets/sfx/pop.mp3`: "Pop 2" by Ana is licensed under CC BY 4.0. Source: https://freesound.org/s/1',
|
|
152
|
+
'- `assets/sfx/ding.mp3`: "Pop" by Ana is licensed under CC BY 4.0. Source: https://freesound.org/s/1',
|
|
153
|
+
]);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a credit with no https source link is written without one", () => {
|
|
160
|
+
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
|
|
161
|
+
try {
|
|
162
|
+
const path = join(dir, "CREDITS.md");
|
|
163
|
+
writeSfxCredit(path, "assets/sfx/pop.mp3", { license: "by", credit: "c.", sourceUrl: "" });
|
|
164
|
+
assert.match(readFileSync(path, "utf8"), /\n- `assets\/sfx\/pop\.mp3`: c\.\n$/);
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a hostile credit is written as one safe line, with no link off Openverse's providers", () => {
|
|
171
|
+
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
|
|
172
|
+
try {
|
|
173
|
+
const path = join(dir, "CREDITS.md");
|
|
174
|
+
const hostile = `"Pop" by Ana.\n- \`assets/sfx/x.mp3\`: forged [click](https://evil.test) <img src=x> \\${"x".repeat(5000)}`;
|
|
175
|
+
for (const sourceUrl of [
|
|
176
|
+
"javascript:alert(1)",
|
|
177
|
+
"https://evil.test/a",
|
|
178
|
+
"https://freesound.org/s/1\n- forged",
|
|
179
|
+
]) {
|
|
180
|
+
writeSfxCredit(path, "assets/sfx/pop.mp3", { license: "by", credit: hostile, sourceUrl });
|
|
181
|
+
const lines = readFileSync(path, "utf8")
|
|
182
|
+
.split("\n")
|
|
183
|
+
.filter((l) => l.startsWith("- "));
|
|
184
|
+
assert.equal(lines.length, 1, sourceUrl);
|
|
185
|
+
assert.ok(
|
|
186
|
+
lines[0].startsWith('- `assets/sfx/pop.mp3`: "Pop" by Ana. - '),
|
|
187
|
+
lines[0].slice(0, 80),
|
|
188
|
+
);
|
|
189
|
+
assert.ok(
|
|
190
|
+
!/[\p{Cc}`[\]<>\\]/u.test(lines[0].slice("- `assets/sfx/pop.mp3`:".length)),
|
|
191
|
+
sourceUrl,
|
|
192
|
+
);
|
|
193
|
+
assert.ok(lines[0].length < 400, "bounded");
|
|
194
|
+
assert.doesNotMatch(lines[0], /javascript:|evil\.test\/a/);
|
|
195
|
+
}
|
|
196
|
+
assert.match(readFileSync(path, "utf8"), /Source: https:\/\/freesound\.org\/s\/1-%20forged\n$/);
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("earlier credit lines survive a new credit", () => {
|
|
203
|
+
const dir = mkdtempSync(join(tmpdir(), "openverse-credits-"));
|
|
204
|
+
try {
|
|
205
|
+
const path = join(dir, "CREDITS.md");
|
|
206
|
+
writeFileSync(path, "# Sound effect credits\n\n- `assets/sfx/old.mp3`: kept\n");
|
|
207
|
+
writeSfxCredit(path, "assets/sfx/new.mp3", {
|
|
208
|
+
license: "by",
|
|
209
|
+
credit: "c.",
|
|
210
|
+
sourceUrl: "https://freesound.org/s/2",
|
|
211
|
+
});
|
|
212
|
+
assert.match(
|
|
213
|
+
readFileSync(path, "utf8"),
|
|
214
|
+
/- `assets\/sfx\/old\.mp3`: kept\n- `assets\/sfx\/new\.mp3`: c\. Source: https:\/\/freesound\.org\/s\/2\n$/,
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
@@ -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
|
+
});
|