From a2243f7586e90e07eddcb99a477e467a30667526 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 21:49:08 -0700 Subject: [PATCH] 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 --- packages/core/src/figma/index.ts | 6 + .../src/figma/motionContextToDocs.test.ts | 144 ++++++++++++ .../core/src/figma/motionContextToDocs.ts | 221 ++++++++++++++++++ skills-manifest.json | 4 +- skills/figma/SKILL.md | 4 +- skills/figma/scripts/verify-motion.mjs | 105 +++++++++ 6 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/figma/motionContextToDocs.test.ts create mode 100644 packages/core/src/figma/motionContextToDocs.ts create mode 100644 skills/figma/scripts/verify-motion.mjs diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 9e9a0b552..1d8e00e17 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -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"; diff --git a/packages/core/src/figma/motionContextToDocs.test.ts b/packages/core/src/figma/motionContextToDocs.test.ts new file mode 100644 index 000000000..859a32fad --- /dev/null +++ b/packages/core/src/figma/motionContextToDocs.test.ts @@ -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: + '', + }, + }, + { + nodeId: "3021:6491", + nodeName: "3D Object - Headphones", + nodeType: "ROUNDED_RECTANGLE", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6492", + nodeName: "Shape", + nodeType: "ROUNDED_RECTANGLE", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6493", + nodeName: "Headline", + nodeType: "TEXT", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6494", + nodeName: "Knob", + nodeType: "FRAME", + codeSnippets: { + motionDev: + '', + }, + }, + ], + timelineCohorts: [ + { + rootNodeId: "3021:6485", + durationMs: 2000, + loopMode: "loop", + memberNodeIds: ["3021:6487", "3021:6491", "3021:6492", "3021:6493", "3021:6494"], + }, + ], +}; + +const SELECTORS: Record = { + "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([]); + }); +}); diff --git a/packages/core/src/figma/motionContextToDocs.ts b/packages/core/src/figma/motionContextToDocs.ts new file mode 100644 index 000000000..ba6a11a55 --- /dev/null +++ b/packages/core/src/figma/motionContextToDocs.ts @@ -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 = { 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, + times: number[], + ease: MotionEase[], + duration: number, +): { values: Array; 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; + 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; + 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; +} diff --git a/skills-manifest.json b/skills-manifest.json index ce45e5ca0..1b48b1bf0 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,8 +10,8 @@ "files": 18 }, "figma": { - "hash": "17dd858344329586", - "files": 1 + "hash": "e84abcb652194f57", + "files": 2 }, "general-video": { "hash": "e26710c3537b3a07", diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 7d2a70f28..d75e766b6 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -88,8 +88,8 @@ Node tree → editable HTML at exact figma geometry, packaged as a registry item No REST equivalent exists. You drive the MCP tools, then hand output to the pure helpers in `@hyperframes/core/figma`: 1. `get_motion_context(fileKey, nodeId)` — use `recursive:true` on the parent frame (one call for the whole scene, not one per element). Save the raw JSON next to the project (`.media/figma-cache/`) so retranslation is free. -2. Normalize into a `MotionDoc`: per animated property a `MotionTrack` { property (motion.dev name), values, times (0..1), ease[] (named or `[x1,y1,x2,y2]` bezier), duration, repeat }. Selector = the element's stable id (`#` from Phase-3 output or the authored scene). **Translate values VERBATIM — never paraphrase, simplify, or invent keyframes.** Decoding rules (field-tested): the response carries two encodings — the motion.dev snippet is the timeline-cohort window (all tracks share the cohort duration), the CSS snippet may stretch per-track durations; use ONE encoding consistently (prefer motion.dev, cohort-windowed). Keyframes at times ≈0.9999→1 are **loop-wrap markers** (the instant reset at the loop boundary), NOT authored motion — drop them and realize the wrap via the tween's `repeat` restart; inventing a visible return/fade-out where the source has a wrap snap is the known failure mode. - 2b. **Validate against ground truth before calling it done**: `export_video` on the cohort's `rootNodeId` gives Figma's own render of the timeline. Extract a frame grid from both videos at the same interval (e.g. `fps=5` contact sheets) and compare — element positions, fade states, and rotation phase must match frame-for-frame. A translation that hasn't been compared to the export is unverified. +2. Normalize into `MotionDoc`s with `motionContextToDocs(rawResponse, { selectorFor, repeat })` from `@hyperframes/core/figma` — **never transcribe keyframe numbers by hand**. The helper encodes the field-tested decoding rules mechanically: it parses the motion.dev snippets (the reliable encoding — the CSS snippets stretch durations and can disagree; they are ignored), strips loop-wrap tail keyframes (sub-millisecond segments at times ≈0.9999→1 are the loop's instant reset, not authored motion — the wrap is realized by `repeat` restart), and preserves bezier eases verbatim. `selectorFor` must return the ids from the Phase-3 component import — don't derive selectors from node names. + 2b. **Validate against ground truth before calling it done — mandatory**: `export_video` on the cohort's `rootNodeId` gives Figma's own render of the timeline. Run `node skills/figma/scripts/verify-motion.mjs --reference --render --crop WxH+X+Y` — it compares motion-energy deltas (static import fidelity cancels out) and fails below 15dB min motion-PSNR (calibrated: faithful ≈ 20+, diverging ≈ 5). Measure `--crop` from the render's actual card edges, don't guess. FAIL means re-check the translation, not the threshold. 3. `motionToGsap(doc)` → `emitTimelineScript(spec)` → inject as a `