mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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;
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
"files": 18
|
||||
},
|
||||
"figma": {
|
||||
"hash": "17dd858344329586",
|
||||
"files": 1
|
||||
"hash": "e84abcb652194f57",
|
||||
"files": 2
|
||||
},
|
||||
"general-video": {
|
||||
"hash": "e26710c3537b3a07",
|
||||
|
||||
@@ -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 (`#<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 <export.mp4> --render <render.mp4> --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 `<script>` after the GSAP + CustomEase CDN tags. Paused, finite, registered on `window.__timelines` with a literal key.
|
||||
4. Untranslatable track (shader-driven, unsupported prop, complex masks) → bake: `export_video` → freeze MP4 → embed as `<video class="clip">`. Exception: shader-driven tracks — figma's export path flattens shaders to the base color (see Shaders below), so a bake there silently loses the shader; ask the user for a native figma export instead. Always say which path you used and why. Named eases outside the mapped set fall back to linear — the mapping table lives in `motionEase.ts`; flag the fallback to the user when it fires.
|
||||
5. Run `npx hyperframes lint && npx hyperframes validate` before calling it done.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Objective fidelity gate for figma-motion imports (skill step 2b).
|
||||
*
|
||||
* Compares the HyperFrames render against Figma's own `export_video` output
|
||||
* using MOTION-ENERGY deltas: for each sample window [t, t+interval], the
|
||||
* frame difference ref(t+i)-ref(t) is compared (PSNR) against
|
||||
* render(t+i)-render(t). Static import divergence (fonts, rasterized edges,
|
||||
* subpixel geometry — the hybrid-fidelity ceiling) cancels out of both
|
||||
* deltas, so the score isolates choreography: trajectories, timing, easing.
|
||||
*
|
||||
* Calibration (SDS "Unlocked" card, 2026-07): a faithful translation scored
|
||||
* min 20.3dB / mean 27.7dB; a diverging one (invented retract keyframes,
|
||||
* wrong durations) scored min 5.0dB / mean 23.1dB. Default threshold 15dB
|
||||
* sits between with margin on both sides.
|
||||
*
|
||||
* node verify-motion.mjs --reference figma-export.mp4 --render out.mp4 \
|
||||
* [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]
|
||||
*
|
||||
* --crop selects the card region inside the (usually larger) composition
|
||||
* frame. Measure it from the render (the card's left/top edge + scaled
|
||||
* size), don't guess: a wrong crop reads as motion divergence.
|
||||
*/
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i > -1 ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const reference = arg("reference");
|
||||
const render = arg("render");
|
||||
if (!reference || !render) {
|
||||
console.error(
|
||||
"usage: verify-motion.mjs --reference ref.mp4 --render out.mp4 [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const crop = arg("crop", null);
|
||||
const interval = Number(arg("interval", "0.2"));
|
||||
const minMotion = Number(arg("min-motion-psnr", "15"));
|
||||
|
||||
const ffprobe = (file) =>
|
||||
Number(
|
||||
execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file])
|
||||
.toString()
|
||||
.trim(),
|
||||
);
|
||||
const refDur = ffprobe(reference);
|
||||
const renderDur = ffprobe(render);
|
||||
const end = Math.min(refDur, renderDur) - interval - 0.01;
|
||||
|
||||
const dims = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v", "-show_entries", "stream=width,height", "-of", "csv=p=0", reference])
|
||||
.toString().trim().split(",").map(Number);
|
||||
const [rw, rh] = dims;
|
||||
|
||||
let cropFilter = "";
|
||||
if (crop) {
|
||||
const m = crop.match(/^(\d+)x(\d+)\+(\d+)\+(\d+)$/);
|
||||
if (!m) { console.error("bad --crop, expected WxH+X+Y"); process.exit(2); }
|
||||
cropFilter = `crop=${m[1]}:${m[2]}:${m[3]}:${m[4]},`;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "verify-motion-"));
|
||||
const frame = (src, t, vf, dst) => {
|
||||
const args = ["-y", "-v", "error", "-ss", String(t), "-i", src, "-frames:v", "1"];
|
||||
if (vf) args.push("-vf", vf);
|
||||
execFileSync("ffmpeg", args.concat(dst));
|
||||
};
|
||||
const diff = (a, b, dst) =>
|
||||
execFileSync("ffmpeg", ["-y", "-v", "error", "-i", a, "-i", b, "-filter_complex", "blend=all_mode=difference", dst]);
|
||||
const psnr = (a, b) => {
|
||||
const err = execSync(`ffmpeg -i ${JSON.stringify(a)} -i ${JSON.stringify(b)} -lavfi psnr -f null - 2>&1`).toString();
|
||||
const m = err.match(/average:([\d.]+|inf)/);
|
||||
return m ? (m[1] === "inf" ? 99 : Number(m[1])) : NaN;
|
||||
};
|
||||
|
||||
const renderVf = `${cropFilter}scale=${rw}:${rh}`;
|
||||
const results = [];
|
||||
for (let t = 0; t <= end; t = Math.round((t + interval) * 1000) / 1000) {
|
||||
const t1 = Math.round((t + interval) * 1000) / 1000;
|
||||
frame(reference, t, null, join(dir, "ra.png"));
|
||||
frame(reference, t1, null, join(dir, "rb.png"));
|
||||
frame(render, t, renderVf, join(dir, "oa.png"));
|
||||
frame(render, t1, renderVf, join(dir, "ob.png"));
|
||||
diff(join(dir, "ra.png"), join(dir, "rb.png"), join(dir, "rd.png"));
|
||||
diff(join(dir, "oa.png"), join(dir, "ob.png"), join(dir, "od.png"));
|
||||
results.push({ t, motion: psnr(join(dir, "rd.png"), join(dir, "od.png")), abs: psnr(join(dir, "rb.png"), join(dir, "ob.png")) });
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
const min = Math.min(...results.map((r) => r.motion));
|
||||
const mean = results.reduce((s, r) => s + r.motion, 0) / results.length;
|
||||
for (const r of results)
|
||||
console.log(
|
||||
`window ${r.t.toFixed(2)}s→${(r.t + interval).toFixed(2)}s motion-psnr=${r.motion.toFixed(2)}dB (abs=${r.abs.toFixed(1)}dB)${r.motion < minMotion ? " <-- BELOW THRESHOLD" : ""}`,
|
||||
);
|
||||
console.log(`\nwindows=${results.length} min-motion=${min.toFixed(2)}dB mean-motion=${mean.toFixed(2)}dB threshold=${minMotion}dB`);
|
||||
if (min < minMotion) {
|
||||
console.log("VERDICT: FAIL — choreography diverges from the Figma export (check timings, invented keyframes, durations)");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("VERDICT: PASS — motion matches the Figma export within the static-fidelity ceiling");
|
||||
Reference in New Issue
Block a user