mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
* feat(core): GSAP keyframe parsing, mutations, and API routes * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance.
90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
|
|
|
|
/** Parse an SVG-path CustomEase string into {x, y} pairs. */
|
|
function parsePairs(data: string): { x: number; y: number }[] {
|
|
// Strip "M0,0 L" prefix, then split on whitespace between coordinate pairs
|
|
const body = data.replace(/^M0,0\s+L/, "");
|
|
const tokens = body.split(/\s+/);
|
|
return [
|
|
{ x: 0, y: 0 }, // from M0,0
|
|
...tokens.map((tok) => {
|
|
const [xStr, yStr] = tok.split(",");
|
|
return { x: Number(xStr), y: Number(yStr) };
|
|
}),
|
|
];
|
|
}
|
|
|
|
describe("generateSpringEaseData", () => {
|
|
it("generates a valid SVG-path CustomEase data string", () => {
|
|
const data = generateSpringEaseData(1, 180, 12);
|
|
expect(typeof data).toBe("string");
|
|
// Must start with M0,0 (SVG moveTo)
|
|
expect(data.startsWith("M0,0")).toBe(true);
|
|
// Must contain L (lineTo) segments
|
|
expect(data).toContain(" L");
|
|
const pairs = parsePairs(data);
|
|
expect(pairs.length).toBeGreaterThan(10);
|
|
// First point at origin, last at (1,1)
|
|
expect(pairs[0]).toEqual({ x: 0, y: 0 });
|
|
expect(pairs[pairs.length - 1]).toEqual({ x: 1, y: 1 });
|
|
});
|
|
|
|
it("underdamped spring produces overshoot", () => {
|
|
const data = generateSpringEaseData(1, 180, 8); // low damping = bouncy
|
|
const pairs = parsePairs(data);
|
|
const hasOvershoot = pairs.some((p) => p.y > 1.01);
|
|
expect(hasOvershoot).toBe(true);
|
|
});
|
|
|
|
it("critically damped spring has no overshoot", () => {
|
|
const mass = 1;
|
|
const stiffness = 100;
|
|
const criticalDamping = 2 * Math.sqrt(stiffness * mass); // zeta = 1
|
|
const data = generateSpringEaseData(mass, stiffness, criticalDamping);
|
|
const pairs = parsePairs(data);
|
|
const maxY = Math.max(...pairs.map((p) => p.y));
|
|
expect(maxY).toBeLessThanOrEqual(1.005);
|
|
});
|
|
|
|
it("overdamped spring has no overshoot and monotonically increases", () => {
|
|
// zeta > 1 — heavy damping
|
|
const data = generateSpringEaseData(1, 100, 30);
|
|
const pairs = parsePairs(data);
|
|
const maxY = Math.max(...pairs.map((p) => p.y));
|
|
expect(maxY).toBeLessThanOrEqual(1.005);
|
|
// Monotonically non-decreasing (within floating point tolerance)
|
|
for (let i = 1; i < pairs.length; i++) {
|
|
expect(pairs[i].y).toBeGreaterThanOrEqual(pairs[i - 1].y - 0.001);
|
|
}
|
|
});
|
|
|
|
it("all presets generate valid data", () => {
|
|
for (const preset of SPRING_PRESETS) {
|
|
const data = generateSpringEaseData(preset.mass, preset.stiffness, preset.damping);
|
|
expect(data.length).toBeGreaterThan(0);
|
|
expect(data.startsWith("M0,0")).toBe(true);
|
|
const pairs = parsePairs(data);
|
|
expect(pairs.length).toBeGreaterThan(50);
|
|
}
|
|
});
|
|
|
|
it("output x values span [0,1] monotonically", () => {
|
|
const data = generateSpringEaseData(1, 180, 12);
|
|
const pairs = parsePairs(data);
|
|
expect(pairs[0].x).toBe(0);
|
|
expect(pairs[pairs.length - 1].x).toBe(1);
|
|
for (let i = 1; i < pairs.length; i++) {
|
|
expect(pairs[i].x).toBeGreaterThan(pairs[i - 1].x - 0.0001);
|
|
expect(pairs[i].x).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
it("respects custom step count", () => {
|
|
const data = generateSpringEaseData(1, 100, 15, 60);
|
|
const pairs = parsePairs(data);
|
|
// 60 steps + the M0,0 origin = 61 points
|
|
expect(pairs.length).toBe(61);
|
|
});
|
|
});
|