mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): read music sources through the shared HTML scanner (#5281)
This commit is contained in:
@@ -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&b.wav">', "assets/a&b.wav"],
|
||||
['<audio id="music" src="assets/don'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="true" data-timeline-role="music">',
|
||||
),
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user