Setting the file. One moment.
Carve Test · Hyperframes Audio · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page scripts/carve.test.mjs
scripts/ carve.test.mjs
JavaScript · 254 lines · 12 KB
from
"node:url"
;
8 import {
9 DEFAULT_STRENGTH,
10 carveSources,
11 groupSourceRefusal,
12 loadCore,
13 parseArgs,
14 } from "./carve.mjs" ;
15
16 const SCRIPTS_DIR = dirname ( fileURLToPath ( import . meta .url));
17
18 /** A voice as `detectTracks` yields it: an id plus its raw opening tag. */
19 function voice ( id , group ) {
20 const attr = group === undefined ? "" : ` data-audio-group="${ group }"` ;
21 return { id, tag: `<audio id="${ id }"${ attr } src="${ id }.wav"></audio>` };
22 }
23
24 /** An audio member as `main` describes it for the source decision. */
25 function member ( id , group , nameKind ) {
26 return { id, group, nameKind };
27 }
28
29 /** A bed as `mediaElements` yields it: `kind` is what decides group membership. */
30 function bed ( id , group , kind = "audio" ) {
31 const attr = group === undefined ? "" : ` data-audio-group="${ group }"` ;
32 return { id, kind, tag: `<${ kind } id="${ id }"${ attr } src="${ id }.mp3"></${ kind }>` };
33 }
34
35 async function inTempDir ( run ) {
36 const dir = mkdtempSync ( join ( tmpdir (), "carve-test-" ));
37 try {
38 return await run (dir);
39 } finally {
40 rmSync (dir, { recursive: true , force: true });
41 }
42 }
43
44 test ( "voices sharing one group carve against the group, not their ids" , () => {
45 // SKILL.md's invariant: "A carve against more than one clip id is wrong.
46 // Group the clips and carve against the group." Naming the group lets
47 // membership resolve at analysis time, so a voice added later is covered.
48 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" ), voice ( "vo3" , "voiceover" )];
49 assert. deepEqual ( carveSources (voices, undefined , []), [ "voiceover" ]);
50 });
51
52 test ( "a single grouped voice still records the group" , () => {
53 assert. deepEqual ( carveSources ([ voice ( "vo1" , "voiceover" )], undefined , []), [ "voiceover" ]);
54 });
55
56 test ( "ungrouped voices keep their ids, so the lint rule can still say so" , () => {
57 // Not silently inventing a group: `audio_carve_ungrouped_sources` is the
58 // right signal here, and it needs the ids to fire on.
59 assert. deepEqual ( carveSources ([ voice ( "vo1" ), voice ( "vo2" )], undefined , []), [ "vo1" , "vo2" ]);
60 });
61
62 test ( "voices in DIFFERENT groups keep their ids" , () => {
63 // One carve cannot name two groups, and picking either would silently drop
64 // the other's members from the analysis.
65 const voices = [ voice ( "vo1" , "narration" ), voice ( "vo2" , "interview" )];
66 assert. deepEqual ( carveSources (voices, undefined , []), [ "vo1" , "vo2" ]);
67 });
68
69 test ( "a partially grouped set keeps its ids" , () => {
70 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" )];
71 assert. deepEqual ( carveSources (voices, undefined , []), [ "vo1" , "vo2" ]);
72 });
73
74 test ( "an empty group attribute is not a group" , () => {
75 assert. deepEqual ( carveSources ([ voice ( "vo1" , "" ), voice ( "vo2" , "" )], undefined , []), [
76 "vo1" ,
77 "vo2" ,
78 ]);
79 });
80
81 // The nine cases above pass `[]` explicitly because they predate the membership
82 // check and are about the bed and the group attributes alone. `members` is a
83 // required argument: with `[]` the `mixed` refusal cannot fire, so a default
84 // would let a call that forgot it return the group form and silently undo the
85 // widening fix — see the wiring test at the bottom of this file.
86
87 test ( "a bed inside the voices' group keeps clip ids, so it is never its own source" , () => {
88 // `resolveCarveSourceIds` expands a group to every CURRENT member, and it gets
89 // no host element to exclude — so naming a group the bed belongs to puts the
90 // bed in its own voice list on the next analysis, and it is carved against
91 // itself. This run cannot see it (main sums the detected voices directly), so
92 // the check has to happen here.
93 const voices = [ voice ( "vo1" , "mix" ), voice ( "vo2" , "mix" )];
94 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "mix" ), []), [ "vo1" , "vo2" ]);
95 });
96
97 test ( "a bed in a DIFFERENT group leaves the group form alone" , () => {
98 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" )];
99 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), []), [ "voiceover" ]);
100 });
101
102 test ( "an ungrouped bed leaves the group form alone" , () => {
103 assert. deepEqual ( carveSources ([ voice ( "vo1" , "voiceover" )], bed ( "bgm" ), []), [ "voiceover" ]);
104 });
105
106 test ( "a VIDEO bed is immune — group membership is audio-only" , () => {
107 // `data-audio-group` on a <video> is ignored by core, so expanding the group
108 // can never pull this bed in and declining would be a false positive.
109 const voices = [ voice ( "vo1" , "mix" ), voice ( "vo2" , "mix" )];
110 assert. deepEqual ( carveSources (voices, bed ( "clip" , "mix" , "video" ), []), [ "mix" ]);
111 });
112
113 test ( "an sfx member of the voices' group blocks the group form" , () => {
114 // The group resolves wider than the analysis: `resolveCarveSourceIds` expands
115 // it to every current member and `resolveCarveVoices` keeps any audio with a
116 // src, so an sfx clip sharing the voice group enters the sidechain on the next
117 // analysis and ducks the bed under a whoosh. This run cannot see it — it sums
118 // the voices `detectTracks` returned, which correctly excluded the sfx.
119 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" )];
120 const members = [
121 member ( "vo1" , "voiceover" , "voice" ),
122 member ( "vo2" , "voiceover" , "voice" ),
123 member ( "whoosh" , "voiceover" , "sfx" ),
124 member ( "bgm" , "music" , "music" ),
125 ];
126 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), members), [ "vo1" , "vo2" ]);
127 });
128
129 test ( "a music member of the voices' group blocks it too" , () => {
130 const voices = [ voice ( "vo1" , "voiceover" )];
131 const members = [ member ( "vo1" , "voiceover" , "voice" ), member ( "pad" , "voiceover" , "music" )];
132 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), members), [ "vo1" ]);
133 });
134
135 test ( "a voice member this run did NOT analyse keeps the group form" , () => {
136 // The designed case, and the one a membership check must not break: a voice
137 // that does not overlap the bed is left out of the analysis on purpose, and
138 // covering it on a later analysis without editing `sources` is the entire
139 // reason SKILL.md says to name the group. Refusing here would collapse the
140 // group form into clip ids for every ordinary narration sequence.
141 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" )];
142 const members = [
143 member ( "vo1" , "voiceover" , "voice" ),
144 member ( "vo2" , "voiceover" , "voice" ),
145 member ( "vo-outro" , "voiceover" , "voice" ),
146 ];
147 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), members), [ "voiceover" ]);
148 });
149
150 test ( "an unclassifiable member keeps the group form" , () => {
151 // `unknown` is what `detectTracks` itself treats as a possible voice, so it is
152 // not evidence of a non-voice member — loose in the safe direction, same as
153 // detection.
154 const voices = [ voice ( "vo1" , "voiceover" )];
155 const members = [ member ( "vo1" , "voiceover" , "voice" ), member ( "track7" , "voiceover" , "unknown" )];
156 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), members), [ "voiceover" ]);
157 });
158
159 test ( "an sfx member of a DIFFERENT group is irrelevant" , () => {
160 const voices = [ voice ( "vo1" , "voiceover" )];
161 const members = [ member ( "vo1" , "voiceover" , "voice" ), member ( "whoosh" , "sfx" , "sfx" )];
162 assert. deepEqual ( carveSources (voices, bed ( "bgm" , "music" ), members), [ "voiceover" ]);
163 });
164
165 test ( "groupSourceRefusal names which member blocked the group, and why" , () => {
166 // The stderr note is built from this, so it has to carry the ids: "sources are
167 // clip ids" plus `audio_carve_ungrouped_sources` reads as nonsense to an author
168 // who did group their clips.
169 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" )];
170 assert. deepEqual (
171 groupSourceRefusal (voices, bed ( "bgm" , "music" ), [ member ( "whoosh" , "voiceover" , "sfx" )]),
172 { group: "voiceover" , reason: "mixed" , ids: [ "whoosh" ] },
173 );
174 assert. deepEqual ( groupSourceRefusal (voices, bed ( "bgm" , "voiceover" ), []), {
175 group: "voiceover" ,
176 reason: "bed" ,
177 ids: [ "bgm" ],
178 });
179 assert. equal ( groupSourceRefusal (voices, bed ( "bgm" , "music" ), []), null );
180 });
181
182 test ( "the CLI still runs when invoked through a symlinked path" , () => {
183 // argv[1] keeps the invoked spelling while import.meta.url is the realpath, so
184 // a raw compare in the entry guard skips main() and the CLI exits 0 having
185 // written nothing. macOS /tmp -> /private/tmp reaches this with no symlink of
186 // one's own; so does any skill install placed behind a link.
187 return inTempDir (( dir ) => {
188 const link = join (dir, "scripts-link" );
189 symlinkSync ( SCRIPTS_DIR , link, "junction" );
190 let status = 0 ;
191 let stderr = "" ;
192 try {
193 execFileSync (process.execPath, [ join (link, "carve.mjs" )], { encoding: "utf-8" });
194 } catch (error) {
195 status = error.status;
196 stderr = String (error.stderr);
197 }
198 // Reaching parseArgs is the proof main() ran at all: no args is an error, and
199 // the broken guard's symptom is a silent exit 0 with no output.
200 assert. equal (status, 1 , `expected the usage error, got status ${ status }` );
201 assert. match (stderr, /--comp is required/ );
202 });
203 });
204
205 test ( "loadCore honours an import-only export map, as the published core has" , () => {
206 // The published manifest carries only `import` + `types` for these subpaths, so
207 // `require.resolve` cannot resolve them at all and the fallback has to read the
208 // export map itself. A fixture pins that without depending on npm.
209 return inTempDir ( async ( dir ) => {
210 const pkgDir = join (dir, "node_modules" , "@hyperframes" , "core" );
211 mkdirSync ( join (pkgDir, "dist" ), { recursive: true });
212 writeFileSync ( join (dir, "package.json" ), JSON . stringify ({ name: "fixture-project" }));
213 writeFileSync (
214 join (pkgDir, "package.json" ),
215 JSON . stringify ({
216 name: "@hyperframes/core" ,
217 version: "0.0.0-fixture" ,
218 type: "module" ,
219 exports: {
220 "./package.json" : "./package.json" ,
221 "./audio-carve" : { import: "./dist/audioCarve.js" },
222 "./audio-fx" : { import: "./dist/audioFx.js" },
223 },
224 }),
225 );
226 writeFileSync ( join (pkgDir, "dist" , "audioCarve.js" ), 'export const marker = "carve"; \n ' );
227 writeFileSync ( join (pkgDir, "dist" , "audioFx.js" ), 'export const marker = "fx"; \n ' );
228
229 const core = await loadCore (dir);
230 assert. equal (core.carve.marker, "carve" );
231 assert. equal (core.fx.marker, "fx" );
232 });
233 });
234
235 test ( "members is required, so dropping it cannot silently restore the group form" , () => {
236 // The gap this closes: `main()` is the only code that BUILDS `members`, and no
237 // test runs `main()` (the symlink test stops at the usage error, a real run
238 // needs ffmpeg). With `members = []` defaulted, a refactor that dropped the
239 // third argument would return the group form again with the whole suite green
240 // — the same signature as the bug itself: first pass correct, persisted
241 // attribute wrong, nothing red. Omitting it now throws instead.
242 const voices = [ voice ( "vo1" , "voiceover" ), voice ( "vo2" , "voiceover" )];
243 assert. throws (() => carveSources (voices, bed ( "bgm" , "music" )), TypeError);
244 assert. throws (() => groupSourceRefusal (voices, bed ( "bgm" , "music" )), TypeError);
245 });
246
247 test ( "the default strength is 0.8, and --strength still overrides it" , () => {
248 // 0.25 left the bed audibly fighting the voice on narrated builds; a carve at
249 // the default has to already be a finished mix. Pinned so it cannot drift back
250 // unnoticed — core pins its own DEFAULT_CARVE the same way.
251 assert. equal ( DEFAULT_STRENGTH , 0.8 );
252 assert. equal ( parseArgs ([ "--comp" , "x.html" ]).strength, 0.8 );
253 assert. equal ( parseArgs ([ "--comp" , "x.html" , "--strength" , "0.25" ]).strength, 0.25 );
254 });