mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: allow clip animation + ship <hyperframes-player> web component (#209)
## Summary Two independent initiatives that improve agent DX and expand HyperFrames' reach. ### Initiative 1: Fix the Clip Animation Footgun - `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element - All other properties (opacity, transform, x, y, scale, etc.) are allowed silently - This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1) ### Initiative 2: `<hyperframes-player>` Web Component - New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped - Iframe-based web component with Shadow DOM for perfect isolation - Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events - Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide - Full docs page at `docs/packages/player.mdx` ## Before / After ### Clip animation lint **Before (10/10 agents hit this):** ``` ✗ gsap_animates_clip_element: GSAP animation targets a clip element. Selector "#title" resolves to element <div id="title" class="clip">. The framework manages clip visibility — animate an inner wrapper instead. Fix: Wrap content in a child <div> and target that with GSAP. ``` **After (only errors on actual conflicts):** ``` # This passes lint — no error: tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0); # This still errors — actual conflict with runtime: tl.to("#title", { visibility: "hidden" }, 3); ✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element. Fix: Remove the visibility/display tween. Use opacity for fade effects. ``` ### Embeddable player **Before:** No way to embed a composition in a web page. **After:** ```html <script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script> <hyperframes-player src="./composition/index.html" controls></hyperframes-player> ``` ```js const player = document.querySelector('hyperframes-player'); player.play(); player.pause(); player.seek(2.5); player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration)); ``` ## Test plan - [x] 427 core tests pass (20 GSAP lint tests with smart detection) - [x] 7 player tests pass (formatTime + element registration) - [x] TypeScript compiles cleanly (core + player) - [x] Lint: GSAP animating clip with safe props → 0 errors - [x] Lint: GSAP animating clip with `visibility` → 1 error (correct) - [x] Player builds to 3.3KB gzipped ESM - [x] Lockfile updated for CI - [x] Docs page added at `docs/packages/player.mdx`
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
describe("GSAP rules", () => {
|
||||
it("reports error when GSAP targets a clip element by id", () => {
|
||||
it("does NOT error when GSAP animates opacity on a clip element (by id)", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
@@ -19,13 +19,10 @@ describe("GSAP rules", () => {
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#overlay");
|
||||
expect(finding?.message).toContain("inner wrapper");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when GSAP targets a clip element by class", () => {
|
||||
it("does NOT error when GSAP targets a clip element with safe properties (by class)", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
@@ -42,8 +39,7 @@ describe("GSAP rules", () => {
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".my-card");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT flag GSAP targeting a child of a clip element", () => {
|
||||
@@ -86,7 +82,7 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
|
||||
it("does NOT error when GSAP targets a clip element with safe properties (class-only, no id)", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
@@ -100,12 +96,118 @@ describe("GSAP rules", () => {
|
||||
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT error when GSAP animates opacity on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<h1>Title</h1>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { opacity: 0, y: -50, duration: 0.5 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT error when GSAP animates transform props on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="box" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<div>Box</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { scale: 1.2, x: 100, rotation: 45, duration: 0.5 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ERRORS when GSAP animates visibility on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<p>Overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#overlay", { visibility: "hidden", duration: 0.3 }, 2.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".scene-card");
|
||||
expect(finding?.elementId).toBeUndefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#overlay");
|
||||
expect(finding?.message).toContain("visibility");
|
||||
});
|
||||
|
||||
it("ERRORS when GSAP animates display on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="card" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<p>Card</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", { display: "none", duration: 0.3 }, 3.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#card");
|
||||
expect(finding?.message).toContain("display");
|
||||
});
|
||||
|
||||
it("ERRORS when GSAP tween mixes safe properties with visibility on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#overlay", { opacity: 0, visibility: "hidden", duration: 0.3 }, 2.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("visibility");
|
||||
});
|
||||
|
||||
it("warns when tl.to animates x on an element with CSS translateX", () => {
|
||||
|
||||
@@ -284,19 +284,24 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
}
|
||||
}
|
||||
|
||||
// gsap_animates_clip_element
|
||||
// gsap_animates_clip_element — only error when GSAP animates visibility/display
|
||||
for (const win of gsapWindows) {
|
||||
const sel = win.targetSelector;
|
||||
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
||||
if (!clipInfo) continue;
|
||||
const conflictingProps = win.properties.filter(
|
||||
(p) => p === "visibility" || p === "display",
|
||||
);
|
||||
if (conflictingProps.length === 0) continue;
|
||||
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
||||
findings.push({
|
||||
code: "gsap_animates_clip_element",
|
||||
severity: "error",
|
||||
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
|
||||
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
|
||||
selector: sel,
|
||||
elementId: clipInfo.id || undefined,
|
||||
fixHint: "Wrap content in a child <div> and target that with GSAP.",
|
||||
fixHint:
|
||||
"Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user