mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(core): figma motion → GSAP translator + /figma skill v1 (#1869)
* feat(core): add figma motion easing mapping * feat(core): translate figma motion doc to gsap timeline spec * feat(core): emit paused GSAP timeline script from figma motion spec * fix(core): restore type exports dropped from figma barrel in Task 8 * feat(skills): add /figma import skill + catalog wiring Add the agent-facing /figma skill (asset + Figma Motion import via the Figma MCP connector, built on @hyperframes/core/figma) and wire it into the skill catalog across CLAUDE.md, README.md, docs/guides/skills.mdx, and the hyperframes router's capability map. Bumps the skill count from 19 to 20 in CLAUDE.md and README.md. * fix(core): use replaceAll for figma node-id dash-to-colon conversion * style: format skills catalog tables oxfmt-align the README and router SKILL.md tables after the /figma + /hyperframes-keyframes merge left uneven column padding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): add missing cache fields to telemetry test fixture ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/ cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry test fixture was never updated, breaking Typecheck on main and every PR based on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1a7002f208
commit
e92700acde
+6
-5
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mapEase } from "./motionEase";
|
||||
import type {
|
||||
CustomEaseRef,
|
||||
GsapKeyframeStep,
|
||||
GsapTween,
|
||||
MotionDoc,
|
||||
MotionTrack,
|
||||
TimelineSpec,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* repeat semantics match GSAP and motion.dev: count of EXTRA plays
|
||||
* (0 = play once). Infinity clamps to 0 — a single play — because a
|
||||
* deterministic render needs a finite timeline; composition-duration-aware
|
||||
* loop counts are a later milestone (spec §6 motion notes).
|
||||
*/
|
||||
function clampRepeat(repeat: number | undefined): number {
|
||||
return repeat !== undefined && Number.isFinite(repeat) && repeat > 0 ? Math.floor(repeat) : 0;
|
||||
}
|
||||
|
||||
function deriveId(selector: string): string {
|
||||
const base = selector.replace(/^[#.]/, "").replace(/[^A-Za-z0-9_-]/g, "-");
|
||||
return `figma-${base.length > 0 ? base : "timeline"}`;
|
||||
}
|
||||
|
||||
/** Mutable counter shared across all tracks so generated CustomEase names stay unique. */
|
||||
interface CustomEaseCounter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** Resolves one segment's ease, registering a CustomEase in `customEases` for bezier arrays. */
|
||||
function resolveStepEase(
|
||||
rawEase: string | [number, number, number, number],
|
||||
customEases: CustomEaseRef[],
|
||||
counter: CustomEaseCounter,
|
||||
): string {
|
||||
const mapped = mapEase(rawEase);
|
||||
if (mapped.kind === "bezier") {
|
||||
const name = `hfCe${counter.value}`;
|
||||
counter.value += 1;
|
||||
customEases.push({ name, bezier: mapped.bezier });
|
||||
return name;
|
||||
}
|
||||
return mapped.ease;
|
||||
}
|
||||
|
||||
function buildSteps(
|
||||
track: MotionTrack,
|
||||
customEases: CustomEaseRef[],
|
||||
counter: CustomEaseCounter,
|
||||
): GsapKeyframeStep[] {
|
||||
const steps: GsapKeyframeStep[] = [];
|
||||
|
||||
for (let i = 1; i < track.values.length; i += 1) {
|
||||
const tPrev = track.times[i - 1];
|
||||
const tCur = track.times[i];
|
||||
const value = track.values[i];
|
||||
if (tPrev === undefined || tCur === undefined || value === undefined) continue;
|
||||
|
||||
const rawEase = track.ease[i - 1] ?? "linear";
|
||||
const ease = resolveStepEase(rawEase, customEases, counter);
|
||||
steps.push({ value, duration: (tCur - tPrev) * track.duration, ease });
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function buildTween(
|
||||
track: MotionTrack,
|
||||
selector: string,
|
||||
customEases: CustomEaseRef[],
|
||||
counter: CustomEaseCounter,
|
||||
): GsapTween {
|
||||
if (track.values.length < 2 || track.times.length !== track.values.length) {
|
||||
throw new Error(`motionToGsap: invalid track "${track.property}" (values/times mismatch)`);
|
||||
}
|
||||
const initial = track.values[0];
|
||||
if (initial === undefined) throw new Error(`motionToGsap: empty track "${track.property}"`);
|
||||
|
||||
return {
|
||||
selector,
|
||||
property: track.property,
|
||||
initial,
|
||||
steps: buildSteps(track, customEases, counter),
|
||||
repeat: clampRepeat(track.repeat),
|
||||
};
|
||||
}
|
||||
|
||||
export function motionToGsap(doc: MotionDoc): TimelineSpec {
|
||||
const customEases: CustomEaseRef[] = [];
|
||||
const counter: CustomEaseCounter = { value: 0 };
|
||||
const tweens = doc.tracks.map((track) => buildTween(track, doc.selector, customEases, counter));
|
||||
return { timelineId: deriveId(doc.selector), tweens, customEases };
|
||||
}
|
||||
@@ -29,3 +29,9 @@ describe("parseFigmaRef", () => {
|
||||
expect(() => parseFigmaRef(" ")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeNodeId multi-segment", () => {
|
||||
it("converts every dash in a multi-segment node id", () => {
|
||||
expect(parseFigmaRef("KEY:1-2-3").nodeId).toBe("1:2:3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FigmaRef } from "./types";
|
||||
const FILE_KEY_RE = /\/(?:design|file|proto)\/([A-Za-z0-9]+)/;
|
||||
|
||||
function normalizeNodeId(raw: string): string {
|
||||
return raw.replace("-", ":");
|
||||
return raw.replaceAll("-", ":");
|
||||
}
|
||||
|
||||
export function parseFigmaRef(input: string): FigmaRef {
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
"hash": "844c54a06fd16d3c",
|
||||
"files": 17
|
||||
},
|
||||
"figma": {
|
||||
"hash": "5b39cd7270fe10db",
|
||||
"files": 1
|
||||
},
|
||||
"general-video": {
|
||||
"hash": "a30225e30ec7b06c",
|
||||
"files": 1
|
||||
},
|
||||
"hyperframes": {
|
||||
"hash": "6cff4ef2b582b81b",
|
||||
"hash": "9582315c3a80e700",
|
||||
"files": 1
|
||||
},
|
||||
"hyperframes-animation": {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: figma
|
||||
description: Import Figma content into a HyperFrames composition — rendered assets, brand tokens, components, and Figma Motion animations — via the Figma MCP connector. Use when the user pastes a figma.com link or asks to bring a Figma design, frame, logo, brand, or animation into a video/composition.
|
||||
---
|
||||
|
||||
# Figma → HyperFrames
|
||||
|
||||
Bring the user's Figma work into a composition. **MCP-first:** you (the agent) call the Figma MCP tools, then hand their output to the pure helpers in `@hyperframes/core/figma`, freezing every asset locally so renders stay deterministic.
|
||||
|
||||
## Auth
|
||||
|
||||
Requires the Figma MCP connector (one-click OAuth). If tools error unauthenticated, tell the user to connect Figma and stop.
|
||||
|
||||
## Routing
|
||||
|
||||
Parse the user's figma link with `parseFigmaRef`. Then by intent:
|
||||
|
||||
- "use this layer / logo / image" → **Asset import**
|
||||
- "import this animation / motion" → **Motion import**
|
||||
- brand tokens / components → not in this skill version (see the design spec roadmap).
|
||||
|
||||
## Asset import
|
||||
|
||||
1. `get_metadata(fileKey, nodeId)` → confirm the node, capture width/height.
|
||||
2. Export it: `download_assets` (or node image export) for PNG; prefer SVG for vectors.
|
||||
3. Freeze: write the bytes with `freezeBytes(bytes, join(typeDirPath(projectDir,"image"), id + ext))` where `id = nextId(projectDir,"image")`.
|
||||
4. Ledger: `appendRecord(projectDir, { id, type:"image", path, source:"figma", width, height, provenance:{ source:"figma", fileKey, nodeId, format } })`.
|
||||
5. Emit `buildAssetSnippet(record).html` into the composition. Re-import guard: `findByFigmaNode` before re-fetching.
|
||||
|
||||
## Motion import
|
||||
|
||||
1. `get_motion_context(fileKey, nodeId)` → read the `codeSnippets.motionDev` (and `.css`).
|
||||
2. Normalize it into a `MotionDoc`: for each animated property build a `MotionTrack` { property (motion.dev name), values, times (0..1), ease[] (named strings or `[x1,y1,x2,y2]` bezier arrays), duration, repeat }. Selector = the target element's stable id (`#<id>` from the component's `data-figma-id`/`id`).
|
||||
3. `const spec = motionToGsap(doc);`
|
||||
4. `const script = emitTimelineScript(spec);` → inject as a `<script>` in the composition (after the GSAP + CustomEase CDN tags). The timeline is paused, finite, registered on `window.__timelines`.
|
||||
5. If a track uses shader/spring/effect props with no GSAP mapping, bake instead: `export_video` → freeze MP4 → embed as `<video class="clip">`. Say which path you used.
|
||||
|
||||
## Determinism
|
||||
|
||||
Never leave a Figma URL in the composition — freeze first. Never emit `repeat: -1`. Timelines paused.
|
||||
@@ -35,6 +35,7 @@ Atomic capabilities you load **on demand** — not full video workflows. For "ma
|
||||
| **Media resolve** — find + freeze BGM, SFX, images, icons from HeyGen catalog into `.media/` with manifest tracking | `/media-use` |
|
||||
| **CLI dev loop** — init, lint, validate, inspect, preview, render, publish, doctor | `/hyperframes-cli` |
|
||||
| **Install registry blocks / components** (`hyperframes add`) | `/hyperframes-registry` |
|
||||
| **Import Figma content** — assets, tokens, components, Motion animations (MCP-first) | `/figma` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user