Setting the file. One moment.
Carve · Hyperframes Audio · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page function mediaElements
— line 281
This file
Number 19.5
Position 5 of 6
Type JavaScript
Size 24 KB
Lines 572 scripts/ carve.mjs
JavaScript · 572 lines · 24 KB
15 *
16 * With no --bed/--voice it works out the tracks itself: the bed, and every voice
17 * playing over it. `--voice` may be repeated to name them instead. Every named
18 * voice is analysed together, so a bed running under a narrator and an answer makes
19 * room for both.
20 *
21 * Needs `ffmpeg` on PATH (to decode the audio) and `@hyperframes/core` resolvable
22 * from the composition's project (`npm i -D @hyperframes/core`) — the CLI bundles
23 * core inline rather than shipping it as a package, so it cannot be borrowed from
24 * there.
25 */
26
27 import { execFileSync } from "node:child_process" ;
28 import { createRequire } from "node:module" ;
29 import { readFileSync, realpathSync, writeFileSync } from "node:fs" ;
30 import { dirname, resolve } from "node:path" ;
31 import { pathToFileURL } from "node:url" ;
32
33 /** Sample rate the analysis runs at. Matches Studio's own decode rate, so the
34 * bands and envelopes come out the same either way. */
35 const SAMPLE_RATE = 48000 ;
36
37 /** Default carve strength. 0.25 kept the bed present but still fighting the voice. */
38 export const DEFAULT_STRENGTH = 0.8 ;
39
40 const usage = `carve.mjs --comp <file.html> [--bed <elementId>] [--voice <elementId> ...]
41 [--strength 0..1] [--dry-run] [--core <dir>]
42
43 --bed id of the music track that gets carved (detected if omitted)
44 --voice id of a voice to make room for; repeatable (detected if omitted)
45 --strength how hard to carve, 0..1 (default ${ DEFAULT_STRENGTH })
46 --dry-run report what it would write, touch nothing
47 --core directory to resolve @hyperframes/core from (default: the comp's)` ;
48
49 export function parseArgs ( argv ) {
50 const args = { strength: DEFAULT_STRENGTH , dryRun: false , voices: [] };
51 for ( let i = 0 ; i < argv. length ; i += 1 ) {
52 const flag = argv[i];
53 const next = () => {
54 const value = argv[i + 1 ];
55 if (value === undefined ) fail ( `${ flag } needs a value` );
56 i += 1 ;
57 return value;
58 };
59 if (flag === "--comp" ) args.comp = next ();
60 else if (flag === "--bed" ) args.bed = next ();
61 else if (flag === "--voice" ) args.voices. push ( next ());
62 else if (flag === "--strength" ) args.strength = Number ( next ());
63 else if (flag === "--core" ) args.core = next ();
64 else if (flag === "--dry-run" ) args.dryRun = true ;
65 else if (flag === "-h" || flag === "--help" ) fail (usage, 0 );
66 else fail ( `unknown flag: ${ flag } \n\n ${ usage }` );
67 }
68 if ( ! args.comp) fail ( `--comp is required \n\n ${ usage }` );
69 if ( ! Number. isFinite (args.strength) || args.strength < 0 || args.strength > 1 ) {
70 fail ( "--strength must be a number from 0 to 1" );
71 }
72 return args;
73 }
74
75 function fail ( message , code = 1 ) {
76 process.stderr. write ( `${ message } \n ` );
77 process. exit (code);
78 }
79
80 /**
81 * Load the carve analysis out of `@hyperframes/core`.
82 *
83 * Resolved from the project rather than from this script, which lives wherever
84 * the skill was installed — a sibling of the composition is what has the
85 * dependency.
86 */
87 export async function loadCore ( fromDir ) {
88 const require = createRequire ( pathToFileURL ( resolve (fromDir, "package.json" )));
89
90 /*
91 * Two constraints at once, and satisfying either alone is broken:
92 *
93 * 1. Anchored at the PROJECT, not at this script. This file lives wherever
94 * the skill was installed, which has no @hyperframes/core; the
95 * composition's project is what holds the dependency. So a bare
96 * `import("@hyperframes/core/audio-carve")` from here cannot work — bare
97 * specifiers resolve relative to the importing module.
98 * 2. Honouring the package's export CONDITIONS. `require.resolve` asks for
99 * "require"/"node". The workspace manifest declares `node`, so this
100 * resolved fine inside the monorepo — but the PUBLISHED manifest carries
101 * only `import` + `types`, so every consumer of the released package got
102 * ERR_PACKAGE_PATH_NOT_EXPORTED for a package that ships the file. That
103 * is the audience this skill is shipped to, so the script was broken
104 * everywhere except where it was developed.
105 *
106 * Keep the project anchor; fall back to the package's declared `import`
107 * target when no require-resolvable condition exists.
108 */
109 const load = async ( subpath ) => {
110 const spec = `@hyperframes/core/${ subpath }` ;
111 try {
112 return await import ( pathToFileURL (require. resolve (spec)).href);
113 } catch (error) {
114 if (error?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED" ) throw error;
115 // `./package.json` is exported by every manifest, so this always resolves
116 // and gives us the package root without guessing at node_modules layout.
117 const pkgPath = require. resolve ( "@hyperframes/core/package.json" );
118 const pkg = JSON . parse ( readFileSync (pkgPath, "utf-8" ));
119 const entry = pkg.exports?.[ `./${ subpath }` ];
120 const target = typeof entry === "string" ? entry : (entry?.import ?? entry?.default ?? null );
121 if ( ! target) {
122 fail (
123 `@hyperframes/core does not export ./${ subpath } \n ` +
124 ` found at: ${ pkgPath } (version ${ pkg . version }) \n ` +
125 ` update it: npm i -D @hyperframes/core` ,
126 );
127 }
128 return import ( pathToFileURL ( resolve ( dirname (pkgPath), target)).href);
129 }
130 };
131
132 try {
133 return {
134 carve: await load ( "audio-carve" ),
135 fx: await load ( "audio-fx" ),
136 };
137 } catch (error) {
138 fail (
139 `cannot load @hyperframes/core from ${ fromDir } \n ` +
140 ` is it installed there? npm i -D @hyperframes/core \n ` +
141 ` or point at one: --core <dir containing node_modules/@hyperframes/core> \n ` +
142 ` (${ error . code ?? "error"}: ${ error . message . split ( " \n " )[ 0 ] })` ,
143 );
144 }
145 }
146
147 /**
148 * The `sources` a carve should record for these voices, on this bed.
149 *
150 * SKILL.md states the invariant: "A carve against more than one clip id is
151 * wrong. Group the clips and carve against the group." Naming the group lets
152 * `resolveCarveSourceIds` resolve membership at analysis time, so a voice added
153 * later is covered without editing `sources` — whereas a list of clip ids rots
154 * silently the moment a fourth narration clip appears. The lint rule
155 * `audio_carve_ungrouped_sources` enforces exactly this.
156 *
157 * This script was writing clip ids unconditionally, so it violated its own
158 * skill's invariant and tripped its own lint rule on every run. When every
159 * voice shares one group, record the group. Mixed or ungrouped voices keep
160 * their ids, and the lint rule then correctly tells the author to group them.
161 *
162 * The bed has to be part of the decision, because the group form resolves
163 * LATER and wider than it looks. If the bed is itself a member of the voices'
164 * group, `resolveCarveSourceIds` expands that id to every current member on the
165 * next analysis — including the bed — and the bed ends up carved against
166 * itself, which SKILL.md calls a bug rather than a mix choice. This run cannot
167 * see it: `main()` sums the voice list it detected and never round-trips
168 * through group resolution, so the first pass is correct and only the next
169 * re-analysis in Studio is wrong. So decline the group form there and fall back
170 * to clip ids, which is exactly the case `audio_carve_ungrouped_sources` exists
171 * to put in front of the author.
172 *
173 * Only an `<audio>` bed can trip it: group membership is audio-only
174 * (`audioGroupOf`), so `data-audio-group` on a `<video>` bed is ignored by core
175 * and expanding a group can never pull it in.
176 */
177 export function carveSources ( voices , bed , members ) {
178 const group = sharedVoiceGroup (voices);
179 return group && ! groupSourceRefusal (voices, bed, members) ? [group] : voices. map (( v ) => v.id);
180 }
181
182 /** The one group every voice belongs to, or null if they do not share exactly one. */
183 function sharedVoiceGroup ( voices ) {
184 const groups = voices. map (( v ) => attrOf (v.tag, "data-audio-group" ));
185 const first = groups[ 0 ];
186 return Boolean (first) && groups. every (( g ) => g === first) ? first : null ;
187 }
188
189 /**
190 * Why naming the voices' shared group would persist something this run did not
191 * analyse — or null when the group is safe to name.
192 *
193 * `members` is every `<audio>` in the composition as `{id, group, nameKind}`,
194 * with `nameKind` from core's `classifyAudioName`, so this and Studio's picker
195 * classify the same way.
196 *
197 * Required, deliberately not defaulting to `[]`. With an empty list the `mixed`
198 * refusal below cannot fire, so a call that forgot the argument would return the
199 * group form and restore the exact behaviour this function exists to prevent —
200 * silently, because the first CLI pass is correct either way and only a later
201 * Studio re-analysis is wrong. A missing argument throws on `members.filter`
202 * instead.
203 *
204 * Two refusals, and both exist because the group form resolves LATER and WIDER
205 * than the analysis: `resolveCarveSourceIds` expands a group id to every current
206 * member on every analysis, and `resolveCarveVoices` keeps any audio member with
207 * a src. `main()` meanwhile sums the voice list `detectTracks` returned, so the
208 * first pass looks correct however wrong the persisted attribute is.
209 *
210 * `bed` — the bed is a member, so it would be handed to itself as a voice
211 * and carved against its own content.
212 * `mixed` — a member classified music or sfx is not a voice this run measured,
213 * so it would enter the sidechain on the next analysis and duck the
214 * bed under a whoosh.
215 *
216 * Deliberately NOT a refusal: a member classified `voice` or `unknown` that this
217 * run left out. That is the group form working as designed — `detectTracks` only
218 * takes voices that overlap the bed, and picking up a clip that starts playing
219 * later without an edit to `sources` is the whole reason SKILL.md says to name
220 * the group. Refusing there would collapse the group form into clip ids for
221 * every ordinary narration sequence.
222 */
223 export function groupSourceRefusal ( voices , bed , members ) {
224 const group = sharedVoiceGroup (voices);
225 if ( ! group) return null ;
226 if (bed?.kind === "audio" && attrOf (bed.tag, "data-audio-group" ) === group) {
227 return { group, reason: "bed" , ids: [bed.id] };
228 }
229 const analysed = new Set (voices. map (( v ) => v.id));
230 const strays = members
231 . filter (
232 ( m ) =>
233 m.group === group &&
234 ! analysed. has (m.id) &&
235 (m.nameKind === "music" || m.nameKind === "sfx" ),
236 )
237 . map (( m ) => m.id);
238 return strays. length > 0 ? { group, reason: "mixed" , ids: strays } : null ;
239 }
240
241 /** Mono float PCM for one media file, via ffmpeg. */
242 function decode ( path ) {
243 let raw;
244 try {
245 raw = execFileSync (
246 "ffmpeg" ,
247 [
248 "-v" ,
249 "error" ,
250 "-i" ,
251 path,
252 "-vn" ,
253 "-ac" ,
254 "1" ,
255 "-ar" ,
256 String ( SAMPLE_RATE ),
257 "-f" ,
258 "f32le" ,
259 "-" ,
260 ],
261 { maxBuffer: 1 << 30 },
262 );
263 } catch (error) {
264 fail ( `could not decode ${ path } \n ${ error . message . split ( " \n " )[ 0 ] }` );
265 }
266 if (raw. length === 0 ) fail ( `no audio in ${ path }` );
267 return new Float32Array (raw.buffer, raw.byteOffset, raw. length / 4 );
268 }
269
270 const attrOf = ( tag , name ) => tag. match ( new RegExp ( ` \\ s${ name }="([^"]*)"` , "i" ))?.[ 1 ] ?? null ;
271
272 const unescapeAttr = ( value ) =>
273 value
274 . replace ( /"/ g , '"' )
275 . replace ( /'/ g , "'" )
276 . replace ( /&/ g , "&" );
277
278 const escapeAttr = ( value ) => value. replace ( /&/ g , "&" ). replace ( /"/ g , """ );
279
280 /** Every media element with a src, as {id, tag, kind}. */
281 function mediaElements ( html ) {
282 const found = [];
283 for ( const match of html. matchAll ( /<(audio | video) \b [ ^ >] * >/ gi )) {
284 const tag = match[ 0 ];
285 // `\sid=` and not `id=`: `data-hf-id` would match first.
286 const id = tag. match ( / \s id="( [ ^ "] + )"/ )?.[ 1 ];
287 if (id && attrOf (tag, "src" )) found. push ({ id, tag, kind: match[ 1 ]. toLowerCase () });
288 }
289 return found;
290 }
291
292 /**
293 * Work out which track is the bed and which tracks are its voices.
294 *
295 * Names first, because they are what the author already told us and the answer is
296 * explainable: a track whose id or filename looks like music is the bed, ones that
297 * look like speech are voices, SFX-shaped names are neither. `classifyAudioName`
298 * comes from core so Studio's own picker and this cannot disagree.
299 *
300 * EVERY voice over the bed, not one of them. A bed usually runs under a whole
301 * sequence, and they are analysed together — so there is nothing to disambiguate,
302 * which is why this no longer refuses when several tracks look like speech.
303 *
304 * Only tracks that actually play while the bed does: one somewhere else on the
305 * timeline cannot mask it. It still refuses when it cannot find a bed at all, or
306 * finds no voice to make room for.
307 */
308 function detectTracks ( html , given , classify , overlaps ) {
309 const all = mediaElements (html);
310 const kindOf = ( el ) => classify (el.id, unescapeAttr ( attrOf (el.tag, "src" ) ?? "" ));
311 const spanOf = ( el ) => {
312 const raw = attrOf (el.tag, "data-duration" );
313 const n = raw === null ? Number.NaN : Number (raw);
314 return {
315 start: startOf (el.tag),
316 duration: Number. isFinite (n) ? n : null ,
317 };
318 };
319 const pick = ( id , what ) => {
320 const found = all. find (( el ) => el.id === id);
321 if ( ! found) fail ( `no <audio>/<video> with id="${ id }" in the composition` );
322 return { ... found, why: `--${ what }` };
323 };
324
325 let bed = given.bed ? pick (given.bed, "bed" ) : null ;
326 const named = given.voices. map (( id ) => pick (id, "voice" ));
327
328 if ( ! bed) {
329 const others = all. filter (( el ) => ! named. some (( v ) => v.id === el.id));
330 const music = others. filter (( el ) => kindOf (el) === "music" );
331 if (music. length === 1 ) bed = { ... music[ 0 ], why: "name looks like music" };
332 else if (music. length > 1 ) {
333 fail (
334 `several tracks look like music (${ music . map (( el ) => el . id ). join ( ", " ) }) — name one with --bed` ,
335 );
336 } else if (others. length === 1 ) {
337 bed = { ... others[ 0 ], why: "only track left" };
338 } else {
339 fail (
340 `cannot tell which track is the music bed \n ` +
341 ` media in the composition: ${ all . map (( el ) => el . id ). join ( ", " ) || "none"} \n ` +
342 ` name it with --bed` ,
343 );
344 }
345 }
346
347 const bedSpan = spanOf (bed);
348 const overlapping = ( el ) => overlaps (bedSpan, spanOf (el));
349 const plausible = all
350 . filter (( el ) => el.id !== bed.id && kindOf (el) !== "music" && kindOf (el) !== "sfx" )
351 . filter (overlapping);
352 // A voiceover is normally its own <audio>. Video counts only when no audio track
353 // is left to be the voice — a talking-head recut — because otherwise every B-roll
354 // clip in the composition reads as somebody talking.
355 const spoken = plausible. filter (( el ) => el.kind === "audio" );
356 const pool = spoken. length > 0 ? spoken : plausible;
357 const voices = named. length
358 ? named
359 : pool. map (( el ) => ({
360 ... el,
361 why: kindOf (el) === "voice" ? "name looks like a voice" : "plays over the bed" ,
362 }));
363
364 const usable = voices. filter (( el ) => attrOf (el.tag, "src" ));
365 if (usable. length === 0 ) {
366 fail (
367 `no voice to make room for on ${ bed . id } \n ` +
368 ` media in the composition: ${ all . map (( el ) => el . id ). join ( ", " ) || "none"} \n ` +
369 ` name one with --voice` ,
370 );
371 }
372 return { bed, voices: usable, all };
373 }
374
375 const startOf = ( tag ) => {
376 const raw = Number ( attrOf (tag, "data-start" ));
377 return Number. isFinite (raw) ? raw : 0 ;
378 };
379
380 async function main () {
381 const args = parseArgs (process.argv. slice ( 2 ));
382 const compPath = resolve (args.comp);
383 const compDir = dirname (compPath);
384 const { carve : carveApi , fx : fxApi } = await loadCore (args.core ? resolve (args.core) : compDir);
385
386 const html = readFileSync (compPath, "utf-8" );
387 const {
388 bed : bedEl ,
389 voices ,
390 all : media ,
391 } = detectTracks (html, args, carveApi.classifyAudioName, carveApi.clipsOverlap);
392 // Group membership + name classification for every audio track, so the source
393 // decision can see what the group will resolve to later and not just what this
394 // run analysed.
395 const members = media
396 . filter (( el ) => el.kind === "audio" )
397 . map (( el ) => ({
398 id: el.id,
399 group: attrOf (el.tag, "data-audio-group" ),
400 nameKind: carveApi. classifyAudioName (el.id, unescapeAttr ( attrOf (el.tag, "src" ) ?? "" )),
401 }));
402 const bedTag = bedEl.tag;
403 const bedSrc = attrOf (bedTag, "src" );
404 process.stdout. write (
405 `bed ${ bedEl . id } (${ bedEl . why }) \n ` +
406 voices. map (( v ) => `voice ${ v . id } (${ v . why })` ). join ( " \n " ) +
407 " \n " ,
408 );
409
410 const profile = carveApi. carveProfile (args.strength);
411 // Every voice summed onto the BED's clock before anything is measured. One
412 // question — where and when is speech masking this bed — with one answer, even
413 // when the answer comes from several people at different times.
414 const voice = carveApi. mixCarveSources (
415 voices. map (( v ) => ({
416 samples: decode ( resolve (compDir, unescapeAttr ( attrOf (v.tag, "src" )))),
417 offsetSeconds: startOf (v.tag) - startOf (bedTag),
418 })),
419 SAMPLE_RATE ,
420 );
421 if (voice. length === 0 ) fail ( "the voices do not overlap the bed, so there is nothing to carve" );
422 const bands = carveApi. analyseCarveBands (voice, SAMPLE_RATE , profile);
423
424 // The level half of the carve needs both sides: "how far over the speech is this
425 // bed" cannot be answered by listening to one of them. No offset — the mix is
426 // already on the bed's clock.
427 const bed = profile.duckDb > 0 ? decode ( resolve (compDir, unescapeAttr (bedSrc))) : null ;
428 const duck = bed ? carveApi. analyseCarveDuck (voice, bed, SAMPLE_RATE , profile, 0 ) : [];
429
430 // Anything the author built by hand survives a carve; only the previous
431 // carve's own nodes are replaced. That is what `fromCarve` is for.
432 const existingChain = attrOf (bedTag, "data-fx-chain" );
433 const existingNodes = existingChain
434 ? fxApi. parseAudioFxChain ( unescapeAttr (existingChain)).nodes
435 : [];
436 const kept = existingNodes. filter (( n ) => ! n.fromCarve);
437 // Lanes belonging to the carve being replaced, addressed by the ids the OLD
438 // nodes had. Taken before anything is minted: those ids are freed by the
439 // replacement and a new node can be handed one of them, so reading them off the
440 // new chain would keep exactly the stale lanes it is supposed to drop.
441 const stalePrefixes = existingNodes. filter (( n ) => n.fromCarve && n.id). map (( n ) => `fx.${ n . id }.` );
442
443 let claimed = { version: 1 , nodes: kept };
444 const mint = ( node ) => {
445 const withId = { ... node, id: fxApi. mintAudioFxNodeId (claimed), fromCarve: true };
446 claimed = { version: 1 , nodes: [ ... claimed.nodes, withId] };
447 return withId;
448 };
449 const bandNodes = bands. map (( band ) => mint (carveApi. carveBandsToChain ([band]).nodes[ 0 ]));
450
451 const duckNode =
452 duck. length > 0
453 ? mint ({
454 type: "gain" ,
455 enabled: true ,
456 params: {
457 ... fxApi. defaultAudioFxParams ( "gain" ),
458 gain: 0 ,
459 },
460 })
461 : null ;
462 const chain = {
463 version: 1 ,
464 nodes: [ ... bandNodes, ... (duckNode ? [duckNode] : []), ... kept],
465 };
466
467 /**
468 * One carve envelope as a lane on the BED's clock.
469 *
470 * Nothing to shift: the voices were summed onto that clock before the analysis
471 * ran. A lane does hold its first value backwards to the start of its clip, so an
472 * envelope that begins later needs an explicit "no cut" at zero or the bed starts
473 * out ducked.
474 */
475 const laneFor = ( id , points ) => {
476 const timed = points
477 . map (( p ) => ({ t: Number (p.t. toFixed ( 3 )), v: p.v }))
478 . filter (( p ) => p.t >= 0 );
479 if ((timed[ 0 ]?.t ?? 0 ) > 0 ) timed. unshift ({ t: 0 , v: 0 });
480 return timed. length > 1 ? [{ target: `fx.${ id }.gain` , points: timed }] : [];
481 };
482
483 // Every carve follows the speech: a fixed depth thins the bed through every pause.
484 const carvedLanes = [
485 ... carveApi
486 . analyseCarveDynamics (voice, SAMPLE_RATE , bands)
487 . flatMap (( dyn , i ) => (bandNodes[i]?.id ? laneFor (bandNodes[i].id, dyn.points) : [])),
488 ... (duckNode?.id && duck. length > 0 ? laneFor (duckNode.id, duck) : []),
489 ];
490
491 // Hand-drawn lanes are kept the same way hand-built nodes are: by dropping only
492 // the ones that addressed the previous carve's nodes.
493 const existingAutomation = attrOf (bedTag, "data-automation" );
494 const carriedLanes = existingAutomation
495 ? ( JSON . parse ( unescapeAttr (existingAutomation)).lanes ?? []). filter (
496 ( lane ) => ! stalePrefixes. some (( prefix ) => String (lane.target). startsWith (prefix)),
497 )
498 : [];
499 const lanes = [ ... carriedLanes, ... carvedLanes];
500
501 const settings = {
502 enabled: true ,
503 sources: carveSources (voices, bedEl, members),
504 strength: args.strength,
505 };
506 // Say why the group form was declined, or the lint rule tells the author to
507 // group clips they have already grouped.
508 const refusal = groupSourceRefusal (voices, bedEl, members);
509 if (refusal) {
510 process.stderr. write (
511 refusal.reason === "bed"
512 ? `note bed ${ bedEl . id } is in group "${ refusal . group }" with the voices, so \n ` +
513 ` sources are clip ids: naming that group would carve the bed \n ` +
514 ` against itself on the next analysis. Move the bed to its own group. \n `
515 : `note group "${ refusal . group }" also holds ${ refusal . ids . join ( ", " ) }, which this run \n ` +
516 ` did not analyse (music/sfx by name), so sources are clip ids: naming \n ` +
517 ` the group would pull them into the sidechain on the next analysis. \n ` +
518 ` Move them out of the voice group. \n ` ,
519 );
520 }
521 const written =
522 ` data-fx-carve="${ escapeAttr ( JSON . stringify ( settings )) }"` +
523 ` data-fx-chain="${ escapeAttr ( fxApi . serializeAudioFxChain ( chain )) }"` +
524 (lanes. length > 0
525 ? ` data-automation="${ escapeAttr ( JSON . stringify ({ version: 1 , lanes })) }"`
526 : "" );
527
528 process.stdout. write (
529 `carve strength ${ args . strength }, ${ voices . length } voice${ voices . length === 1 ? "" : "s"} \n ` +
530 `bands ${ bands . map (( b ) => `${ b . freq }Hz ${ b . gainDb }dB q${ b . q }` ). join ( ", " ) } \n ` +
531 `level ${
532 duckNode
533 ? `${ duck . length }-point envelope, floor ${ Math . min ( ... duck . map (( p ) => p . v )) } dB`
534 : "no level match at this strength"
535 } \n ` +
536 `lanes ${ carvedLanes . length } carve${ carriedLanes . length ? ` + ${ carriedLanes . length } kept` : ""} \n ` ,
537 );
538 if (args.dryRun) {
539 process.stdout. write ( "dry run: nothing written \n " );
540 return ;
541 }
542
543 let stripped = bedTag;
544 for ( const attr of [ "data-fx-carve" , "data-fx-chain" , "data-automation" ]) {
545 stripped = stripped. replace ( new RegExp ( ` \\ s${ attr }="[^"]*"` , "i" ), "" );
546 }
547 // Inserted before the tag's own closing ">", which is the only place they can
548 // go: `stripped` is the opening tag alone, so appending would land outside it.
549 const nextTag = stripped. replace ( / \/ ? > $ / , ( close ) => `${ written }${ close }` );
550 if (nextTag === stripped) fail ( "attribute write produced no change — refusing to save" );
551 writeFileSync (compPath, html. replace (bedTag, nextTag));
552 process.stdout. write ( `wrote ${ args . comp } (id="${ bedEl . id }") \n ` );
553 }
554
555 // Only run as a CLI. Guarded so the pure helpers above can be unit-tested by
556 // importing this module (`skills/**/*.test.mjs`, run by `bun run test:skills`).
557 //
558 // realpath both sides: on macOS /tmp → /private/tmp, and node resolves the main
559 // module's symlinks in import.meta.url while argv[1] keeps the invoked spelling —
560 // a raw compare silently skips main() when invoked through any symlinked path.
561 function isMainModule ( importMetaUrl ) {
562 if ( ! process.argv[ 1 ]) return false ;
563 try {
564 return pathToFileURL ( realpathSync (process.argv[ 1 ])).href === importMetaUrl;
565 } catch {
566 return false ;
567 }
568 }
569
570 if ( isMainModule ( import . meta .url)) {
571 await main ();
572 }