Skip to content
Open
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
58 changes: 52 additions & 6 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
Expand All @@ -386,11 +386,15 @@ describe("composition rules", () => {
</script>
</body></html>`;
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 = `
<html><body>
Expand All @@ -404,8 +408,50 @@ describe("composition rules", () => {
</script>
</body></html>`;
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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
<div class="clip" data-start="2.5" data-duration="2" data-track-index="0">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
<div class="clip" data-start="2" data-duration="3" data-track-index="0">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "adjacent_clips_touch_exact_boundary"),
).toBeUndefined();
});
});

Expand Down
20 changes: 19 additions & 1 deletion packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,14 +414,32 @@ 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",
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
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.',
});
}
}
}
Expand Down
Loading