Skip to content
11 changes: 6 additions & 5 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,16 @@
"file": "packages/studio/src/utils/timelineElementSplit.ts",
"exports": ["buildPatchTarget", "readFileContent"],
},
// freezeUrl and freezeLocalFile are public API for Task 4 manifest flow
// and the /figma skill integration; not yet imported by current code.
// freezeUrl and freezeLocalFile are public API re-exported from the figma
// barrel (index.ts) for Task 4 manifest flow and the /figma skill integration;
// not yet imported by current code outside the module.
{
"file": "packages/core/src/figma/freeze.ts",
"exports": ["freezeUrl", "freezeLocalFile"],
},
// mediaDir, typeDirPath, isFigmaManifestRecord: exported from manifest.ts
// for barrel wiring in Task 8 (one-batch export across Tasks 2-7).
// Not yet consumed by any code in the current stack.
// mediaDir, typeDirPath, isFigmaManifestRecord: re-exported from the figma
// barrel (index.ts) via manifest.ts per Task 8 wiring. Consumed only by the
// /figma skill integration, not by code in the current codebase.
{
"file": "packages/core/src/figma/manifest.ts",
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ Open-source video rendering framework: write HTML, render video.

## Skills

This repo ships 20 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover.
This repo ships 21 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover.

```bash
npx skills add heygen-com/hyperframes # interactive picker
npx skills add heygen-com/hyperframes --all # install all 20 (skips picker)
npx skills add heygen-com/hyperframes --all # install all 21 (skips picker)
npx skills add heygen-com/hyperframes --skill <name> # just one (bare name, no leading slash)
```

Expand Down Expand Up @@ -40,6 +40,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
- `/media-use` — resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking; keeps search noise on disk.
- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering).
- `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream.
- `/figma` — import Figma assets, tokens, components, and Motion animations into a composition (MCP-first).

## Skill catalog maintenance

Expand All @@ -48,7 +49,7 @@ When adding a new skill, or substantially renaming / repurposing an existing one
1. The skill list above (CLAUDE.md) AND the `## Skills` section in `README.md` AND `docs/guides/skills.mdx` (rendered at [hyperframes.heygen.com/guides/skills](https://hyperframes.heygen.com/guides/skills)). Out-of-date entries silently kill discovery.
2. If the skill changes the routing surface for "make a video" requests, also update the capability map and intent router in `skills/hyperframes/SKILL.md` — that's the canonical router agents read first.
3. Mirror the Router / Creation workflows / Domain skills grouping across all three surfaces so a skill always lives in the same column.
4. Skill count appears in the README and CLAUDE.md intro lines ("20 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page deliberately omits a count to avoid drift; keep it count-free.
4. Skill count appears in the README and CLAUDE.md intro lines ("21 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page deliberately omits a count to avoid drift; keep it count-free.

The skill's own `SKILL.md` frontmatter `description:` is the source of truth for the one-line "use when" blurb; copy from there into the catalog rather than paraphrasing.

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ The skills teach agents the HyperFrames production loop: plan the video, write v

## Skills

HyperFrames ships 20 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a video" request and points to the domain skills below.
HyperFrames ships 21 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a video" request and points to the domain skills below.

Run `npx skills add heygen-com/hyperframes` for the interactive picker, `npx skills add heygen-com/hyperframes --all` to install all 20 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
Run `npx skills add heygen-com/hyperframes` for the interactive picker, `npx skills add heygen-com/hyperframes --all` to install all 21 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).

### Router

Expand Down Expand Up @@ -89,6 +89,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
| `/figma` | Import Figma assets, tokens, components, and Motion animations into a composition (MCP-first). |

For visual design handoff workflows, see the [Claude Design guide](https://hyperframes.heygen.com/guides/claude-design) and [Open Design guide](https://hyperframes.heygen.com/guides/open-design).

Expand Down
1 change: 1 addition & 0 deletions docs/guides/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
| `/figma` | Import Figma assets, tokens, components, and Motion animations into a composition (MCP-first). |

## Source of truth

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/server/studioRenderTelemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ const fullPerf: RenderPerfSummary = {
extractMs: 60,
cacheHits: 3,
cacheMisses: 4,
cachePublishFailures: 0,
cacheGcEvictions: 0,
cacheGcBytesFreed: 0,
cacheAgedPartialsCleared: 0,
},
tmpPeakBytes: 1024,
captureAvgMs: 13,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/figma/emitTimelineScript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { emitTimelineScript } from "./emitTimelineScript";
import { motionToGsap } from "./motionToGsap";
import type { MotionDoc } from "./types";

const doc: MotionDoc = {
selector: "#hero-headline",
tracks: [
{
property: "opacity",
values: [0, 1, 0],
times: [0, 0.5, 1],
ease: ["linear", [0.539, 0, 0.312, 0.995]],
duration: 2,
repeat: Infinity,
},
],
};

describe("emitTimelineScript", () => {
const script = emitTimelineScript(motionToGsap(doc));

it("creates a paused timeline and never emits repeat:-1", () => {
expect(script).toContain("gsap.timeline({ paused: true })");
expect(script).not.toContain("repeat: -1");
});
it("registers under a string-literal __timelines key", () => {
expect(script).toContain('window.__timelines["figma-hero-headline"] = tl;');
});
it("uses string-literal selectors and sets the initial value", () => {
expect(script).toContain('tl.set("#hero-headline", { opacity: 0 }, 0);');
expect(script).toContain('tl.to("#hero-headline", { keyframes: [');
});
it("registers a CustomEase for the bezier segment", () => {
expect(script).toContain('CustomEase.create("hfCe0", "M0,0 C0.539,0 0.312,0.995 1,1");');
});
});

describe("emitTimelineScript runtime guard", () => {
it("wraps the script in an IIFE that warns when gsap/CustomEase are missing", () => {
const script = emitTimelineScript(motionToGsap(doc));
expect(script).toContain('typeof gsap === "undefined"');
expect(script).toContain("console.warn");
expect(script.startsWith("(function () {")).toBe(true);
expect(script.endsWith("})();")).toBe(true);
});
});
53 changes: 53 additions & 0 deletions packages/core/src/figma/emitTimelineScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { GsapTween, TimelineSpec } from "./types";

function lit(value: string): string {
return JSON.stringify(value);
}

function num(value: number): number {
return Math.round(value * 1e6) / 1e6;
}

function val(value: number | string): string {
return typeof value === "number" ? String(num(value)) : JSON.stringify(value);
}

function emitTween(t: GsapTween): string[] {
const set = `tl.set(${lit(t.selector)}, { ${t.property}: ${val(t.initial)} }, 0);`;
const kf = t.steps
.map(
(s) =>
`{ ${t.property}: ${val(s.value)}, duration: ${num(s.duration)}, ease: ${lit(s.ease)} }`,
)
.join(", ");
const repeat = t.repeat > 0 ? `, repeat: ${t.repeat}` : "";
return [set, `tl.to(${lit(t.selector)}, { keyframes: [${kf}]${repeat} }, 0);`];
}

export function emitTimelineScript(spec: TimelineSpec): string {
const lines: string[] = [];
// Guard the whole script: if the composition author forgot the GSAP or
// CustomEase CDN tag, warn loudly instead of throwing mid-script and
// silently never registering the timeline.
lines.push("(function () {");
const needsCustomEase = spec.customEases.length > 0;
const missing = needsCustomEase
? 'typeof gsap === "undefined" || typeof CustomEase === "undefined"'
: 'typeof gsap === "undefined"';
const libs = needsCustomEase ? "gsap + CustomEase" : "gsap";
lines.push(
`if (${missing}) { console.warn(${lit(`figma timeline ${spec.timelineId}: ${libs} not loaded — add the CDN <script> tags before this one`)}); return; }`,
);
for (const ce of spec.customEases) {
const [x1, y1, x2, y2] = ce.bezier;
lines.push(
`CustomEase.create(${lit(ce.name)}, "M0,0 C${num(x1)},${num(y1)} ${num(x2)},${num(y2)} 1,1");`,
);
}
lines.push("const tl = gsap.timeline({ paused: true });");
for (const t of spec.tweens) lines.push(...emitTween(t));
lines.push("window.__timelines = window.__timelines || {};");
lines.push(`window.__timelines[${lit(spec.timelineId)}] = tl;`);
lines.push("})();");
return lines.join("\n");
}
25 changes: 23 additions & 2 deletions packages/core/src/figma/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,23 @@
export * from "./types";
export { MAX_FREEZE_BYTES } from "./freeze";
export type * from "./types";
export { parseFigmaRef } from "./parseFigmaRef";
export {
MAX_FREEZE_BYTES,
exceedsFreezeCap,
freezeBytes,
freezeUrl,
freezeLocalFile,
} from "./freeze";
export {
mediaDir,
manifestPath,
typeDirPath,
isFigmaManifestRecord,
readManifest,
appendRecord,
findByFigmaNode,
nextId,
} from "./manifest";
export { buildAssetSnippet } from "./assetSnippet";
export { mapEase } from "./motionEase";
export { motionToGsap } from "./motionToGsap";
export { emitTimelineScript } from "./emitTimelineScript";
45 changes: 45 additions & 0 deletions packages/core/src/figma/motionEase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { mapEase } from "./motionEase";

describe("mapEase", () => {
it("maps linear to none", () => {
expect(mapEase("linear")).toEqual({ kind: "named", ease: "none" });
});
it("maps a bezier array through unchanged", () => {
expect(mapEase([0.539, 0, 0.312, 0.995])).toEqual({
kind: "bezier",
bezier: [0.539, 0, 0.312, 0.995],
});
});
it("maps named eases to GSAP equivalents (case/format insensitive)", () => {
expect(mapEase("easeOut")).toEqual({ kind: "named", ease: "power2.out" });
expect(mapEase("EASE_IN_AND_OUT")).toEqual({
kind: "named",
ease: "power2.inOut",
});
expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" });
});
it("falls back to none for unknown named eases", () => {
expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" });
});
});

describe("mapEase validation + coverage", () => {
it("rejects malformed bezier arrays (wrong length / NaN) to linear", () => {
expect(mapEase([0.5, 0, 0.3] as unknown as [number, number, number, number])).toEqual({
kind: "named",
ease: "none",
});
expect(mapEase([0.5, Number.NaN, 0.3, 1])).toEqual({ kind: "named", ease: "none" });
});
it("covers circ/expo/bounce/elastic/anticipate/spring", () => {
expect(mapEase("circOut")).toEqual({ kind: "named", ease: "circ.out" });
expect(mapEase("expoInOut")).toEqual({ kind: "named", ease: "expo.inOut" });
expect(mapEase("bounceOut")).toEqual({ kind: "named", ease: "bounce.out" });
expect(mapEase("elasticOut")).toEqual({ kind: "named", ease: "elastic.out" });
expect(mapEase("anticipate")).toEqual({ kind: "named", ease: "back.in" });
expect(mapEase("spring")).toEqual({ kind: "named", ease: "elastic.out" });
});
});
47 changes: 47 additions & 0 deletions packages/core/src/figma/motionEase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { MappedEase, MotionEase } from "./types";

// Full motion.dev named-ease coverage → nearest GSAP equivalent. Anything
// outside this table falls back to "none" (linear) — documented in the
// /figma skill's motion section so the fallback is never a surprise.
const NAMED_EASE: Record<string, string> = {
linear: "none",
ease: "power1.inOut",
easein: "power2.in",
easeout: "power2.out",
easeinout: "power2.inOut",
easeinandout: "power2.inOut",
backin: "back.in",
backout: "back.out",
backinout: "back.inOut",
backinandout: "back.inOut",
circin: "circ.in",
circout: "circ.out",
circinout: "circ.inOut",
expoin: "expo.in",
expoout: "expo.out",
expoinout: "expo.inOut",
bouncein: "bounce.in",
bounceout: "bounce.out",
bounceinout: "bounce.inOut",
elasticin: "elastic.in",
elasticout: "elastic.out",
elasticinout: "elastic.inOut",
anticipate: "back.in",
spring: "elastic.out",
hold: "steps(1)",
};

function isBezier4(ease: unknown[]): ease is [number, number, number, number] {
return ease.length === 4 && ease.every((n) => typeof n === "number" && Number.isFinite(n));
}

export function mapEase(ease: MotionEase): MappedEase {
if (Array.isArray(ease)) {
// Runtime-validate the 4-tuple: a malformed payload (3 numbers, NaN)
// would otherwise emit a broken CustomEase path that fails at load.
if (isBezier4(ease)) return { kind: "bezier", bezier: ease };
return { kind: "named", ease: "none" };
}
const key = ease.toLowerCase().replace(/[_\s-]/g, "");
return { kind: "named", ease: NAMED_EASE[key] ?? "none" };
}
61 changes: 61 additions & 0 deletions packages/core/src/figma/motionToGsap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { motionToGsap } from "./motionToGsap";
import type { MotionDoc } from "./types";

const headline: MotionDoc = {
selector: "#hero-headline",
tracks: [
{
property: "opacity",
values: [0, 0, 1, 1, 0],
times: [0, 0.0686, 0.2273, 0.9999, 1],
ease: ["linear", [0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]],
duration: 2,
repeat: Infinity,
},
],
};

describe("motionToGsap", () => {
it("derives a finite, paused-timeline spec from the captured Headline payload", () => {
const spec = motionToGsap(headline);
expect(spec.timelineId).toBe("figma-hero-headline");
expect(spec.tweens).toHaveLength(1);

const t = spec.tweens[0];
expect(t?.selector).toBe("#hero-headline");
expect(t?.property).toBe("opacity");
expect(t?.initial).toBe(0);
// 4 segments for 5 keyframes
expect(t?.steps).toHaveLength(4);
// clamps Infinity -> 0 (single play) for determinism
expect(t?.repeat).toBe(0);
});

it("computes per-segment durations from times * duration", () => {
const t = motionToGsap(headline).tweens[0];
// segment 0: (0.0686 - 0) * 2 = 0.1372
expect(t?.steps[0]?.duration).toBeCloseTo(0.1372, 4);
// segment 1: (0.2273 - 0.0686) * 2 = 0.3174
expect(t?.steps[1]?.duration).toBeCloseTo(0.3174, 4);
});

it("registers a CustomEase per bezier segment and names it in the step", () => {
const spec = motionToGsap(headline);
expect(spec.customEases).toHaveLength(2);
expect(spec.customEases[0]?.bezier).toEqual([0.539, 0, 0.312, 0.995]);
// step 0 ease is linear -> none; step 1 ease is the first bezier
expect(spec.tweens[0]?.steps[0]?.ease).toBe("none");
expect(spec.tweens[0]?.steps[1]?.ease).toBe(spec.customEases[0]?.name);
});

it("throws when times and values lengths disagree", () => {
expect(() =>
motionToGsap({
selector: "#x",
tracks: [{ property: "x", values: [0, 1], times: [0], ease: ["linear"], duration: 1 }],
}),
).toThrow();
});
});
Loading
Loading