Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 63 additions & 18 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -228,24 +282,24 @@ 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;
});

// 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");
Expand All @@ -260,9 +314,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
compositionSrc: compositionSrc ?? undefined,
});
}

return match;
});
}

return { html: restore(html), unresolved };
}
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}"`);

Expand Down
Loading