mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): bound timing compiler opening tag scans (#3719)
* fix(core): bound timing compiler opening tag scans * fix(core): bound ID-targeted duration tag scans
This commit is contained in:
@@ -95,6 +95,88 @@ describe("inert region scanning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening tag scanning", () => {
|
||||
it.each([injectDurations, clampDurations])(
|
||||
"keeps long unclosed ID-targeted tags unchanged in %p",
|
||||
(write) => {
|
||||
const html = '<video id="target" '.repeat(100_000);
|
||||
expect(write(html, [{ id: "target", duration: 3 }])).toBe(html);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["target", "a>b", "a<b", "a.b[0]"])(
|
||||
"preserves substring-ID matches and delimiter characters for %j",
|
||||
(id) => {
|
||||
const html = `<video data-id="${id}" data-start="1" data-duration="bad" data-end="4">`;
|
||||
expect(injectDurations(html, [{ id, duration: 3 }])).toBe(
|
||||
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
|
||||
);
|
||||
expect(clampDurations(html, [{ id, duration: 3 }])).toBe(
|
||||
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves similar IDs alone and applies repeated resolutions in order", () => {
|
||||
const html = '<video id="a.b" data-start="1" data-duration="bad"><video id="axb">';
|
||||
const resolutions = [
|
||||
{ id: "a.b", duration: 3 },
|
||||
{ id: "a.b", duration: 5 },
|
||||
];
|
||||
expect(injectDurations(html, resolutions)).toBe(
|
||||
'<video id="a.b" data-start="1" data-duration="3" data-end="4"><video id="axb">',
|
||||
);
|
||||
expect(clampDurations(html, resolutions)).toBe(
|
||||
'<video id="a.b" data-start="1" data-duration="5"><video id="axb">',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["<video", "<audio", "<div", "<section", "<video<audio<div<section"])(
|
||||
"preserves an unclosed %j suffix after compiling complete media",
|
||||
(prefix) => {
|
||||
const media = '<video id="v" data-start="1" data-duration="2">';
|
||||
const suffix = prefix.repeat(100_000);
|
||||
expect(compileTimingAttrs(media + suffix)).toEqual({
|
||||
html: compileTimingAttrs(media).html + suffix,
|
||||
unresolved: [],
|
||||
});
|
||||
expect(extractResolvedMedia(media + suffix).map((el) => el.id)).toEqual(["v"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps separate media ID counters and video/audio/composition resolution order", () => {
|
||||
const html = '<audio><VIDEO><section id="scene" data-start="0"><audio>';
|
||||
const result = compileTimingAttrs(html);
|
||||
expect(result.unresolved.map((el) => el.id)).toEqual([
|
||||
"hf-video-0",
|
||||
"hf-audio-0",
|
||||
"hf-audio-1",
|
||||
"scene",
|
||||
]);
|
||||
expect(result.html.indexOf('id="hf-audio-0"')).toBeLessThan(
|
||||
result.html.indexOf('id="hf-video-0"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts mixed-case media in source order", () => {
|
||||
const html = '<AUDIO id="a" data-duration="2"><video id="v" data-duration="3">';
|
||||
expect(extractResolvedMedia(html).map((el) => [el.id, el.tagName, el.duration])).toEqual([
|
||||
["a", "audio", 2],
|
||||
["v", "video", 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the existing first-greater-than boundary even inside a quoted value", () => {
|
||||
const html = '<video title="a>b" data-duration="2">';
|
||||
const result = compileTimingAttrs(html);
|
||||
expect(result.html).toBe(
|
||||
'<video title="a id="hf-video-0" data-start="0" data-hf-auto-start="" data-has-audio="true">b" data-duration="2">',
|
||||
);
|
||||
expect(result.unresolved.map((el) => el.id)).toEqual(["hf-video-0"]);
|
||||
expect(extractResolvedMedia(html)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compileTimingAttrs", () => {
|
||||
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
|
||||
"does not partially parse invalid literal data-duration=%j",
|
||||
|
||||
@@ -154,6 +154,60 @@ function maskInertRegions(html: string): { masked: string; restore: (s: string)
|
||||
|
||||
// ── Core compilation ─────────────────────────────────────────────────────
|
||||
|
||||
function* iterateOpeningTags(html: string, prefix: RegExp) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = prefix.exec(html)) !== null) {
|
||||
const closing = html.indexOf(">", prefix.lastIndex);
|
||||
// Without a closer, no later prefix can form a complete tag either.
|
||||
if (closing < 0) break;
|
||||
const end = closing + 1;
|
||||
yield { tag: html.slice(match.index, end), index: match.index, end };
|
||||
prefix.lastIndex = end;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOpeningTags(
|
||||
html: string,
|
||||
prefix: RegExp,
|
||||
replace: (tag: string) => string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let cursor = 0;
|
||||
for (const { tag, index, end } of iterateOpeningTags(html, prefix)) {
|
||||
parts.push(html.slice(cursor, index), replace(tag));
|
||||
cursor = end;
|
||||
}
|
||||
parts.push(html.slice(cursor));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function replaceIdTags(html: string, id: string, replace: (tag: string) => string): string {
|
||||
const idPattern = new RegExp(`id=["']${escapeRegex(id)}["']`, "gi");
|
||||
const lastClosing = html.lastIndexOf(">");
|
||||
const parts: string[] = [];
|
||||
let cursor = 0;
|
||||
let candidate = idPattern.exec(html);
|
||||
for (const { index, end } of iterateOpeningTags(html, /</g)) {
|
||||
if (index < cursor) continue;
|
||||
let targetEnd = -1;
|
||||
while (candidate && candidate.index < end) {
|
||||
const candidateEnd = candidate.index + candidate[0].length;
|
||||
if (candidate.index > index && candidateEnd <= lastClosing) targetEnd = candidateEnd;
|
||||
// Preserve the old greedy prefix's last matching ID, including overlaps.
|
||||
idPattern.lastIndex = candidate.index + 1;
|
||||
candidate = idPattern.exec(html);
|
||||
}
|
||||
if (targetEnd < 0) continue;
|
||||
// An authored ID can itself contain '>', so its closer may follow the
|
||||
// initial span. The lastClosing check guarantees a closing delimiter.
|
||||
const closing = html.indexOf(">", targetEnd) + 1;
|
||||
parts.push(html.slice(cursor, index), replace(html.slice(index, closing)));
|
||||
cursor = closing;
|
||||
}
|
||||
parts.push(html.slice(cursor));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function compileTag(
|
||||
tag: string,
|
||||
isVideo: boolean,
|
||||
@@ -228,14 +282,14 @@ export function compileTimingAttrs(html: string): CompilationResult {
|
||||
html = masked;
|
||||
|
||||
// Process <video ...> tags
|
||||
html = html.replace(/<video[^>]*>/gi, (match) => {
|
||||
html = replaceOpeningTags(html, /<video/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, true, () => nextVideoId++);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
});
|
||||
|
||||
// Process <audio ...> tags
|
||||
html = html.replace(/<audio[^>]*>/gi, (match) => {
|
||||
html = replaceOpeningTags(html, /<audio/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, false, () => nextAudioId++);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
@@ -243,9 +297,9 @@ export function compileTimingAttrs(html: string): CompilationResult {
|
||||
|
||||
// Identify unresolved timed elements (divs with data-start but no data-end/data-duration)
|
||||
// These are typically compositions whose duration depends on GSAP timelines
|
||||
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
|
||||
if (!hasAttr(match, "data-start")) return match;
|
||||
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) return match;
|
||||
for (const { tag: match } of iterateOpeningTags(html, /<(?:div|section)/gi)) {
|
||||
if (!hasAttr(match, "data-start")) continue;
|
||||
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) continue;
|
||||
|
||||
const id = getAttr(match, "id");
|
||||
const compositionSrc = getAttr(match, "data-composition-src");
|
||||
@@ -260,9 +314,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
|
||||
compositionSrc: compositionSrc ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
return { html: restore(html), unresolved };
|
||||
}
|
||||
@@ -277,9 +329,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
|
||||
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of resolutions) {
|
||||
// Match the element's opening tag by id
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
html = replaceIdTags(html, id, (tag) => {
|
||||
let result = tag;
|
||||
|
||||
// Add data-duration if missing
|
||||
@@ -314,10 +364,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
|
||||
const resolved: ResolvedMediaElement[] = [];
|
||||
|
||||
html = maskInertRegions(html).masked;
|
||||
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mediaRegex.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
for (const { tag } of iterateOpeningTags(html, /<(?:video|audio)/gi)) {
|
||||
const id = getAttr(tag, "id");
|
||||
const durationStr = getAttr(tag, "data-duration");
|
||||
if (!id || durationStr === null) continue;
|
||||
@@ -351,9 +398,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
|
||||
*/
|
||||
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of clamps) {
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
html = replaceIdTags(html, id, (tag) => {
|
||||
// Replace data-duration value
|
||||
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user