mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): warn when a sub-composition slot blanks before the host (#1542)
A sub-composition mount whose data-duration ends before the host composition's window leaves its slot blank for the remainder. The runtime behavior is correct (data-duration is the slot's visible window and takes precedence), but a full-bleed sub-composition shorter than the composition is almost always an authoring mistake that fails silently (issue #1540). Add the subcomposition_blanks_before_host rule, scoped narrowly to the high-signal shape — a sole/dominant external mount starting at ~0 whose window ends before the host's — so it stays silent on intentional short clips. Document the slot-window semantics in the sub-compositions reference, distinguishing the hold-through-slot case (#911/#917) from the blank-when-shorter-than-host case.
This commit is contained in:
@@ -820,4 +820,115 @@ describe("composition rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subcomposition_blanks_before_host", () => {
|
||||
const find = (findings: Array<{ code: string }>) =>
|
||||
findings.find((f) => f.code === "subcomposition_blanks_before_host");
|
||||
|
||||
it("fires on the issue #1540 shape (child shorter than host)", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="331.224" data-width="1920" data-height="1080">
|
||||
<div id="decision-tree-comp" data-composition-id="decision-tree" data-composition-src="compositions/decision_tree.html" data-start="0" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
const finding = find(result.findings);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.message).toContain("blank");
|
||||
expect(finding?.message).toContain("331.224");
|
||||
});
|
||||
|
||||
it("fires when the mount starts within the start tolerance", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.3" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeDefined();
|
||||
});
|
||||
|
||||
it("fires at exactly the start tolerance boundary (start=0.5)", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.5" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeDefined();
|
||||
});
|
||||
|
||||
it("stays silent on an intentional short intro followed by another clip", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="15"></div>
|
||||
<div data-composition-id="body" data-composition-src="compositions/body.html" data-start="15" data-duration="45"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when the child matches the host window", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="15">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when the child is longer than the host", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="15">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="30"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent for a non-sub-composition timed element", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div class="clip" data-start="0" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when the root has no numeric data-duration", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent for a late-starting clip", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="40" data-duration="5"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent when an unknown-duration sibling covers the tail", async () => {
|
||||
const html = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
|
||||
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
|
||||
<div class="clip" data-start="0"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,4 +514,66 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// subcomposition_blanks_before_host
|
||||
// Warns when a full-bleed sub-composition slot ends before the host composition
|
||||
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
|
||||
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
|
||||
// stays silent on intentional short clips (an intro followed by other clips that
|
||||
// carry the timeline forward).
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, rootTag }) => {
|
||||
if (!rootTag) return [];
|
||||
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
|
||||
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
||||
|
||||
// Two independent knobs that happen to share a 0.5s magnitude. Tuned for
|
||||
// real hosts (tens to hundreds of seconds); on a very short host (~6s) the
|
||||
// EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
|
||||
// because the silent-blank trap this rule targets only matters at scale.
|
||||
const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
|
||||
const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
// Timed children of the root. An element with data-start but no usable
|
||||
// data-duration is treated as covering the tail (end = Infinity), so an
|
||||
// unknown-length sibling suppresses the warning rather than triggering it.
|
||||
const timed = tags
|
||||
.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
|
||||
.map((tag) => {
|
||||
const start = Number(readAttr(tag.raw, "data-start")) || 0;
|
||||
const dur = Number(readAttr(tag.raw, "data-duration"));
|
||||
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
|
||||
return { tag, start, end };
|
||||
});
|
||||
|
||||
// `tags` is a flat list (no nesting depth), so a timed element nested
|
||||
// *inside* a candidate slot is treated as a tail-covering sibling rather
|
||||
// than a descendant. Acceptable: external src mounts are empty by
|
||||
// convention (content is loaded from the linked file), so the only
|
||||
// false-negative path is rare and matches the flat-tag scope of the
|
||||
// sibling rules in this file.
|
||||
const tailCovered = (exceptIndex: number) =>
|
||||
timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
|
||||
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const t of timed) {
|
||||
if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
|
||||
if (t.start > START_TOLERANCE) continue; // must start at the composition start
|
||||
if (!Number.isFinite(t.end)) continue; // known, finite slot length
|
||||
if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
|
||||
if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
|
||||
const elementId = readAttr(t.tag.raw, "id") || undefined;
|
||||
const gap = round3(rootDuration - t.end);
|
||||
findings.push({
|
||||
code: "subcomposition_blanks_before_host",
|
||||
severity: "warning",
|
||||
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
|
||||
elementId,
|
||||
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
|
||||
snippet: truncateSnippet(t.tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
@@ -157,6 +157,13 @@ For the runtime end-to-end check (a fast `snapshot` pass + per-scene frame eyeba
|
||||
|
||||
**Do not** manually `master.add(child)` a sub-composition timeline into the host timeline. HyperFrames already drives them independently — nesting them in GSAP causes double-seeks.
|
||||
|
||||
### The host clip's `data-duration` is the slot's visible window
|
||||
|
||||
`data-duration` on the host clip defines **how long the slot is visible**, and it takes precedence over the sub-composition's internal GSAP timeline length. Two consequences follow:
|
||||
|
||||
- **Internal timeline shorter than the slot → the slot holds.** If the sub-composition's GSAP timeline finishes before `data-duration` elapses, the slot keeps showing its final frame for the rest of the window. You do **not** need to pad the timeline with empty tweens.
|
||||
- **`data-duration` shorter than the host composition → the slot ends (and goes blank) when its own `data-duration` elapses.** This is intended: the clip is a fixed-length window on the timeline, not "fill until the composition ends." To keep a sub-composition visible for the whole composition, set its `data-duration` to span the host window (or add another clip to cover the remaining time). Leaving a single full-bleed sub-composition shorter than the composition is almost always a mistake — the linter flags it as `subcomposition_blanks_before_host`.
|
||||
|
||||
## Animations Inside Sub-Compositions
|
||||
|
||||
Prefer `gsap.fromTo()` over `gsap.from()` for entrance tweens. The host re-seeks the sub-composition every time its clip becomes visible; `gsap.from()` records the starting state at registration and can desync on seek-back, while `gsap.fromTo()` declares both endpoints explicitly and replays cleanly.
|
||||
|
||||
Reference in New Issue
Block a user