mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user