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,125 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { validateCube } from "./cube-validate.mjs";
|
|
8
|
+
|
|
9
|
+
const IDENTITY_2 = `
|
|
10
|
+
# comment
|
|
11
|
+
TITLE "Identity 2"
|
|
12
|
+
DOMAIN_MIN 0 0 0
|
|
13
|
+
DOMAIN_MAX 1 1 1
|
|
14
|
+
LUT_3D_SIZE 2
|
|
15
|
+
0 0 0
|
|
16
|
+
1 0 0
|
|
17
|
+
0 1 0
|
|
18
|
+
1 1 0
|
|
19
|
+
0 0 1
|
|
20
|
+
1 0 1
|
|
21
|
+
0 1 1
|
|
22
|
+
1 1 1
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
test("accepts a valid minimal 3D cube LUT", () => {
|
|
26
|
+
const result = validateCube(IDENTITY_2);
|
|
27
|
+
assert.deepEqual(result, { ok: true, size: 2 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects oversize LUTs with the core parser message", () => {
|
|
31
|
+
const result = validateCube("LUT_3D_SIZE 65", { maxSize: 64 });
|
|
32
|
+
assert.equal(result.ok, false);
|
|
33
|
+
assert.match(result.error, /LUT_3D_SIZE 65 exceeds max 64/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects data rows before LUT_3D_SIZE", () => {
|
|
37
|
+
const result = validateCube("0 0 0\nLUT_3D_SIZE 2");
|
|
38
|
+
assert.equal(result.ok, false);
|
|
39
|
+
assert.match(result.error, /LUT data appears before LUT_3D_SIZE/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects missing LUT_3D_SIZE", () => {
|
|
43
|
+
const result = validateCube('TITLE "No Size"');
|
|
44
|
+
assert.equal(result.ok, false);
|
|
45
|
+
assert.match(result.error, /Missing LUT_3D_SIZE/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects row count mismatches with the core parser message", () => {
|
|
49
|
+
const result = validateCube("LUT_3D_SIZE 2\n0 0 0");
|
|
50
|
+
assert.equal(result.ok, false);
|
|
51
|
+
assert.match(result.error, /Expected 8 LUT rows/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects inverted domains", () => {
|
|
55
|
+
const result = validateCube(`
|
|
56
|
+
DOMAIN_MIN 0 0 0
|
|
57
|
+
DOMAIN_MAX 1 0 1
|
|
58
|
+
LUT_3D_SIZE 2
|
|
59
|
+
0 0 0
|
|
60
|
+
1 0 0
|
|
61
|
+
0 1 0
|
|
62
|
+
1 1 0
|
|
63
|
+
0 0 1
|
|
64
|
+
1 0 1
|
|
65
|
+
0 1 1
|
|
66
|
+
1 1 1
|
|
67
|
+
`);
|
|
68
|
+
assert.equal(result.ok, false);
|
|
69
|
+
assert.match(result.error, /DOMAIN_MAX values must be greater than DOMAIN_MIN values/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects unsupported 1D and mixed cube LUTs", () => {
|
|
73
|
+
const oneD = validateCube("LUT_1D_SIZE 2\n0 0 0\n1 1 1");
|
|
74
|
+
assert.equal(oneD.ok, false);
|
|
75
|
+
assert.match(oneD.error, /1D cube LUTs are not supported yet/);
|
|
76
|
+
|
|
77
|
+
const mixed = validateCube(`
|
|
78
|
+
LUT_1D_SIZE 2
|
|
79
|
+
LUT_3D_SIZE 2
|
|
80
|
+
0 0 0
|
|
81
|
+
1 0 0
|
|
82
|
+
0 1 0
|
|
83
|
+
1 1 0
|
|
84
|
+
0 0 1
|
|
85
|
+
1 0 1
|
|
86
|
+
0 1 1
|
|
87
|
+
1 1 1
|
|
88
|
+
`);
|
|
89
|
+
assert.equal(mixed.ok, false);
|
|
90
|
+
assert.match(mixed.error, /Mixed 1D and 3D cube LUTs are not supported yet/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("CLI exits zero for valid files and non-zero for invalid files", () => {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-cube-validate-"));
|
|
95
|
+
try {
|
|
96
|
+
const valid = join(dir, "valid.cube");
|
|
97
|
+
const invalid = join(dir, "invalid.cube");
|
|
98
|
+
writeFileSync(valid, IDENTITY_2);
|
|
99
|
+
writeFileSync(invalid, "LUT_3D_SIZE 65");
|
|
100
|
+
|
|
101
|
+
const out = execFileSync(
|
|
102
|
+
process.execPath,
|
|
103
|
+
[new URL("./cube-validate.mjs", import.meta.url).pathname, valid],
|
|
104
|
+
{
|
|
105
|
+
encoding: "utf8",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
assert.match(out, /ok: LUT_3D_SIZE 2/);
|
|
109
|
+
|
|
110
|
+
assert.throws(
|
|
111
|
+
() =>
|
|
112
|
+
execFileSync(
|
|
113
|
+
process.execPath,
|
|
114
|
+
[new URL("./cube-validate.mjs", import.meta.url).pathname, invalid],
|
|
115
|
+
{
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
stdio: "pipe",
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 64"),
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { compileCutList } from "./cutlist.mjs";
|
|
9
|
+
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// These legacy wrappers remain under skills/media-use/scripts because the
|
|
12
|
+
// skill's public audio entrypoints still invoke them; the moved engine tests
|
|
13
|
+
// their implementation here while preserving that compatibility surface.
|
|
14
|
+
const SCRIPT = join(
|
|
15
|
+
HERE,
|
|
16
|
+
"..",
|
|
17
|
+
"..",
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
"..",
|
|
21
|
+
"skills",
|
|
22
|
+
"media-use",
|
|
23
|
+
"scripts",
|
|
24
|
+
"transcript-cut.mjs",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
test("explicit --remove ranges invert to kept segments", () => {
|
|
28
|
+
const transcript = [
|
|
29
|
+
word("w0", "alpha", 0, 1),
|
|
30
|
+
word("w1", "beta", 1.2, 2),
|
|
31
|
+
word("w2", "gamma", 2.2, 5),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
assert.deepEqual(compileCutList(transcript, { remove: "1-2.5" }), [
|
|
35
|
+
{ start: 0, end: 1 },
|
|
36
|
+
{ start: 2.5, end: 5 },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("--remove-words resolves inclusive word-index ranges to time ranges", () => {
|
|
41
|
+
const transcript = [
|
|
42
|
+
word("w0", "zero", 0, 0.5),
|
|
43
|
+
word("w1", "one", 0.6, 1),
|
|
44
|
+
word("w2", "two", 1.1, 1.5),
|
|
45
|
+
word("w3", "three", 2, 3),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(compileCutList(transcript, { removeWords: "1-2" }), [
|
|
49
|
+
{ start: 0, end: 0.6 },
|
|
50
|
+
{ start: 1.5, end: 3 },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("--remove-fillers drops case-insensitive matching words", () => {
|
|
55
|
+
const transcript = [
|
|
56
|
+
word("w0", "Hello", 0, 0.5),
|
|
57
|
+
word("w1", "Um", 0.5, 0.7),
|
|
58
|
+
word("w2", "world", 0.8, 1.2),
|
|
59
|
+
word("w3", "LIKE", 1.3, 1.5),
|
|
60
|
+
word("w4", "done", 1.6, 2),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(compileCutList(transcript, { removeFillers: "um,like" }), [
|
|
64
|
+
{ start: 0, end: 0.5 },
|
|
65
|
+
{ start: 0.7, end: 1.3 },
|
|
66
|
+
{ start: 1.5, end: 2 },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("--cut-silence removes only the center of long inter-word gaps", () => {
|
|
71
|
+
const transcript = [word("w0", "a", 0, 0.5), word("w1", "b", 2, 2.5), word("w2", "c", 2.7, 3)];
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(compileCutList(transcript, { cutSilence: 0.8 }), [
|
|
74
|
+
{ start: 0, end: 0.65 },
|
|
75
|
+
{ start: 1.85, end: 3 },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("overlapping removal sources merge before inversion", () => {
|
|
80
|
+
const transcript = [
|
|
81
|
+
word("w0", "start", 0, 0.5),
|
|
82
|
+
word("w1", "um", 0.9, 1.1),
|
|
83
|
+
word("w2", "middle", 2.5, 2.8),
|
|
84
|
+
word("w3", "more", 3.1, 3.4),
|
|
85
|
+
word("w4", "end", 5.5, 6),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
compileCutList(transcript, {
|
|
90
|
+
remove: "1-2.7",
|
|
91
|
+
removeWords: "2-3",
|
|
92
|
+
removeFillers: "um",
|
|
93
|
+
}),
|
|
94
|
+
[
|
|
95
|
+
{ start: 0, end: 0.9 },
|
|
96
|
+
{ start: 3.4, end: 6 },
|
|
97
|
+
],
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("kept slivers shorter than 0.2s are dropped", () => {
|
|
102
|
+
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 2.5, 3)];
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(compileCutList(transcript, { remove: "0.1-2.95" }), []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("--keep is inverse mode and coalesces direct kept ranges", () => {
|
|
108
|
+
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 4.5, 5)];
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(compileCutList(transcript, { keep: "3-4,1-2,1.5-2.5,4.1-4.2" }), [
|
|
111
|
+
{ start: 1, end: 2.5 },
|
|
112
|
+
{ start: 3, end: 4 },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("kept ranges keep frame precision instead of rounding to the millisecond", () => {
|
|
117
|
+
const transcript = [word("w0", "start", 0, 1), word("w1", "end", 14, 15)];
|
|
118
|
+
|
|
119
|
+
// Frame 386 at 30 fps is 12.866666 s. These bounds become ffmpeg's -ss/-to, so
|
|
120
|
+
// rounding them to 12.867 hands ffmpeg a time just past the frame and the
|
|
121
|
+
// segment starts one frame late.
|
|
122
|
+
assert.deepEqual(compileCutList(transcript, { keep: "0-1,12.866666666666667-14" }), [
|
|
123
|
+
{ start: 0, end: 1 },
|
|
124
|
+
{ start: 12.866667, end: 14 },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("--plan on a fixture transcript prints the exact segment JSON", () => {
|
|
129
|
+
const dir = mkdtempSync(join(tmpdir(), "media-use-cutlist-"));
|
|
130
|
+
try {
|
|
131
|
+
const transcriptPath = join(dir, "fixture.json");
|
|
132
|
+
writeFileSync(
|
|
133
|
+
transcriptPath,
|
|
134
|
+
JSON.stringify([
|
|
135
|
+
word("w0", "hello", 0, 0.4),
|
|
136
|
+
word("w1", "um", 0.5, 0.65),
|
|
137
|
+
word("w2", "there", 0.7, 1),
|
|
138
|
+
word("w3", "pause", 2.2, 2.5),
|
|
139
|
+
word("w4", "end", 2.7, 3.2),
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const out = execFileSync(
|
|
144
|
+
process.execPath,
|
|
145
|
+
[
|
|
146
|
+
SCRIPT,
|
|
147
|
+
"--input",
|
|
148
|
+
"ignored.mp4",
|
|
149
|
+
"--transcript",
|
|
150
|
+
transcriptPath,
|
|
151
|
+
"--remove",
|
|
152
|
+
"0.9-1.2",
|
|
153
|
+
"--remove-fillers",
|
|
154
|
+
"um",
|
|
155
|
+
"--cut-silence",
|
|
156
|
+
"0.8",
|
|
157
|
+
"--plan",
|
|
158
|
+
],
|
|
159
|
+
{ encoding: "utf8" },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
assert.deepEqual(JSON.parse(out), [
|
|
163
|
+
{ start: 0, end: 0.5 },
|
|
164
|
+
{ start: 0.65, end: 0.9 },
|
|
165
|
+
{ start: 2.05, end: 3.2 },
|
|
166
|
+
]);
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(dir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function word(id, text, start, end) {
|
|
173
|
+
return { id, text, start, end };
|
|
174
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs";
|
|
9
|
+
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// Keep the skill-owned wrapper path under test until the compatibility layer
|
|
12
|
+
// is removed in a later release.
|
|
13
|
+
const SCRIPT = join(
|
|
14
|
+
HERE,
|
|
15
|
+
"..",
|
|
16
|
+
"..",
|
|
17
|
+
"..",
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
"skills",
|
|
21
|
+
"media-use",
|
|
22
|
+
"scripts",
|
|
23
|
+
"audio-duck.mjs",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
test("speechSpans bridges gaps smaller than mergeGap", () => {
|
|
27
|
+
const meta = {
|
|
28
|
+
words: [word("w0", "one", 0, 0.5), word("w1", "two", 0.8, 1), word("w2", "three", 2, 2.2)],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.4 }), [
|
|
32
|
+
{ start: 0, end: 1 },
|
|
33
|
+
{ start: 2, end: 2.2 },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("speechSpans refuses multi-line meta without placement (file-relative times)", () => {
|
|
38
|
+
const meta = {
|
|
39
|
+
voices: [
|
|
40
|
+
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
|
41
|
+
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
assert.throws(() => speechSpans(meta, { mergeGap: 0.2 }), /--sequential or --offsets/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("speechSpans sequential stacks lines by duration plus gap", () => {
|
|
48
|
+
const meta = {
|
|
49
|
+
voices: [
|
|
50
|
+
{ id: "a", duration_s: 2, words: [word("w0", "one", 0.1, 1.9)] },
|
|
51
|
+
{ id: "b", duration_s: 1, words: [word("w1", "two", 0.1, 0.9)] },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, sequential: true, gap: 0.5 }), [
|
|
55
|
+
{ start: 0.1, end: 1.9 },
|
|
56
|
+
{ start: 2.6, end: 3.4 },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("speechSpans explicit offsets place each line at composition time", () => {
|
|
61
|
+
const meta = {
|
|
62
|
+
voices: [
|
|
63
|
+
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
|
64
|
+
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, offsets: { a: 0, b: 4 } }), [
|
|
68
|
+
{ start: 0, end: 1 },
|
|
69
|
+
{ start: 4, end: 5 },
|
|
70
|
+
]);
|
|
71
|
+
assert.throws(() => speechSpans(meta, { offsets: { a: 0 } }), /missing voice "b"/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("speechSpans returns empty spans for empty input", () => {
|
|
75
|
+
assert.deepEqual(speechSpans({ voices: [] }, { mergeGap: 0.6 }), []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("speechSpans offsets retain voice IDs across empty transcripts", () => {
|
|
79
|
+
const meta = {
|
|
80
|
+
voices: [
|
|
81
|
+
{ id: "silent", duration_s: 2, words: [] },
|
|
82
|
+
{ id: "narration", duration_s: 1, words: [word("w0", "hello", 0.1, 0.9)] },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
const expected = [{ start: 4.1, end: 4.9 }];
|
|
86
|
+
assert.deepEqual(speechSpans(meta, { offsets: { silent: 0, narration: 4 } }), expected);
|
|
87
|
+
assert.deepEqual(speechSpans(meta, { offsets: { narration: 4 } }), expected);
|
|
88
|
+
assert.throws(() => speechSpans(meta, { offsets: { silent: 0 } }), /missing voice "narration"/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("speechSpans sequential counts empty line durations and gaps", () => {
|
|
92
|
+
const meta = {
|
|
93
|
+
voices: [
|
|
94
|
+
{ id: "intro", duration_s: 2, words: [] },
|
|
95
|
+
{ id: "first", duration_s: 1, words: [word("w0", "hello", 0.1, 0.9)] },
|
|
96
|
+
{ id: "pause", duration_s: 3, words: [] },
|
|
97
|
+
{ id: "last", duration_s: 1, words: [word("w1", "goodbye", 0.1, 0.9)] },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
assert.deepEqual(speechSpans(meta, { sequential: true, gap: 0.5 }), [
|
|
101
|
+
{ start: 2.6, end: 3.4 },
|
|
102
|
+
{ start: 7.6, end: 8.4 },
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("speechSpans offsets without IDs retain original voice indices", () => {
|
|
107
|
+
const meta = {
|
|
108
|
+
voices: [{ duration_s: 2 }, { duration_s: 1, words: [word("w0", "hello", 0.1, 0.9)] }],
|
|
109
|
+
};
|
|
110
|
+
assert.deepEqual(speechSpans(meta, { offsets: { 1: 4 } }), [{ start: 4.1, end: 4.9 }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("speechSpans keeps normalized-empty transcripts aligned with their voices", () => {
|
|
114
|
+
const meta = {
|
|
115
|
+
voices: [
|
|
116
|
+
{ id: "untimed", duration_s: 2, words: [{ text: "hello" }] },
|
|
117
|
+
{ id: "narration", duration_s: 1, words: [word("w0", "hello", 0.1, 0.9)] },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
assert.deepEqual(speechSpans(meta, { sequential: true }), [{ start: 2.1, end: 2.9 }]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("speechSpans does not require placement for transcripts without speech", () => {
|
|
124
|
+
const meta = {
|
|
125
|
+
voices: [
|
|
126
|
+
{ id: "a", duration_s: 2, words: [] },
|
|
127
|
+
{ id: "b", duration_s: 1 },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
assert.deepEqual(speechSpans(meta), []);
|
|
131
|
+
assert.deepEqual(speechSpans(meta, { offsets: {} }), []);
|
|
132
|
+
assert.deepEqual(speechSpans(meta, { sequential: true }), []);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("duckKeyframes shapes attack and release from base volume", () => {
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
duckKeyframes([{ start: 3, end: 5 }], {
|
|
138
|
+
duck: 0.25,
|
|
139
|
+
attack: 0.15,
|
|
140
|
+
release: 0.4,
|
|
141
|
+
baseVolume: 0.6,
|
|
142
|
+
}),
|
|
143
|
+
[
|
|
144
|
+
{ time: 3, volume: 0.15, duration: 0.15 },
|
|
145
|
+
{ time: 5, volume: 0.6, duration: 0.4 },
|
|
146
|
+
],
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("duckLane turns composition-time keyframes into clip-local ramps", () => {
|
|
151
|
+
const keyframes = [
|
|
152
|
+
{ time: 3, volume: 0.15, duration: 0.15 },
|
|
153
|
+
{ time: 5, volume: 0.6, duration: 0.4 },
|
|
154
|
+
];
|
|
155
|
+
assert.deepEqual(duckLane(keyframes, { clipStart: 1, baseVolume: 0.6 }), {
|
|
156
|
+
version: 1,
|
|
157
|
+
lanes: [
|
|
158
|
+
{
|
|
159
|
+
target: "volume",
|
|
160
|
+
points: [
|
|
161
|
+
{ t: 0, v: 0.6 },
|
|
162
|
+
{ t: 2, v: 0.6 },
|
|
163
|
+
{ t: 2.15, v: 0.15 },
|
|
164
|
+
{ t: 4, v: 0.15 },
|
|
165
|
+
{ t: 4.4, v: 0.6 },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
for (const [name, clipStart, expected] of [
|
|
173
|
+
[
|
|
174
|
+
"before the attack",
|
|
175
|
+
0.5,
|
|
176
|
+
[
|
|
177
|
+
{ t: 0, v: 0.6 },
|
|
178
|
+
{ t: 0.5, v: 0.6 },
|
|
179
|
+
{ t: 0.65, v: 0.15 },
|
|
180
|
+
{ t: 2.5, v: 0.15 },
|
|
181
|
+
{ t: 2.9, v: 0.6 },
|
|
182
|
+
],
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
"during the attack",
|
|
186
|
+
1.075,
|
|
187
|
+
[
|
|
188
|
+
{ t: 0, v: 0.375 },
|
|
189
|
+
{ t: 0.075, v: 0.15 },
|
|
190
|
+
{ t: 1.925, v: 0.15 },
|
|
191
|
+
{ t: 2.325, v: 0.6 },
|
|
192
|
+
],
|
|
193
|
+
],
|
|
194
|
+
[
|
|
195
|
+
"during speech",
|
|
196
|
+
2,
|
|
197
|
+
[
|
|
198
|
+
{ t: 0, v: 0.15 },
|
|
199
|
+
{ t: 1, v: 0.15 },
|
|
200
|
+
{ t: 1.4, v: 0.6 },
|
|
201
|
+
],
|
|
202
|
+
],
|
|
203
|
+
[
|
|
204
|
+
"at the release",
|
|
205
|
+
3,
|
|
206
|
+
[
|
|
207
|
+
{ t: 0, v: 0.15 },
|
|
208
|
+
{ t: 0.4, v: 0.6 },
|
|
209
|
+
],
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"during the release",
|
|
213
|
+
3.2,
|
|
214
|
+
[
|
|
215
|
+
{ t: 0, v: 0.375 },
|
|
216
|
+
{ t: 0.2, v: 0.6 },
|
|
217
|
+
],
|
|
218
|
+
],
|
|
219
|
+
["after narration", 4, [{ t: 0, v: 0.6 }]],
|
|
220
|
+
]) {
|
|
221
|
+
test(`duckLane preserves composition-time gain when music starts ${name}`, () => {
|
|
222
|
+
const keyframes = duckKeyframes([{ start: 1, end: 3 }], { baseVolume: 0.6 });
|
|
223
|
+
const points = duckLane(keyframes, { clipStart, baseVolume: 0.6 }).lanes[0].points;
|
|
224
|
+
|
|
225
|
+
assert.equal(points.length, expected.length);
|
|
226
|
+
for (let i = 0; i < points.length; i++) {
|
|
227
|
+
assert.equal(points[i].t, expected[i].t);
|
|
228
|
+
assert.ok(
|
|
229
|
+
Math.abs(points[i].v - expected[i].v) < 1e-9,
|
|
230
|
+
`gain at ${points[i].t}s: expected ${expected[i].v}, received ${points[i].v}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
test("duckLane discards earlier narration and retains the next speech span", () => {
|
|
237
|
+
const keyframes = duckKeyframes([
|
|
238
|
+
{ start: 1, end: 2 },
|
|
239
|
+
{ start: 5, end: 6 },
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(duckLane(keyframes, { clipStart: 3 }).lanes[0].points, [
|
|
243
|
+
{ t: 0, v: 1 },
|
|
244
|
+
{ t: 2, v: 1 },
|
|
245
|
+
{ t: 2.15, v: 0.25 },
|
|
246
|
+
{ t: 3, v: 0.25 },
|
|
247
|
+
{ t: 3.4, v: 1 },
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("--json spans match --merge-gap semantics exactly", () => {
|
|
252
|
+
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-"));
|
|
253
|
+
try {
|
|
254
|
+
const metaPath = join(dir, "audio_meta.json");
|
|
255
|
+
writeFileSync(
|
|
256
|
+
metaPath,
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
voices: [
|
|
259
|
+
{
|
|
260
|
+
id: "narration",
|
|
261
|
+
words: [
|
|
262
|
+
word("w0", "one", 0, 0.4),
|
|
263
|
+
word("w1", "two", 0.9, 1.2),
|
|
264
|
+
word("w2", "three", 1.8, 2.1),
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const out = execFileSync(
|
|
272
|
+
process.execPath,
|
|
273
|
+
[SCRIPT, "--meta", metaPath, "--target", "#bgm", "--merge-gap", "0.6", "--json"],
|
|
274
|
+
{ encoding: "utf8" },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const parsed = JSON.parse(out);
|
|
278
|
+
assert.deepEqual(parsed.spans, [
|
|
279
|
+
{ start: 0, end: 1.2 },
|
|
280
|
+
{ start: 1.8, end: 2.1 },
|
|
281
|
+
]);
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
for (const [placement, flags, expected] of [
|
|
288
|
+
["sequential", ["--sequential", "--gap", "0.5"], [{ start: 2.6, end: 3.4 }]],
|
|
289
|
+
["offsets", ["--offsets", "narration=4"], [{ start: 4.1, end: 4.9 }]],
|
|
290
|
+
]) {
|
|
291
|
+
test(`audio-duck --json preserves empty voice placement with ${placement}`, (t) => {
|
|
292
|
+
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-empty-"));
|
|
293
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
294
|
+
const metaPath = join(dir, "audio_meta.json");
|
|
295
|
+
writeFileSync(
|
|
296
|
+
metaPath,
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
voices: [
|
|
299
|
+
{ id: "silent", duration_s: 2, words: [] },
|
|
300
|
+
{ id: "narration", duration_s: 1, words: [word("w0", "hello", 0.1, 0.9)] },
|
|
301
|
+
],
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
const result = JSON.parse(
|
|
305
|
+
execFileSync(
|
|
306
|
+
process.execPath,
|
|
307
|
+
[SCRIPT, "--meta", metaPath, "--target", "#bgm", ...flags, "--json"],
|
|
308
|
+
{ encoding: "utf8" },
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
assert.deepEqual(result.spans, expected);
|
|
312
|
+
assert.equal(result.keyframes[0].time, expected[0].start);
|
|
313
|
+
assert.equal(result.lane.lanes[0].points[1].t, expected[0].start);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function word(id, text, start, end) {
|
|
318
|
+
return { id, text, start, end };
|
|
319
|
+
}
|