From 0aa57b683fc2e020bbc99ea0708d8c4b89be89a6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 2 Jul 2026 22:01:06 -0700 Subject: [PATCH] feat(lint): warn on same-track clips that touch at an exact boundary isTimedElementVisibleAt's clip-visibility check is inclusive on both ends (currentTime >= start && currentTime <= end). When one clip's end exactly equals the next clip's start on the same track, both conditions hold for both clips at that instant, so they render simultaneously for a single frame - a real, independently reported glitch (two separate reports each diagnosed it themselves and worked around it by shaving ~1 frame off the earlier clip). Warning, not error: back-to-back zero-gap clips are an extremely common, usually-fine authoring pattern (most already avoid the glitch via an explicit exit hard-kill), so this flags the risk without blocking render. Reuses the OVERLAP_EPSILON_SECONDS threshold from the neighboring overlapping_clips_same_track check for a consistent float-slop tolerance. --- packages/lint/src/rules/composition.test.ts | 58 ++++++++++++++++++--- packages/lint/src/rules/composition.ts | 20 ++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index fcf7457d63..0b6895ea6e 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -373,7 +373,7 @@ describe("composition rules", () => { expect(finding).toBeUndefined(); }); - it("does not flag sequential clips on the same track", async () => { + it("does not flag (error) sequential clips on the same track, but warns on the touching boundary", async () => { const html = `
@@ -386,11 +386,15 @@ describe("composition rules", () => { `; const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); - expect(finding).toBeUndefined(); + expect( + result.findings.find((f) => f.code === "overlapping_clips_same_track"), + ).toBeUndefined(); + const warning = result.findings.find((f) => f.code === "adjacent_clips_touch_exact_boundary"); + expect(warning).toBeDefined(); + expect(warning?.severity).toBe("warning"); }); - it("does not flag adjacencies where parseFloat + add drifts by a few ulps", async () => { + it("does not flag (error) adjacencies where parseFloat + add drifts by a few ulps, but warns on the touching boundary", async () => { // parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004 const html = ` @@ -404,8 +408,50 @@ describe("composition rules", () => { `; const result = await lintHyperframeHtml(html); - const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); - expect(finding).toBeUndefined(); + expect( + result.findings.find((f) => f.code === "overlapping_clips_same_track"), + ).toBeUndefined(); + expect( + result.findings.find((f) => f.code === "adjacent_clips_touch_exact_boundary"), + ).toBeDefined(); + }); + }); + + describe("adjacent_clips_touch_exact_boundary", () => { + it("does not fire when there is a real gap between clips", async () => { + const html = ` + +
+
A
+
B
+
+ +`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "adjacent_clips_touch_exact_boundary"), + ).toBeUndefined(); + }); + + it("does not fire when clips genuinely overlap (that's overlapping_clips_same_track's job)", async () => { + const html = ` + +
+
A
+
B
+
+ +`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "adjacent_clips_touch_exact_boundary"), + ).toBeUndefined(); }); }); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 1e96250997..966dd3af61 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -414,7 +414,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding const current = clips[i]; const next = clips[i + 1]; if (!current || !next) continue; - if (current.end - next.start > OVERLAP_EPSILON_SECONDS) { + const gap = next.start - current.end; + if (gap < -OVERLAP_EPSILON_SECONDS) { findings.push({ code: "overlapping_clips_same_track", severity: "error", @@ -422,6 +423,23 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding fixHint: "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.", }); + } else if (gap <= OVERLAP_EPSILON_SECONDS) { + // The runtime's clip-visibility check (isTimedElementVisibleAt) is + // inclusive on both ends: currentTime >= start && currentTime <= + // end. When one clip's end exactly equals the next clip's start, + // both conditions are true for both clips at that single instant, + // so they render simultaneously for one frame — a real, observed + // double-visibility glitch, not a false positive. Warning, not + // error: authors legitimately chain clips back-to-back with zero + // gap constantly, and a hard-kill on the earlier clip's exit + // already avoids the glitch in most compositions. + findings.push({ + code: "adjacent_clips_touch_exact_boundary", + severity: "warning", + message: `Track ${track}: clip ending at ${current.end}s and the next clip starting at ${next.start}s touch at the exact same instant. The runtime's clip-visibility check is inclusive on both ends, so both clips can render simultaneously for one frame at that boundary.`, + fixHint: + 'Shave roughly one frame (~1/fps seconds) off the earlier clip\'s data-duration, or add an explicit hard-kill (tl.set(..., { visibility: "hidden" }, ...)) on the earlier clip at its exit, so only one clip is visible at the boundary instant.', + }); } } }