mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core,figma-skill): mechanical motion translation + objective fidelity gate
Two guarantees so figma-motion imports can't drift from the design again: - motionContextToDocs(): raw get_motion_context response -> MotionDoc[], in code. Parses the motion.dev snippets (the reliable encoding; the CSS snippets stretch durations and can disagree), strips loop-wrap tail keyframes (sub-ms segments at the window end are the loop reset, not authored motion), preserves bezier eases verbatim. Fixture test uses the verbatim response from a real Motion timeline whose translation was frame-validated against Figma's own export_video render. - skills/figma/scripts/verify-motion.mjs: mandatory post-render gate. Compares motion-energy deltas between the render and the export_video ground truth so static import fidelity cancels out and the score isolates choreography. Calibrated on a faithful translation (min 20.3dB) vs a diverging one (min 5.0dB); threshold 15dB. The skill's Motion step now routes through both: no hand transcription, no unverified completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
44653e186a
commit
a2243f7586
@@ -59,5 +59,11 @@ export type {
|
||||
TokensToVariablesResult,
|
||||
} from "./tokensToVariables";
|
||||
export { mapEase } from "./motionEase";
|
||||
export {
|
||||
motionContextToDocs,
|
||||
type MotionContextResponse,
|
||||
type MotionContextNode,
|
||||
type MotionContextToDocsOptions,
|
||||
} from "./motionContextToDocs";
|
||||
export { motionToGsap } from "./motionToGsap";
|
||||
export { emitTimelineScript } from "./emitTimelineScript";
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { motionContextToDocs } from "./motionContextToDocs";
|
||||
import type { MotionContextResponse } from "./motionContextToDocs";
|
||||
|
||||
/**
|
||||
* Fixture: verbatim `get_motion_context` response for the SDS "Unlocked"
|
||||
* Motion card (fileKey Hl5L3gkQ3Tz3Y2KTQJbAkT, node 3021:6485, 2026-07-08).
|
||||
* The expected outputs were validated frame-by-frame against Figma's own
|
||||
* `export_video` render of the same timeline (verify-motion.mjs PASS).
|
||||
*/
|
||||
const FIXTURE: MotionContextResponse = {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "3021:6487",
|
||||
nodeName: "Shape w offset",
|
||||
nodeType: "FRAME",
|
||||
codeSnippets: {
|
||||
motionDev:
|
||||
'<motion.div initial={{ rotate: 0, }} animate={{ rotate: [0, 223.149, 360], }} transition={{ rotate: { duration: 2, times: [0, 0.9999, 1], ease: "linear", repeat: Infinity }, }} />',
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeId: "3021:6491",
|
||||
nodeName: "3D Object - Headphones",
|
||||
nodeType: "ROUNDED_RECTANGLE",
|
||||
codeSnippets: {
|
||||
motionDev:
|
||||
'<motion.div initial={{ y: 0, }} animate={{ y: [0, -71.246, -64.253, -19.227, -1.212, 0], }} transition={{ y: { duration: 2, times: [0, 0.8066, 0.9997, 0.9998, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", "linear", "linear", "linear"], repeat: Infinity }, }} />',
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeId: "3021:6492",
|
||||
nodeName: "Shape",
|
||||
nodeType: "ROUNDED_RECTANGLE",
|
||||
codeSnippets: {
|
||||
motionDev:
|
||||
'<motion.div initial={{ width: 160.5, }} animate={{ width: [160.5, 500, 500, 160.5], }} transition={{ width: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeId: "3021:6493",
|
||||
nodeName: "Headline",
|
||||
nodeType: "TEXT",
|
||||
codeSnippets: {
|
||||
motionDev:
|
||||
'<motion.div initial={{ opacity: 0, x: 98.914, }} animate={{ opacity: [0, 0, 1, 1, 0], x: [98.914, 98.914, 0, 0, 98.914], }} transition={{ opacity: { duration: 2, 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]], repeat: Infinity }, x: { duration: 2, 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]], repeat: Infinity }, }} />',
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeId: "3021:6494",
|
||||
nodeName: "Knob",
|
||||
nodeType: "FRAME",
|
||||
codeSnippets: {
|
||||
motionDev:
|
||||
'<motion.div initial={{ x: -339.087, }} animate={{ x: [-339.087, 0, 0, -339.087], }} transition={{ x: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
|
||||
},
|
||||
},
|
||||
],
|
||||
timelineCohorts: [
|
||||
{
|
||||
rootNodeId: "3021:6485",
|
||||
durationMs: 2000,
|
||||
loopMode: "loop",
|
||||
memberNodeIds: ["3021:6487", "3021:6491", "3021:6492", "3021:6493", "3021:6494"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SELECTORS: Record<string, string> = {
|
||||
"3021:6487": "#shape-w-offset",
|
||||
"3021:6491": "#headphones-3d",
|
||||
"3021:6492": "#shape-2",
|
||||
"3021:6493": "#headline",
|
||||
"3021:6494": "#knob",
|
||||
};
|
||||
|
||||
function docs(repeat = 1) {
|
||||
return motionContextToDocs(FIXTURE, {
|
||||
selectorFor: (n) => SELECTORS[n.nodeId] ?? `#${n.nodeId}`,
|
||||
repeat,
|
||||
});
|
||||
}
|
||||
|
||||
describe("motionContextToDocs", () => {
|
||||
it("produces one doc per animated node with caller-supplied selectors", () => {
|
||||
const out = docs();
|
||||
expect(out.map((d) => d.selector)).toEqual([
|
||||
"#shape-w-offset",
|
||||
"#headphones-3d",
|
||||
"#shape-2",
|
||||
"#headline",
|
||||
"#knob",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips the loop-wrap tail and extends the last keyframe to the window end", () => {
|
||||
const rotation = docs()[0]?.tracks[0];
|
||||
// [0, 223.149, 360] @ [0, .9999, 1]: the 360 is the wrap marker.
|
||||
// 223.149° over the 2s window is the true angular speed (the CSS
|
||||
// snippet's "360° in 2s" disagrees and is wrong — verified against
|
||||
// export_video ground truth).
|
||||
expect(rotation?.property).toBe("rotation");
|
||||
expect(rotation?.values).toEqual([0, 223.149]);
|
||||
expect(rotation?.times).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("strips multi-keyframe wrap clusters but keeps real sub-second motion", () => {
|
||||
const y = docs()[1]?.tracks[0];
|
||||
// tail cluster (-19.227, -1.212, 0) spans <1ms each — wrap markers.
|
||||
// -64.253 @ 0.9997 ends a 0.386s segment — real, kept, extended to 1.
|
||||
expect(y?.values).toEqual([0, -71.246, -64.253]);
|
||||
expect(y?.times).toEqual([0, 0.8066, 1]);
|
||||
});
|
||||
|
||||
it("keeps hold segments and drops only the wrap snap", () => {
|
||||
const width = docs()[2]?.tracks[0];
|
||||
expect(width?.values).toEqual([160.5, 500, 500]);
|
||||
expect(width?.times).toEqual([0, 0.1956, 1]);
|
||||
});
|
||||
|
||||
it("parses multiple properties per node", () => {
|
||||
const headline = docs()[3];
|
||||
expect(headline?.tracks.map((t) => t.property).sort()).toEqual(["opacity", "x"]);
|
||||
const opacity = headline?.tracks.find((t) => t.property === "opacity");
|
||||
expect(opacity?.values).toEqual([0, 0, 1, 1]);
|
||||
expect(opacity?.times).toEqual([0, 0.0686, 0.2273, 1]);
|
||||
});
|
||||
|
||||
it("preserves bezier easing arrays and applies the requested repeat", () => {
|
||||
const knob = docs(2)[4]?.tracks[0];
|
||||
expect(knob?.ease[0]).toEqual([0.539, 0, 0.312, 0.995]);
|
||||
expect(knob?.repeat).toBe(2);
|
||||
expect(knob?.duration).toBe(2);
|
||||
});
|
||||
|
||||
it("skips nodes without motion.dev snippets", () => {
|
||||
const out = motionContextToDocs(
|
||||
{ nodes: [{ nodeId: "1:1", nodeName: "Static", codeSnippets: { css: "..." } }] },
|
||||
{ selectorFor: () => "#static" },
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Mechanical translation of a raw Figma MCP `get_motion_context` response
|
||||
* into MotionDocs — no hand transcription (design spec §6 motion notes).
|
||||
*
|
||||
* Field-tested decoding rules (2026-07, SDS "Unlocked" card):
|
||||
*
|
||||
* - The response carries two encodings per node. The motion.dev snippet is
|
||||
* the reliable one: every track is sampled inside the timeline-cohort
|
||||
* window, so values are correct AT their normalized times. The CSS
|
||||
* snippet stretches per-track durations and can disagree — it is ignored.
|
||||
* - Keyframes clustered at the tail of the window (segments spanning less
|
||||
* than WRAP_EPSILON_S of real time) are LOOP-WRAP MARKERS — the instant
|
||||
* reset at the loop boundary — not authored motion. They are stripped and
|
||||
* the wrap is realized by the tween's `repeat` restart. Inventing visible
|
||||
* returns from wrap markers is the known failure mode this module exists
|
||||
* to prevent.
|
||||
* - After stripping, the last kept keyframe's time extends to 1 so the
|
||||
* track fills its window (the dropped tail spanned sub-millisecond time).
|
||||
*
|
||||
* Verification is still mandatory: render and compare against
|
||||
* `export_video` ground truth with skills/figma/scripts/verify-motion.mjs.
|
||||
*/
|
||||
|
||||
import type { MotionDoc, MotionEase, MotionTrack } from "./types";
|
||||
|
||||
/** Tail segments shorter than this (seconds) are loop-wrap markers. */
|
||||
const WRAP_EPSILON_S = 0.005;
|
||||
|
||||
export interface MotionContextNode {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType?: string;
|
||||
codeSnippets?: { css?: string; motionDev?: string };
|
||||
}
|
||||
|
||||
export interface MotionContextResponse {
|
||||
nodes: MotionContextNode[];
|
||||
timelineCohorts?: Array<{
|
||||
rootNodeId: string;
|
||||
durationMs: number;
|
||||
loopMode?: string;
|
||||
memberNodeIds?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MotionContextToDocsOptions {
|
||||
/**
|
||||
* Maps a node to the CSS selector of its imported element. REQUIRED in
|
||||
* practice: pass the ids from the Phase-3 component import (the mapper's
|
||||
* slugs, e.g. `#headphones-3d`) — deriving selectors from node names here
|
||||
* would silently drift from the imported HTML.
|
||||
*/
|
||||
selectorFor: (node: MotionContextNode) => string;
|
||||
/** Extra plays per track (GSAP semantics: 0 = play once). Default 0. */
|
||||
repeat?: number;
|
||||
}
|
||||
|
||||
/** motion.dev property → GSAP property. */
|
||||
const PROPERTY_MAP: Record<string, string> = { rotate: "rotation" };
|
||||
|
||||
/** Extract the balanced `{...}` body following `marker` in `src`. */
|
||||
function balancedBlock(src: string, marker: string): string | null {
|
||||
const at = src.indexOf(marker);
|
||||
if (at === -1) return null;
|
||||
const start = src.indexOf("{", at + marker.length - 1);
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
if (src[i] === "{") depth += 1;
|
||||
if (src[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract a balanced `[...]` immediately after `key:` inside `src`. */
|
||||
function arrayAfterKey(src: string, key: string): string | null {
|
||||
const re = new RegExp(`${key}\\s*:\\s*\\[`);
|
||||
const m = re.exec(src);
|
||||
if (!m) return null;
|
||||
const start = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
if (src[i] === "[") depth += 1;
|
||||
if (src[i] === "]") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scalarAfterKey(src: string, key: string): string | null {
|
||||
const m = new RegExp(`${key}\\s*:\\s*("[^"]*"|[\\w.]+)`).exec(src);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseEase(transitionBlock: string): MotionEase[] | null {
|
||||
const arr = arrayAfterKey(transitionBlock, "ease");
|
||||
if (arr) return JSON.parse(arr) as MotionEase[];
|
||||
const scalar = scalarAfterKey(transitionBlock, "ease");
|
||||
if (scalar?.startsWith('"')) return [JSON.parse(scalar) as string];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip loop-wrap tail keyframes: walking from the end, drop keyframes whose
|
||||
* incoming segment spans < WRAP_EPSILON_S of real time; stop at the first
|
||||
* substantial segment. Extend the last kept time to 1.
|
||||
*/
|
||||
function stripWrapTail(
|
||||
values: Array<number | string>,
|
||||
times: number[],
|
||||
ease: MotionEase[],
|
||||
duration: number,
|
||||
): { values: Array<number | string>; times: number[]; ease: MotionEase[] } {
|
||||
let end = values.length;
|
||||
while (end > 2) {
|
||||
const tPrev = times[end - 2];
|
||||
const tCur = times[end - 1];
|
||||
if (tPrev === undefined || tCur === undefined) break;
|
||||
if ((tCur - tPrev) * duration >= WRAP_EPSILON_S) break;
|
||||
end -= 1;
|
||||
}
|
||||
const v = values.slice(0, end);
|
||||
const t = times.slice(0, end);
|
||||
const e = ease.slice(0, Math.max(1, end - 1));
|
||||
const last = t.length - 1;
|
||||
if (t[last] !== undefined && t[last] < 1) t[last] = 1;
|
||||
return { values: v, times: t, ease: e };
|
||||
}
|
||||
|
||||
interface RawTrackData {
|
||||
values: Array<number | string>;
|
||||
times: number[];
|
||||
ease: MotionEase[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** Extract and validate one property's raw arrays from the snippet blocks. */
|
||||
function extractTrackData(animate: string, transition: string, prop: string): RawTrackData | null {
|
||||
const valuesSrc = arrayAfterKey(animate, prop);
|
||||
const propTransition = balancedBlock(transition, `${prop}: {`);
|
||||
if (!valuesSrc || !propTransition) return null;
|
||||
const timesSrc = arrayAfterKey(propTransition, "times");
|
||||
const durationSrc = scalarAfterKey(propTransition, "duration");
|
||||
const ease = parseEase(propTransition);
|
||||
if (!timesSrc || !durationSrc || !ease) return null;
|
||||
const values = JSON.parse(valuesSrc) as Array<number | string>;
|
||||
const times = JSON.parse(timesSrc) as number[];
|
||||
const duration = Number(durationSrc);
|
||||
if (values.length !== times.length || !Number.isFinite(duration)) return null;
|
||||
return { values, times, ease, duration };
|
||||
}
|
||||
|
||||
/** Parse one property's track out of the animate/transition blocks. */
|
||||
function parsePropertyTrack(
|
||||
animate: string,
|
||||
transition: string,
|
||||
prop: string,
|
||||
repeat: number,
|
||||
): MotionTrack | null {
|
||||
const raw = extractTrackData(animate, transition, prop);
|
||||
if (!raw) return null;
|
||||
// segment eases: a single named ease applies to every segment
|
||||
const segCount = raw.values.length - 1;
|
||||
const segEase =
|
||||
raw.ease.length === segCount
|
||||
? raw.ease
|
||||
: Array.from({ length: segCount }, (_, i) => raw.ease[i % raw.ease.length] ?? "linear");
|
||||
const stripped = stripWrapTail(raw.values, raw.times, segEase, raw.duration);
|
||||
return {
|
||||
property: PROPERTY_MAP[prop] ?? prop,
|
||||
values: stripped.values,
|
||||
times: stripped.times,
|
||||
ease: stripped.ease,
|
||||
duration: raw.duration,
|
||||
repeat,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse one node's motion.dev snippet into MotionTracks. */
|
||||
function parseNodeTracks(node: MotionContextNode, repeat: number): MotionTrack[] {
|
||||
const snippet = node.codeSnippets?.motionDev;
|
||||
if (!snippet) return [];
|
||||
const animate = balancedBlock(snippet, "animate={");
|
||||
const transition = balancedBlock(snippet, "transition={");
|
||||
if (!animate || !transition) return [];
|
||||
|
||||
const tracks: MotionTrack[] = [];
|
||||
const propRe = /(\w+)\s*:\s*\[/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = propRe.exec(animate)) !== null) {
|
||||
const prop = m[1];
|
||||
if (prop === undefined) continue;
|
||||
const track = parsePropertyTrack(animate, transition, prop, repeat);
|
||||
if (track) tracks.push(track);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw `get_motion_context` response → MotionDoc[], mechanically. Feed the
|
||||
* result to motionToGsap/emitTimelineScript; then verify against
|
||||
* export_video ground truth before calling the import done.
|
||||
*/
|
||||
export function motionContextToDocs(
|
||||
response: MotionContextResponse,
|
||||
options: MotionContextToDocsOptions,
|
||||
): MotionDoc[] {
|
||||
const repeat = options.repeat ?? 0;
|
||||
const docs: MotionDoc[] = [];
|
||||
for (const node of response.nodes ?? []) {
|
||||
const tracks = parseNodeTracks(node, repeat);
|
||||
if (tracks.length === 0) continue;
|
||||
docs.push({ selector: options.selectorFor(node), tracks });
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
Reference in New Issue
Block a user