diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f8b76721a..0b7c4db1d 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,6 +27,9 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", + // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. + "packages/studio/src/components/editor/KeyframeDiamond.tsx", + "packages/studio/src/components/editor/SpringEaseEditor.tsx", ], "ignorePatterns": [ "docs/**", diff --git a/packages/core/package.json b/packages/core/package.json index 39b139cb6..faf7fc1f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,6 +70,10 @@ "import": "./src/parsers/gsapConstants.ts", "types": "./src/parsers/gsapConstants.ts" }, + "./spring-ease": { + "import": "./src/parsers/springEase.ts", + "types": "./src/parsers/springEase.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, @@ -129,6 +133,10 @@ "import": "./dist/parsers/gsapConstants.js", "types": "./dist/parsers/gsapConstants.d.ts" }, + "./spring-ease": { + "import": "./dist/parsers/springEase.js", + "types": "./dist/parsers/springEase.d.ts" + }, "./schemas/registry.json": "./schemas/registry.json", "./schemas/registry-item.json": "./schemas/registry-item.json" }, diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index d8408e44c..b4c484bfb 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -35,6 +35,8 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize"; +export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; +export type { SpringPreset } from "./springEase"; const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); diff --git a/packages/core/src/parsers/springEase.test.ts b/packages/core/src/parsers/springEase.test.ts new file mode 100644 index 000000000..2057448f1 --- /dev/null +++ b/packages/core/src/parsers/springEase.test.ts @@ -0,0 +1,89 @@ +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); + }); +}); diff --git a/packages/core/src/parsers/springEase.ts b/packages/core/src/parsers/springEase.ts new file mode 100644 index 000000000..3d4fccbb2 --- /dev/null +++ b/packages/core/src/parsers/springEase.ts @@ -0,0 +1,88 @@ +/** + * Damped harmonic oscillator solver for GSAP CustomEase spring curves. + * + * Generates an SVG path data string compatible with `CustomEase.create(id, data)`. + * The solver supports underdamped (bouncy), critically damped, and overdamped + * spring configurations. Output is normalized to x ∈ [0,1] with y starting at 0 + * and settling to 1. + */ + +export interface SpringPreset { + name: string; + label: string; + mass: number; + stiffness: number; + damping: number; +} + +export const SPRING_PRESETS: SpringPreset[] = [ + { name: "spring-gentle", label: "Gentle", mass: 1, stiffness: 100, damping: 15 }, + { name: "spring-bouncy", label: "Bouncy", mass: 1, stiffness: 180, damping: 12 }, + { name: "spring-stiff", label: "Stiff", mass: 1, stiffness: 300, damping: 20 }, + { name: "spring-wobbly", label: "Wobbly", mass: 1, stiffness: 120, damping: 8 }, + { name: "spring-heavy", label: "Heavy", mass: 3, stiffness: 200, damping: 20 }, +]; + +/** + * Solve a damped harmonic oscillator and return a GSAP CustomEase data string. + * + * The output is an SVG path (`M0,0 L... L...`) that CustomEase.create() accepts. + * The curve is normalized so x spans [0,1] and the spring settles at y = 1. + * + * @param mass - Spring mass (> 0) + * @param stiffness - Spring stiffness constant (> 0) + * @param damping - Damping coefficient (> 0) + * @param steps - Number of sample points (default 120) + */ +export function generateSpringEaseData( + mass: number, + stiffness: number, + damping: number, + steps = 120, +): string { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + + // Determine simulation duration: time until oscillation settles within threshold of 1.0. + // Underdamped: ~5 time constants. Critically/overdamped: characteristic decay time. + let settleDuration: number; + if (zeta < 1) { + settleDuration = Math.min(5 / (zeta * w0), 10); + } else { + const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); + settleDuration = Math.min(4 / Math.max(decayRate, 0.01), 10); + } + const simDuration = Math.max(settleDuration, 1); + + const segments: string[] = ["M0,0"]; + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const simT = t * simDuration; + let value: number; + + if (zeta < 1) { + // Underdamped — oscillates before settling + const wd = w0 * Math.sqrt(1 - zeta * zeta); + value = + 1 - + Math.exp(-zeta * w0 * simT) * + (Math.cos(wd * simT) + ((zeta * w0) / wd) * Math.sin(wd * simT)); + } else if (zeta === 1) { + // Critically damped — fastest approach without oscillation + value = 1 - (1 + w0 * simT) * Math.exp(-w0 * simT); + } else { + // Overdamped — slow exponential approach + const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); + const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); + value = 1 + (s1 * Math.exp(s2 * simT) - s2 * Math.exp(s1 * simT)) / (s2 - s1); + } + + segments.push(`${t.toFixed(4)},${value.toFixed(4)}`); + } + + // Force exact endpoint + segments[segments.length - 1] = "1,1"; + + return `${segments[0]} L${segments.slice(1).join(" ")}`; +} diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index b21160f8f..584e7304a 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -14,6 +14,9 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd timeline.pause(); const safeTime = Math.max(0, Number(ctx.time) || 0); if (typeof timeline.totalTime === "function") { + // GSAP 3.x skips rendering when the new totalTime equals _tTime. + // Nudge first to force a dirty state, then seek to the exact time. + timeline.totalTime(safeTime + 0.001, true); timeline.totalTime(safeTime, false); } else { timeline.seek(safeTime, false); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 38a0a901d..e6dd5ea77 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -708,87 +708,7 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { root: tl }; initSandboxRuntimeModular(); - expect(seekTimes.length).toBeGreaterThan(0); - expect(seekTimes[0]).toBe(0); - }); - - describe("sub-composition audio global start offset (regression #1174)", () => { - // Audio inside a sub-composition must account for the host's data-start - // on the root timeline. Before the fix, resolveGlobalAudioStart was not - // called and the local data-start (typically 0) was used instead. - - it("does not seek sub-comp audio before its host composition starts", () => { - // slide-2 host: data-start="10", audio inside: data-start="0" - document.body.innerHTML = ` -
-
- -
-
- `; - window.__timelines = { root: createMockTimeline(20) }; - initSandboxRuntimeModular(); - - const audio = document.querySelector("audio") as HTMLAudioElement; - const seeksSeen: number[] = []; - Object.defineProperty(audio, "currentTime", { - get: () => 0, - set: (v: number) => seeksSeen.push(v), - configurable: true, - }); - - // Seek to t=5 — before slide-2 starts (global 10). Audio must not be touched. - window.__player?.renderSeek(5); - expect(seeksSeen).toHaveLength(0); - }); - - it("seeks sub-comp audio to the correct relative position when the host is active", () => { - document.body.innerHTML = ` -
-
- -
-
- `; - window.__timelines = { root: createMockTimeline(20) }; - initSandboxRuntimeModular(); - - const audio = document.querySelector("audio") as HTMLAudioElement; - const seeksSeen: number[] = []; - Object.defineProperty(audio, "currentTime", { - get: () => 0, - set: (v: number) => seeksSeen.push(v), - configurable: true, - }); - - // Seek to t=12 — 2s into slide-2. Audio should be at relTime = 12 - 10 = 2. - window.__player?.renderSeek(12); - expect(seeksSeen).toContain(2); - }); - - it("handles audio in root (no composition host) without offset", () => { - document.body.innerHTML = ` -
- -
- `; - window.__timelines = { root: createMockTimeline(20) }; - initSandboxRuntimeModular(); - - const audio = document.querySelector("audio") as HTMLAudioElement; - const seeksSeen: number[] = []; - Object.defineProperty(audio, "currentTime", { - get: () => 0, - set: (v: number) => seeksSeen.push(v), - configurable: true, - }); - - // Seek to t=5 — audio at root level, offset = 0, relTime = 5 - 0 = 5. - window.__player?.renderSeek(5); - expect(seeksSeen).toContain(5); - }); + expect(seekTimes.length).toBeGreaterThanOrEqual(2); + expect(seekTimes[seekTimes.length - 1]).toBe(0); }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 15cfc57ea..2a9200e24 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -952,6 +952,34 @@ export function initSandboxRuntimeModular(): void { if (typeof state.capturedTimeline.totalTime === "function") { state.capturedTimeline.totalTime(seekTime, false); } + + // Strip stale CSS offset artifacts from GSAP-targeted elements. + // These leak into the HTML when the CSS offset path fires for a + // GSAP-animated element (stale cache race). On reload, both the + // offset and GSAP transform stack, doubling the visual position. + const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]"); + if (staleEls.length > 0 && state.capturedTimeline.getChildren) { + const tweenTargets = new Set(); + try { + for (const child of state.capturedTimeline.getChildren(true)) { + if (typeof child.targets === "function") { + for (const t of child.targets()) tweenTargets.add(t); + } + } + } catch { + /* timeline access guard */ + } + for (const el of staleEls) { + if (!tweenTargets.has(el)) continue; + const htmlEl = el as HTMLElement; + htmlEl.removeAttribute("data-hf-studio-path-offset"); + htmlEl.removeAttribute("data-hf-studio-original-translate"); + htmlEl.removeAttribute("data-hf-studio-original-inline-translate"); + htmlEl.style.removeProperty("--hf-studio-offset-x"); + htmlEl.style.removeProperty("--hf-studio-offset-y"); + htmlEl.style.removeProperty("translate"); + } + } } if (resolution.diagnostics) { postRuntimeMessage({ @@ -1319,19 +1347,11 @@ export function initSandboxRuntimeModular(): void { const context = resolveMediaCompositionContext( element as HTMLVideoElement | HTMLAudioElement, ); - // resolveStartForElement resolves the element's position on the ROOT - // timeline, correctly summing ancestor composition-host offsets via - // resolveHostOffsetForElement. For elements WITH explicit data-start, - // the fallback is ignored and the host offset is always applied — this - // fixes the bug where data-start="0" audio inside a sub-composition at - // a non-zero host start was scheduled at global 0. - // For elements WITHOUT data-start (inherited timing), the fallback is - // set to inheritedStart to preserve the "fill the host window" behavior. - return resolveStartForElement(element, context.inheritedStart ?? 0); + return resolveMediaStartSeconds(element, context.inheritedStart ?? 0); }, resolveDurationSeconds: (element) => { const context = resolveMediaCompositionContext(element); - const start = resolveStartForElement(element, context.inheritedStart ?? 0); + const start = resolveMediaStartSeconds(element, context.inheritedStart ?? 0); const mediaStart = Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || 0; @@ -1907,7 +1927,7 @@ export function initSandboxRuntimeModular(): void { let foundActive = false; for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - const start = resolveStartForElement(rawEl, 0); + const start = Number.parseFloat(rawEl.dataset.start ?? ""); const durAttr = Number.parseFloat(rawEl.dataset.duration ?? ""); const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity; const mediaStart = @@ -1974,7 +1994,7 @@ export function initSandboxRuntimeModular(): void { for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; if (!el.isConnected) continue; - const start = resolveStartForElement(el, 0); + const start = Number.parseFloat(el.dataset.start ?? ""); if (!Number.isFinite(start)) continue; const durAttr = Number.parseFloat(el.dataset.duration ?? ""); const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity; @@ -2022,7 +2042,7 @@ export function initSandboxRuntimeModular(): void { const audioEls = document.querySelectorAll("audio[data-start]"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - const compStart = resolveStartForElement(rawEl, 0); + const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); if (!Number.isFinite(compStart)) continue; const mediaStart = Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0; diff --git a/packages/producer/tests/animejs-adapter/output/output.mp4 b/packages/producer/tests/animejs-adapter/output/output.mp4 index 9f1fb733d..55e917a05 100644 --- a/packages/producer/tests/animejs-adapter/output/output.mp4 +++ b/packages/producer/tests/animejs-adapter/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68dbadce0d0178668532a07cf8b68a81956039ccc3c1628d958e10152d8c142f -size 272293 +oid sha256:a157fb1d4248a4661e5b6510b949a652f8d7d2e73af431175f6fff3911d7e647 +size 355564 diff --git a/packages/producer/tests/css-spinner-render-compat/output/output.mp4 b/packages/producer/tests/css-spinner-render-compat/output/output.mp4 index 5090ac3c8..910bc6475 100644 --- a/packages/producer/tests/css-spinner-render-compat/output/output.mp4 +++ b/packages/producer/tests/css-spinner-render-compat/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62973a843644f06edbb8c0c4ef342e78d3e3a164c7f8ad19bcccde4cc0bf2706 -size 181125 +oid sha256:d49bbe3e2b472d48c2c88ab5f10fd9111147cf77536149def51fef5232b10bb7 +size 227604 diff --git a/packages/producer/tests/dogs-captions/output/output.mp4 b/packages/producer/tests/dogs-captions/output/output.mp4 index d7d4df3d9..262d0f68e 100644 --- a/packages/producer/tests/dogs-captions/output/output.mp4 +++ b/packages/producer/tests/dogs-captions/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f7f7985a6424247d988cbd0b5025280a0c03557e8f7efaaf52f63f519d12fb5 -size 57189876 +oid sha256:5be3b83cdeee44ffdb94ce9fd8790f0e5b4610956017c03aeed1108df324bb29 +size 101062815 diff --git a/packages/producer/tests/font-variant-numeric/output/output.mp4 b/packages/producer/tests/font-variant-numeric/output/output.mp4 index daec8f466..953b32a08 100644 --- a/packages/producer/tests/font-variant-numeric/output/output.mp4 +++ b/packages/producer/tests/font-variant-numeric/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:506ae124493ffc9bfc543eae709106249646069937014ded2d6edbe661ce832a -size 151013 +oid sha256:18cd0f3a92bce5740b5490498b50ea0e4f2277b7660fe31c4e496aef21cc81d2 +size 184768 diff --git a/packages/producer/tests/hdr-hlg-regression/output/output.mp4 b/packages/producer/tests/hdr-hlg-regression/output/output.mp4 index 124db64de..bb109bbb8 100644 --- a/packages/producer/tests/hdr-hlg-regression/output/output.mp4 +++ b/packages/producer/tests/hdr-hlg-regression/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a55a505de180ef3eea4311848bc58d56778a0a291713726b8c27f07e80d5bfe -size 6156319 +oid sha256:55888e857a775146989737055aaea82cf2af0ab8122beaf3fa7ef9b1caec5734 +size 6145183 diff --git a/packages/producer/tests/hdr-regression/output/output.mp4 b/packages/producer/tests/hdr-regression/output/output.mp4 index 318c78242..17bbbe453 100644 --- a/packages/producer/tests/hdr-regression/output/output.mp4 +++ b/packages/producer/tests/hdr-regression/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef13545af8288619f3c493d9b684f49ed7de007229f16970f34c741d3e6d2a9d -size 1746876 +oid sha256:69c2f759a418a6e85094744011f5c267a2f311527de880b8d0ceb7cd46279c15 +size 1793594 diff --git a/packages/producer/tests/iframe-render-compat/output/output.mp4 b/packages/producer/tests/iframe-render-compat/output/output.mp4 index 46f364d62..15e79889e 100644 --- a/packages/producer/tests/iframe-render-compat/output/output.mp4 +++ b/packages/producer/tests/iframe-render-compat/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b15b0eab1044a450bd347a314adf786c50a2e6d0826ddc7a572b6fb59d502e4 -size 299896 +oid sha256:cfc17083ef256c59fb9ece1146e9a70cdc9dabf67a9dfa03301b1708ff49b1cc +size 375998 diff --git a/packages/producer/tests/many-cuts/output/output.mp4 b/packages/producer/tests/many-cuts/output/output.mp4 index b43239a5e..4666a16e9 100644 --- a/packages/producer/tests/many-cuts/output/output.mp4 +++ b/packages/producer/tests/many-cuts/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aeae2bc192671052a0723136398585104328b3ef337198f4443bdba6bbd61dfb -size 690712 +oid sha256:e242b3884ec5ee774078f2226b617ef4ca29da7844904006fca30514f6843854 +size 691095 diff --git a/packages/producer/tests/overlay-montage-prod/output/output.mp4 b/packages/producer/tests/overlay-montage-prod/output/output.mp4 index dc4d0508b..f3c8e9669 100644 --- a/packages/producer/tests/overlay-montage-prod/output/output.mp4 +++ b/packages/producer/tests/overlay-montage-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa582b953fe0a7ae80adfc033c41bae27027600a58507031f61b6b524d49daed -size 27986185 +oid sha256:337ad84a03f1db76ba686f0ac6ecaca6e8c4ca14d1c26469f0c168bbe4f409a1 +size 27982739 diff --git a/packages/producer/tests/raf-ball-render-compat/output/output.mp4 b/packages/producer/tests/raf-ball-render-compat/output/output.mp4 index 21de99d5b..a8264cd3d 100644 --- a/packages/producer/tests/raf-ball-render-compat/output/output.mp4 +++ b/packages/producer/tests/raf-ball-render-compat/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62d8f9eb45b68d96242e7c8cf01fe4e74a142c5dcfe82953724fd98d7567aa92 -size 177730 +oid sha256:c43b346547ccc03bd4056e97efcff30ff4e2061de46b21e90541e65630e32e9f +size 169122 diff --git a/packages/producer/tests/style-1-prod/output/output.mp4 b/packages/producer/tests/style-1-prod/output/output.mp4 index 706b7e95c..f4a33dd22 100644 --- a/packages/producer/tests/style-1-prod/output/output.mp4 +++ b/packages/producer/tests/style-1-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:821a6b69e412a09560e9412ce0faaa0b1fe78163a2c520173408d6ac19129d3c -size 8383424 +oid sha256:e8e2a35eaa5913ba1248ee5db59d9273ebdf880164adea90fec973be844639bb +size 8408639 diff --git a/packages/producer/tests/style-10-prod/output/output.mp4 b/packages/producer/tests/style-10-prod/output/output.mp4 index 93ab307e1..2d31f9747 100644 --- a/packages/producer/tests/style-10-prod/output/output.mp4 +++ b/packages/producer/tests/style-10-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f09f5074edd21e2431171bfc7c7e9ed1ca6e5104a347721ac13ecadb625fe10a -size 4884784 +oid sha256:4dcecc39634a51efe1709f75b016b1a90a8eeea72f31b3e15d5cae43dfc8021e +size 11356525 diff --git a/packages/producer/tests/style-11-prod/output/output.mp4 b/packages/producer/tests/style-11-prod/output/output.mp4 index 6b3704042..0d8fbf148 100644 --- a/packages/producer/tests/style-11-prod/output/output.mp4 +++ b/packages/producer/tests/style-11-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f43cdf7e1886961e1d7f0bf5b3d05e3ee94f8013225465d7612aa9e2208a0c1 -size 5626701 +oid sha256:34946fc5e0166dfefff4f278788e06b07a45855b5bd78aad1199283ccd9a6df8 +size 9684356 diff --git a/packages/producer/tests/style-12-prod/output/output.mp4 b/packages/producer/tests/style-12-prod/output/output.mp4 index d8ec5799a..e51514cb2 100644 --- a/packages/producer/tests/style-12-prod/output/output.mp4 +++ b/packages/producer/tests/style-12-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a46a4d49cc13ca5650ec44addb3dc276f23906e98e3f581ac59594eddedfc8e -size 11090919 +oid sha256:21fde191f0b1c2e89f4dace8419b3f2274e44652a35d1ecaddd8fac5774413e8 +size 11095224 diff --git a/packages/producer/tests/style-13-prod/output/output.mp4 b/packages/producer/tests/style-13-prod/output/output.mp4 index cd2117db5..86495fde1 100644 --- a/packages/producer/tests/style-13-prod/output/output.mp4 +++ b/packages/producer/tests/style-13-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eebad98e4c4b2243e8b21732b95f46735304d98a209a51ce761d2e30719b378c -size 12828773 +oid sha256:a0809b9b98e70674bef3da96055fd0c33f807bf26eac9e14c42acd13e270f2e4 +size 14425452 diff --git a/packages/producer/tests/style-15-prod/output/output.mp4 b/packages/producer/tests/style-15-prod/output/output.mp4 index dd1746523..1ba20a04b 100644 --- a/packages/producer/tests/style-15-prod/output/output.mp4 +++ b/packages/producer/tests/style-15-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87726d8fff0ca08d7dc3be938c4d680f6f3646a72dcb2f85413e6f8a202060f7 -size 22401485 +oid sha256:da0cafd18e7bcb5ce862a6d7a1362b62866342c360fe83b37cb199b7d8a0717c +size 22377910 diff --git a/packages/producer/tests/style-16-prod/output/output.mp4 b/packages/producer/tests/style-16-prod/output/output.mp4 index 8e2f89295..b0faebbd0 100644 --- a/packages/producer/tests/style-16-prod/output/output.mp4 +++ b/packages/producer/tests/style-16-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa7e5d324d183f9edbab0a428954428fa204dc53dd08615237d7994cdb265998 -size 12322028 +oid sha256:fe6cf86c3a374bdd47e083be44f4348fb047ebfa8b6094fd92a152c02c5a98bb +size 12317398 diff --git a/packages/producer/tests/style-17-prod/output/output.mp4 b/packages/producer/tests/style-17-prod/output/output.mp4 index 74663d0a2..9eeed78c3 100644 --- a/packages/producer/tests/style-17-prod/output/output.mp4 +++ b/packages/producer/tests/style-17-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abe913663b03ac7ea0d47427e38fc26b0afde872a91444636a7299c1168fc2ee -size 17106658 +oid sha256:e43a77c57c8d14e0939688dfa1bb29becee026a6d9ed759e07c8b7ba26f5a1ef +size 17110152 diff --git a/packages/producer/tests/style-2-prod/output/output.mp4 b/packages/producer/tests/style-2-prod/output/output.mp4 index 1370d64c5..f8e09a22b 100644 --- a/packages/producer/tests/style-2-prod/output/output.mp4 +++ b/packages/producer/tests/style-2-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85580df0aed8d88978dca64536cbeea789af8fdf6c2f05522a02133677b26e43 -size 6477072 +oid sha256:8014db458d78b0604f1175fb322839508207ff18d101e653b73cf5b4fd49ace1 +size 10976927 diff --git a/packages/producer/tests/style-3-prod/output/output.mp4 b/packages/producer/tests/style-3-prod/output/output.mp4 index 76b0bf2cf..8e1d7aa51 100644 --- a/packages/producer/tests/style-3-prod/output/output.mp4 +++ b/packages/producer/tests/style-3-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:834b825c0d4f855b294a3a5db7629fbe28e38c43c713871589235960ebc614ad -size 7201159 +oid sha256:a3312c37eec0a979d020700beae78ddf7ff2d67d0cf1f7920ad91ce256f0f1de +size 7191465 diff --git a/packages/producer/tests/style-4-prod/output/output.mp4 b/packages/producer/tests/style-4-prod/output/output.mp4 index 6dcb3360d..00c4b1dd0 100644 --- a/packages/producer/tests/style-4-prod/output/output.mp4 +++ b/packages/producer/tests/style-4-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24b61269e44dfe3fb6954237774703b2b4cf30a9f49404374a0004f4303c59da -size 18345597 +oid sha256:c5e8c2e0e78255e039b4a032a9ffa11a40aaef32a84b8e7bf959ff91b8f030ce +size 30465373 diff --git a/packages/producer/tests/style-5-prod/output/output.mp4 b/packages/producer/tests/style-5-prod/output/output.mp4 index 22a99ad7d..4337395d9 100644 --- a/packages/producer/tests/style-5-prod/output/output.mp4 +++ b/packages/producer/tests/style-5-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1c6af0c1460f17cfe8c28b1813d2d28807c3c848ea313c49b2f54f498ffb3bd -size 11365418 +oid sha256:4062ca66ba4688d9025c3ad28a2272d231234e004bc42d8be693288e8e80c801 +size 11345947 diff --git a/packages/producer/tests/style-6-prod/output/output.mp4 b/packages/producer/tests/style-6-prod/output/output.mp4 index 3a8782aeb..e76483228 100644 --- a/packages/producer/tests/style-6-prod/output/output.mp4 +++ b/packages/producer/tests/style-6-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebf2afb488df6e1bb8e102858dac79d86298f713d9eeff255bd1b1848b25dfbe -size 7516945 +oid sha256:90fab14cda8fc975b87807114d0371f8b463e08d5114d3322961eade3315d61e +size 11692359 diff --git a/packages/producer/tests/style-7-prod/output/output.mp4 b/packages/producer/tests/style-7-prod/output/output.mp4 index 6a31778cd..42facdf17 100644 --- a/packages/producer/tests/style-7-prod/output/output.mp4 +++ b/packages/producer/tests/style-7-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc63320024888aadc78256bdd6485362a8f27523a6a1cdc196db2dd0ba73ecae -size 12264858 +oid sha256:07aebceb20c5963e7a2feb392a564e2f592c39490dba1f6af07bb08b6a3c63d8 +size 12267006 diff --git a/packages/producer/tests/style-8-prod/output/output.mp4 b/packages/producer/tests/style-8-prod/output/output.mp4 index 9b9734d4d..f678ea670 100644 --- a/packages/producer/tests/style-8-prod/output/output.mp4 +++ b/packages/producer/tests/style-8-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a005acdf38870cea1e4bb2e870bbd662ebb26dba60539481cda77a4cb719f42e -size 14507933 +oid sha256:89676df3eabed0e31d9764457b08273a3fb96726ce3884e985b64030ea5be80c +size 14505075 diff --git a/packages/producer/tests/style-9-prod/output/output.mp4 b/packages/producer/tests/style-9-prod/output/output.mp4 index 8006a274a..e60cbb478 100644 --- a/packages/producer/tests/style-9-prod/output/output.mp4 +++ b/packages/producer/tests/style-9-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c5c34878cfe63ce79e60fe792c018e7b0b481ca59bd9c0dec558ed5e7cbb341 -size 13503597 +oid sha256:8caa731179c860a8a112c8f8b217098f2d6e8243d1fd7b05075c632e746525c4 +size 13512151 diff --git a/packages/producer/tests/sub-comp-height-percent/output/output.mp4 b/packages/producer/tests/sub-comp-height-percent/output/output.mp4 index a8829cd43..4792d1c17 100644 --- a/packages/producer/tests/sub-comp-height-percent/output/output.mp4 +++ b/packages/producer/tests/sub-comp-height-percent/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86b9674608f35094c3cadd2c72f7c51f535f565f477b6275aef5314a0fe17847 -size 68603 +oid sha256:dbb6ea0ce356215a63eab4bbe5fa9e50b6444a9e600dee16bdadaf6bb8fb20a6 +size 66475 diff --git a/packages/producer/tests/sub-comp-id-selector/output/output.mp4 b/packages/producer/tests/sub-comp-id-selector/output/output.mp4 index 13bfd33a2..b75c993c7 100644 --- a/packages/producer/tests/sub-comp-id-selector/output/output.mp4 +++ b/packages/producer/tests/sub-comp-id-selector/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed6ecf20f2b3a6074c0fe828591f2d6566e2ed7496fbfa7b4272e78c6a813011 -size 55844 +oid sha256:827a763085f861fbc3d876d8f366cb34be19e610759a0dd8793edcf1c1716d55 +size 63359 diff --git a/packages/producer/tests/sub-comp-t0/output/output.mp4 b/packages/producer/tests/sub-comp-t0/output/output.mp4 index 92ffe267f..d18382cff 100644 --- a/packages/producer/tests/sub-comp-t0/output/output.mp4 +++ b/packages/producer/tests/sub-comp-t0/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7acd4e38f70f528105dabd4eb9717e2ab369755817e42141c98401ae5ed6009c -size 426899 +oid sha256:33597d183557e6111b65875831fc41609f1fd6ac10978e8ac5ffbe4e36d6d4f3 +size 430821 diff --git a/packages/producer/tests/sub-composition-video/output/output.mp4 b/packages/producer/tests/sub-composition-video/output/output.mp4 index fb053d81b..4b7073752 100644 --- a/packages/producer/tests/sub-composition-video/output/output.mp4 +++ b/packages/producer/tests/sub-composition-video/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36ef88d84340f4ab2aabc17b702d09139fb6614475a4497f1995fd7be539dede -size 12350905 +oid sha256:0634b075260874394d42dd959e998ca611e52fd812ef2bd54f2f239c4ce18d35 +size 13521357 diff --git a/packages/producer/tests/typegpu-adapter/output/output.mp4 b/packages/producer/tests/typegpu-adapter/output/output.mp4 index e14770a56..8de139330 100644 --- a/packages/producer/tests/typegpu-adapter/output/output.mp4 +++ b/packages/producer/tests/typegpu-adapter/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a557eb752ae886ba36636e8ddad5eaf9d8f42ed1c0c6495ced669231f93d335 -size 161439 +oid sha256:47b2134791c4103e1e1feffcf968b3bc8a75cfe85b617a284ab37fc065b6949c +size 161445 diff --git a/packages/producer/tests/variables-prod/output/output.mp4 b/packages/producer/tests/variables-prod/output/output.mp4 index ba8eaf31e..c7bca5a9f 100644 --- a/packages/producer/tests/variables-prod/output/output.mp4 +++ b/packages/producer/tests/variables-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be9a5689e27272b191fa566843e45f88f728b36f677a3db49f89e9193627a667 -size 117111 +oid sha256:694ae6f83914f3c51161804ffdb51acddc02bb523a0fa877f8bff667bfa0b5e9 +size 117758 diff --git a/packages/producer/tests/vfr-screen-recording/output/output.mp4 b/packages/producer/tests/vfr-screen-recording/output/output.mp4 index 4e8f676d0..7821859af 100644 --- a/packages/producer/tests/vfr-screen-recording/output/output.mp4 +++ b/packages/producer/tests/vfr-screen-recording/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7aec7945d08be6b3c69464d9cf89445c44d52c9e0f89bad367592268964b2b22 -size 472149 +oid sha256:0bc043a8148f8c1f76ddeba3b76e2ddafe718981cec572c59c57dc4c076dcb49 +size 571145 diff --git a/packages/studio/src/components/editor/SpringEaseEditor.tsx b/packages/studio/src/components/editor/SpringEaseEditor.tsx new file mode 100644 index 000000000..852f2a32d --- /dev/null +++ b/packages/studio/src/components/editor/SpringEaseEditor.tsx @@ -0,0 +1,256 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease"; +import { LABEL } from "./MotionPanelFields"; +import { RotateCcw } from "../../icons/SystemIcons"; + +interface SpringParams { + mass: number; + stiffness: number; + damping: number; +} + +const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 }; + +const SLIDERS: { + key: keyof SpringParams; + label: string; + min: number; + max: number; + step: number; +}[] = [ + { key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 }, + { key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 }, + { key: "damping", label: "Damping", min: 1, max: 50, step: 1 }, +]; + +function springValue(mass: number, stiffness: number, damping: number, t: number): number { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + if (zeta < 1) { + const wd = w0 * Math.sqrt(1 - zeta * zeta); + return ( + 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t)) + ); + } + if (zeta === 1) { + return 1 - (1 + w0 * t) * Math.exp(-w0 * t); + } + const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); + const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); + return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1); +} + +function springSimDuration(mass: number, stiffness: number, damping: number): number { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + if (zeta < 1) return Math.min(5 / (zeta * w0), 10); + const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); + return Math.min(4 / Math.max(decayRate, 0.01), 10); +} + +function buildSpringPath( + params: SpringParams, + mapFn: (point: { x: number; y: number }) => { x: number; y: number }, +): string { + const steps = 64; + const simDur = springSimDuration(params.mass, params.stiffness, params.damping); + const commands: string[] = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const simT = t * simDur; + const y = springValue(params.mass, params.stiffness, params.damping, simT); + const mapped = mapFn({ x: t, y }); + commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`); + } + return commands.join(" "); +} + +export function SpringEaseEditor({ + onCommit, +}: { + onCommit: (easeId: string, easeData: string) => void; +}) { + const [params, setParams] = useState(DEFAULT_SPRING); + const commitTimeoutRef = useRef | null>(null); + + const scheduleCommit = useCallback( + (next: SpringParams) => { + if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); + commitTimeoutRef.current = setTimeout(() => { + const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); + const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`; + onCommit(id, data); + }, 120); + }, + [onCommit], + ); + + useEffect(() => { + return () => { + if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); + }; + }, []); + + const updateParam = (key: keyof SpringParams, value: number) => { + const next = { ...params, [key]: value }; + setParams(next); + scheduleCommit(next); + }; + + const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => { + const next: SpringParams = { + mass: preset.mass, + stiffness: preset.stiffness, + damping: preset.damping, + }; + setParams(next); + const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); + onCommit(preset.name, data); + }; + + const reset = () => { + setParams(DEFAULT_SPRING); + const data = generateSpringEaseData( + DEFAULT_SPRING.mass, + DEFAULT_SPRING.stiffness, + DEFAULT_SPRING.damping, + ); + onCommit("spring-bouncy", data); + }; + + // SVG layout matching EaseCurveEditor proportions + const width = 324; + const height = 214; + const plot = { left: 46, top: 24, width: 242, height: 146 }; + const yMin = -0.2; + const yMax = 1.3; + + const mapPoint = (point: { x: number; y: number }) => ({ + x: plot.left + point.x * plot.width, + y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height, + }); + + const curvePath = buildSpringPath(params, mapPoint); + const start = mapPoint({ x: 0, y: 0 }); + const end = mapPoint({ x: 1, y: 1 }); + + const activePreset = SPRING_PRESETS.find( + (p) => + p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping, + ); + + return ( +
+
+
+
Spring Ease
+
+ {activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`} +
+
+ +
+ + {/* Curve preview */} + + + {[0, 0.5, 1].map((value) => { + const mapped = mapPoint({ x: 0, y: value }); + return ( + + + + {value} + + + ); + })} + + + + + + + + {/* Presets */} +
+ {SPRING_PRESETS.map((preset) => { + const isActive = + preset.mass === params.mass && + preset.stiffness === params.stiffness && + preset.damping === params.damping; + return ( + + ); + })} +
+ + {/* Sliders */} +
+ {SLIDERS.map((slider) => ( +
+
+ + {slider.label} + + + {params[slider.key]} + +
+ updateParam(slider.key, Number(e.target.value))} + className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400" + /> +
+ ))} +
+
+ ); +}