@@ -634,6 +634,15 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
634634 findVolumeTweenOverridesGainFindings ,
635635 // audio_carve_ungrouped_sources
636636 findCarveUngroupedSourcesFindings ,
637+
638+ // audio_group_no_members
639+ findAudioGroupNoMembersFindings ,
640+
641+ // audio_group_timing_attrs
642+ findAudioGroupTimingAttrFindings ,
643+
644+ // audio_group_carve_attr
645+ findAudioGroupCarveAttrFindings ,
637646] ;
638647
639648/**
@@ -769,3 +778,134 @@ function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFind
769778 }
770779 return findings ;
771780}
781+
782+ /** Timing attributes a bus must never carry. It has no clip window of its own:
783+ * a group's automation clock is COMPOSITION time, and its members carry the
784+ * timing. */
785+ const AUDIO_GROUP_TIMING_ATTRS = [ "data-start" , "data-duration" , "data-track-index" ] as const ;
786+
787+ /**
788+ * A bus nobody joined does nothing, silently.
789+ *
790+ * `resolveAudioGroups` builds groups from the MEMBERS (`audio[data-audio-group]`)
791+ * and only then looks for a matching `<hf-audio-group>` element, so a bus whose
792+ * id no clip names is dropped entirely — its fader, FX chain and automation
793+ * never reach preview or render, and nothing says so. One typo is enough:
794+ * `data-audio-group="voiceovr"` against `id="voiceover"` loses the authored bus
795+ * AND invents a phantom group at unity gain with no chain, which is what the
796+ * timeline then draws.
797+ */
798+ function findAudioGroupNoMembersFindings ( ctx : LintContext ) : HyperframeLintFinding [ ] {
799+ const memberGroupIds = new Set (
800+ ctx . tags
801+ . filter ( ( tag ) => tag . name === "audio" )
802+ . map ( ( tag ) => readAttr ( tag . raw , "data-audio-group" ) )
803+ . filter ( ( id ) : id is string => Boolean ( id ) ) ,
804+ ) ;
805+
806+ // Only a file that declares SOME membership can be judged. `lintHyperframeHtml`
807+ // sees one file, and the studio's own group creation writes the bus into the
808+ // active composition while patching `data-audio-group` into each member's own
809+ // file (`timelineAudioGroupCreate`) — so a file carrying a bus and no members
810+ // at all is the ordinary cross-file shape. Firing there reported the studio's
811+ // own output as an error, and said "No clip carries `data-audio-group` at all"
812+ // about clips it simply could not see.
813+ if ( memberGroupIds . size === 0 ) return [ ] ;
814+ const mayHaveCrossFileMembers = ctx . tags . some ( ( tag ) =>
815+ Boolean ( readAttr ( tag . raw , "data-composition-src" ) ) ,
816+ ) ;
817+
818+ const findings : HyperframeLintFinding [ ] = [ ] ;
819+ for ( const tag of ctx . tags ) {
820+ if ( tag . name !== "hf-audio-group" ) continue ;
821+ // A bus with no id cannot be joined at all — a different mistake, and
822+ // `resolveAudioGroups` skips it when building its element map.
823+ const elementId = readAttr ( tag . raw , "id" ) ;
824+ if ( ! elementId ) continue ;
825+ if ( memberGroupIds . has ( elementId ) ) continue ;
826+ // A mixed file is still not closed-world: one bus may have local members
827+ // while another serves clips inside a referenced composition. The linter
828+ // cannot inspect that file here, so an unmatched bus is only provably empty
829+ // when this source has no cross-file composition hosts at all.
830+ if ( mayHaveCrossFileMembers ) continue ;
831+
832+ // Naming the near-misses is the whole value: the fix is almost always a
833+ // typo on one member, and the author is looking at the bus, not the clip.
834+ const nearby = [ ...memberGroupIds ] . filter ( ( id ) => id !== elementId ) ;
835+ const suffix =
836+ nearby . length > 0
837+ ? ` Clips in this file name ${ nearby . map ( ( id ) => `"${ id } "` ) . join ( ", " ) } instead.`
838+ : "" ;
839+ findings . push ( {
840+ code : "audio_group_no_members" ,
841+ severity : "error" ,
842+ message : `#${ elementId } is an audio group no clip belongs to, so its fader, effect chain and automation are dropped.${ suffix } ` ,
843+ elementId,
844+ fixHint : `Add \`data-audio-group="${ elementId } "\` to the clips this bus is for, or delete the bus.` ,
845+ snippet : truncateSnippet ( tag . raw ) ,
846+ } ) ;
847+ }
848+ return findings ;
849+ }
850+
851+ /**
852+ * Timing on a bus is meaningless — and it is how a phantom clip row appears.
853+ *
854+ * The preview runtime stamps `data-start`/`data-duration` on id'd children of
855+ * the composition root so they show up in the timeline; a bus caught by that
856+ * became a full-duration clip row above its own group header, draggable and
857+ * deletable (fixed in core). Timing PERSISTED into the file is the same shape
858+ * with none of the excuse: the render reads a group's `fxChain`, `automation`
859+ * and `volume` only, so these attributes change nothing and mislead the next
860+ * reader into thinking the bus has a window.
861+ */
862+ function findAudioGroupTimingAttrFindings ( ctx : LintContext ) : HyperframeLintFinding [ ] {
863+ const findings : HyperframeLintFinding [ ] = [ ] ;
864+ for ( const tag of ctx . tags ) {
865+ if ( tag . name !== "hf-audio-group" ) continue ;
866+ const present = AUDIO_GROUP_TIMING_ATTRS . filter ( ( attr ) => hasAttrName ( tag . raw , attr ) ) ;
867+ if ( present . length === 0 ) continue ;
868+ const elementId = readAttr ( tag . raw , "id" ) || undefined ;
869+ findings . push ( {
870+ code : "audio_group_timing_attrs" ,
871+ severity : "warning" ,
872+ message : `${ elementId ? `#${ elementId } ` : "This audio group" } carries ${ present . map ( ( attr ) => `\`${ attr } \`` ) . join ( ", " ) } , which a bus has no use for — its members carry the timing and its automation clock is composition time.` ,
873+ elementId,
874+ fixHint : `Remove ${ present . map ( ( attr ) => `\`${ attr } \`` ) . join ( ", " ) } from the group element.` ,
875+ snippet : truncateSnippet ( tag . raw ) ,
876+ } ) ;
877+ }
878+ return findings ;
879+ }
880+
881+ /**
882+ * A carve on a bus is half an effect, applied twice.
883+ *
884+ * `data-fx-carve` is a CLIP attribute. The bed being carved is one track, and
885+ * the level half of the analysis measures that track's own audio against the
886+ * voice — a bus has no `src`, so a carve there can only ever produce the
887+ * spectral half: filters with no level match.
888+ *
889+ * Worse, it stacks. A bus and a member clip are the same signal path, so a
890+ * carve on each puts the bed through both sets of filters — which is exactly
891+ * what happened when a bus labelled "Music bed" classified as one and carved
892+ * itself (fixed in Studio; this catches what was already written down).
893+ */
894+ function findAudioGroupCarveAttrFindings ( ctx : LintContext ) : HyperframeLintFinding [ ] {
895+ const findings : HyperframeLintFinding [ ] = [ ] ;
896+ for ( const tag of ctx . tags ) {
897+ if ( tag . name !== "hf-audio-group" ) continue ;
898+ if ( ! hasAttrName ( tag . raw , "data-fx-carve" ) ) continue ;
899+ const elementId = readAttr ( tag . raw , "id" ) || undefined ;
900+ findings . push ( {
901+ code : "audio_group_carve_attr" ,
902+ severity : "warning" ,
903+ message : `${ elementId ? `#${ elementId } ` : "This audio group" } carries \`data-fx-carve\`, which belongs on the clip being carved — a bus has no audio of its own to level-match against, and a carve here stacks with any its members already have.` ,
904+ elementId,
905+ fixHint :
906+ "Remove `data-fx-carve` and the `fromCarve` nodes it wrote into this bus's `data-fx-chain`, and carve the bed clip instead." ,
907+ snippet : truncateSnippet ( tag . raw ) ,
908+ } ) ;
909+ }
910+ return findings ;
911+ }
0 commit comments