fix(core): read music sources through the shared HTML scanner (#5281)

This commit is contained in:
Abdullah M. Nazif
2026-10-08 19:30:10 +00:00
committed by GitHub
parent de1ef5a05a
commit f478fa6ff5
2 changed files with 85 additions and 12 deletions
+64
View File
@@ -26,4 +26,68 @@ describe("findMusicAudioSrc", () => {
const html = `<audio id="music" src="first.mp3"></audio><video id="bgm" src="v.mp4" data-has-audio="true">`;
expect(findMusicAudioSrc(html)).toBe("first.mp3");
});
it.each([
['<audio id="music" src="assets/don\'t-stop.wav">', "assets/don't-stop.wav"],
["<audio id='music' src='assets/say\"yes.wav'>", 'assets/say"yes.wav'],
['<audio id="music" src="assets/a>b.wav">', "assets/a>b.wav"],
['<audio id="music" src="assets/a&amp;b.wav">', "assets/a&b.wav"],
['<audio id="music" src="assets/don&#39;t-stop.wav">', "assets/don't-stop.wav"],
["<AUDIO ID=music SRC=assets/bed.wav>", "assets/bed.wav"],
])("reads the authored music source in %s", (html, src) => {
expect(findMusicAudioSrc(html)).toBe(src);
});
it.each([
'<audio data-id="music" src="wrong.wav">',
'<audio id="music" data-src="wrong.wav">',
'<audio title="id=\'music\'" src="wrong.wav">',
'<!-- <audio id="music" src="wrong.wav"> -->',
'<script>const markup = `<audio id="music" src="wrong.wav">`;</script>',
'<style>.example::after { content: \'<audio id="music" src="wrong.wav">\'; }</style>',
])("skips non-music markup in %s", (prefix) => {
expect(findMusicAudioSrc(`${prefix}<audio id="music" src="right.wav">`)).toBe("right.wav");
});
it("recognizes decoded music roles and sounding-video attributes", () => {
expect(
findMusicAudioSrc(
'<video src="bed.mp4" data-has-audio="tr&#117;e" data-timeline-role="mus&#105;c">',
),
).toBe("bed.mp4");
});
it.each(['muted="false"', "MUTED", "muted=''"])(
"skips a music video with the boolean attribute %s",
(muted) => {
expect(
findMusicAudioSrc(
`<video id="music" src="muted.mp4" data-has-audio="true" ${muted}><audio id="bgm" src="right.wav">`,
),
).toBe("right.wav");
},
);
it("does not treat text inside a quoted attribute as muted", () => {
expect(
findMusicAudioSrc(
'<video id="music" src="bed.mp4" data-has-audio="true" title="muted > example">',
),
).toBe("bed.mp4");
});
it("keeps the first duplicate attribute, like the browser", () => {
const prefix = '<audio id="speech" id="music" src="wrong.wav">';
expect(findMusicAudioSrc(`${prefix}<audio id="music" src="right.wav" src="wrong.wav">`)).toBe(
"right.wav",
);
});
it("keeps an explicit non-music role ahead of an id fallback", () => {
expect(
findMusicAudioSrc(
'<audio id="music" data-timeline-role="voiceover" src="speech.wav"><audio id="bgm" src="right.wav">',
),
).toBe("right.wav");
});
});
+21 -12
View File
@@ -2,6 +2,12 @@
// audio's project-relative path. Lives under `beats/` in the project so it
// survives the audio being removed and re-added.
import {
decodeAuthoredAttribute,
scanHtmlOpeningTags,
type HtmlOpeningTagSpan,
} from "@hyperframes/parsers";
interface BeatFileData {
version: 1;
audio: string;
@@ -82,14 +88,20 @@ export function parseBeats(content: string): { times: number[]; strengths: numbe
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
function attr(tag: string, name: string): string | null {
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"));
return m ? m[1]! : null;
function attr(tag: HtmlOpeningTagSpan, name: string): string | null {
const attribute = tag.attributes.find((value) => value.name === name);
return attribute?.kind === "value" ? decodeAuthoredAttribute(attribute.value) : null;
}
function isSoundingVideoTag(tag: string): boolean {
const unquoted = tag.replace(/"[^"]*"|'[^']*'/g, '""');
return attr(tag, "data-has-audio") === "true" && !/\smuted(?=[\s/>=])/i.test(unquoted);
function isSoundingVideoTag(tag: HtmlOpeningTagSpan): boolean {
return (
attr(tag, "data-has-audio") === "true" &&
!tag.attributes.some((attribute) => attribute.name === "muted")
);
}
function isMusicSourceTag(tag: HtmlOpeningTagSpan): boolean {
return tag.closed && (tag.name === "audio" || (tag.name === "video" && isSoundingVideoTag(tag)));
}
/**
@@ -98,14 +110,11 @@ function isSoundingVideoTag(tag: string): boolean {
* the FIRST `<audio>`, or `<video data-has-audio="true">` without `muted` (in
* document order) where data-timeline-role="music", or —
* when no role is set — whose id matches the music regex. An explicit non-music
* role excludes the element. Returns the raw src attribute, or null.
* role excludes the element. Returns the decoded src attribute, or null.
*/
export function findMusicAudioSrc(html: string): string | null {
// `[^>]*` spans newlines (it's a negated class, not `.`), so multi-line opening
// tags are handled. HyperFrames authors src as an attribute on <audio>.
const tags = html.match(/<(?:audio|video)\b[^>]*>/gi) ?? [];
for (const tag of tags) {
if (/^<video/i.test(tag) && !isSoundingVideoTag(tag)) continue;
for (const tag of scanHtmlOpeningTags(html)) {
if (!isMusicSourceTag(tag)) continue;
const src = attr(tag, "src");
if (!src) continue;
const role = attr(tag, "data-timeline-role");