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 = `
+
+
+
+`;
+ 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 = `
+
+
+
+`;
+ 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.',
+ });
}
}
}