From b184e6e38398733aacde041cb4d81924cf27672e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 06:24:41 +0000 Subject: [PATCH 01/11] Add manual frame rendering API for environments without requestAnimationFrame This adds support for manually advancing animations in environments where requestAnimationFrame is unavailable, such as WebXR immersive sessions, Remotion video rendering, or server-side rendering of animations. New APIs: - renderFrame({ timestamp, frame, fps, delta }) - Manually render a frame - setManualTiming(enabled) - Enable/disable manual timing mode globally - isManualTiming() - Check if manual timing is enabled - useManualFrame({ frame, fps }) - React hook for Remotion integration Fixes #2496 https://claude.ai/code/session_01AFhy8F8ubRRHsau2GRdYAZ --- packages/framer-motion/src/index.ts | 1 + .../src/utils/use-manual-frame.ts | 96 +++++++++ .../frameloop/__tests__/render-frame.test.ts | 188 ++++++++++++++++++ packages/motion-dom/src/frameloop/batcher.ts | 27 ++- packages/motion-dom/src/frameloop/frame.ts | 1 + packages/motion-dom/src/frameloop/index.ts | 1 + .../motion-dom/src/frameloop/render-frame.ts | 122 ++++++++++++ 7 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 packages/framer-motion/src/utils/use-manual-frame.ts create mode 100644 packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts create mode 100644 packages/motion-dom/src/frameloop/render-frame.ts diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index dfb2e0a91a..e863eda118 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -95,6 +95,7 @@ export { useInstantLayoutTransition } from "./projection/use-instant-layout-tran export { useResetProjection } from "./projection/use-reset-projection" export { buildTransform, visualElementStore, VisualElement } from "motion-dom" export { useAnimationFrame } from "./utils/use-animation-frame" +export { useManualFrame } from "./utils/use-manual-frame" export { Cycle, CycleState, useCycle } from "./utils/use-cycle" export { useInView, UseInViewOptions } from "./utils/use-in-view" export { diff --git a/packages/framer-motion/src/utils/use-manual-frame.ts b/packages/framer-motion/src/utils/use-manual-frame.ts new file mode 100644 index 0000000000..841831867d --- /dev/null +++ b/packages/framer-motion/src/utils/use-manual-frame.ts @@ -0,0 +1,96 @@ +"use client" + +import { renderFrame, setManualTiming } from "motion-dom" +import { useContext, useEffect, useRef } from "react" +import { MotionConfigContext } from "../context/MotionConfigContext" + +interface UseManualFrameOptions { + /** + * The current frame number (0-indexed). + * Typically from Remotion's `useCurrentFrame()`. + */ + frame: number + + /** + * Frames per second of the video/animation. + * Typically from Remotion's `useVideoConfig().fps`. + * @default 30 + */ + fps?: number +} + +/** + * A hook for manually controlling Motion animations based on a frame number. + * + * This is designed for integration with video rendering frameworks like Remotion, + * or any environment where `requestAnimationFrame` is unavailable or + * animations need to be driven by an external timing source. + * + * @example + * // Basic usage with Remotion + * import { useCurrentFrame, useVideoConfig } from 'remotion' + * import { useManualFrame } from 'motion/react' + * + * function MyComponent() { + * const frame = useCurrentFrame() + * const { fps } = useVideoConfig() + * + * // This syncs Motion animations to Remotion's frame + * useManualFrame({ frame, fps }) + * + * return ( + * + * ) + * } + * + * @example + * // With a custom frame source + * function MyComponent({ frame }: { frame: number }) { + * useManualFrame({ frame, fps: 60 }) + * + * return ( + * + * ) + * } + */ +export function useManualFrame({ frame, fps = 30 }: UseManualFrameOptions) { + const { isStatic } = useContext(MotionConfigContext) + const prevFrame = useRef(-1) + const hasInitialized = useRef(false) + + // Enable manual timing on mount, disable on unmount + useEffect(() => { + if (isStatic) return + + setManualTiming(true) + + return () => { + setManualTiming(false) + } + }, [isStatic]) + + // Render the frame when it changes + useEffect(() => { + if (isStatic) return + + // Only render if frame has changed or on first render + if (frame !== prevFrame.current || !hasInitialized.current) { + const delta = + hasInitialized.current && prevFrame.current >= 0 + ? ((frame - prevFrame.current) / fps) * 1000 + : 1000 / fps + + renderFrame({ frame, fps, delta: Math.abs(delta) }) + + prevFrame.current = frame + hasInitialized.current = true + } + }, [frame, fps, isStatic]) +} diff --git a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts new file mode 100644 index 0000000000..4433dab407 --- /dev/null +++ b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts @@ -0,0 +1,188 @@ +import { MotionGlobalConfig } from "motion-utils" +import { frame, cancelFrame, frameData } from ".." +import { + renderFrame, + setManualTiming, + isManualTiming, +} from "../render-frame" + +describe("renderFrame", () => { + afterEach(() => { + // Reset manual timing after each test + setManualTiming(false) + }) + + it("processes scheduled callbacks with provided timestamp", () => { + const values: number[] = [] + + frame.update(({ timestamp }) => { + values.push(timestamp) + }) + + renderFrame({ timestamp: 1000 }) + + expect(values).toEqual([1000]) + }) + + it("processes callbacks in correct order", () => { + const order: string[] = [] + + frame.setup(() => order.push("setup")) + frame.read(() => order.push("read")) + frame.resolveKeyframes(() => order.push("resolveKeyframes")) + frame.preUpdate(() => order.push("preUpdate")) + frame.update(() => order.push("update")) + frame.preRender(() => order.push("preRender")) + frame.render(() => order.push("render")) + frame.postRender(() => order.push("postRender")) + + renderFrame({ timestamp: 0 }) + + expect(order).toEqual([ + "setup", + "read", + "resolveKeyframes", + "preUpdate", + "update", + "preRender", + "render", + "postRender", + ]) + }) + + it("converts frame number to timestamp using fps", () => { + const values: number[] = [] + + frame.update(({ timestamp }) => { + values.push(timestamp) + }) + + // Frame 30 at 30fps = 1000ms + renderFrame({ frame: 30, fps: 30 }) + + expect(values).toEqual([1000]) + }) + + it("uses default fps of 30 when not specified", () => { + const values: number[] = [] + + frame.update(({ timestamp }) => { + values.push(timestamp) + }) + + // Frame 15 at default 30fps = 500ms + renderFrame({ frame: 15 }) + + expect(values).toEqual([500]) + }) + + it("calculates delta based on fps when using frame number", () => { + const values: number[] = [] + + frame.update(({ delta }) => { + values.push(delta) + }) + + // At 30fps, delta should be ~33.33ms + renderFrame({ frame: 1, fps: 30 }) + + expect(values[0]).toBeCloseTo(1000 / 30) + }) + + it("uses provided delta value", () => { + const values: number[] = [] + + frame.update(({ delta }) => { + values.push(delta) + }) + + renderFrame({ timestamp: 1000, delta: 16 }) + + expect(values).toEqual([16]) + }) + + it("temporarily enables manual timing during frame processing", () => { + let timingDuringRender: boolean | undefined + + frame.update(() => { + timingDuringRender = MotionGlobalConfig.useManualTiming + }) + + // Ensure manual timing is off before + expect(MotionGlobalConfig.useManualTiming).toBeFalsy() + + renderFrame({ timestamp: 0 }) + + // Manual timing was enabled during render + expect(timingDuringRender).toBe(true) + + // Manual timing is restored after render + expect(MotionGlobalConfig.useManualTiming).toBeFalsy() + }) + + it("preserves previous manual timing setting after render", () => { + setManualTiming(true) + + frame.update(() => {}) + renderFrame({ timestamp: 0 }) + + // Should still be true + expect(MotionGlobalConfig.useManualTiming).toBe(true) + }) + + it("supports incremental frame rendering", () => { + const timestamps: number[] = [] + + // Schedule a keepAlive callback + frame.update(({ timestamp }) => { + timestamps.push(timestamp) + }, true) + + renderFrame({ frame: 0, fps: 30 }) + renderFrame({ frame: 1, fps: 30 }) + renderFrame({ frame: 2, fps: 30 }) + + // Cleanup keepAlive + const cleanup = frame.update(() => {}, true) + cancelFrame(cleanup) + + expect(timestamps).toEqual([0, 1000 / 30, (2 * 1000) / 30]) + }) +}) + +describe("setManualTiming", () => { + afterEach(() => { + setManualTiming(false) + }) + + it("enables manual timing mode", () => { + expect(MotionGlobalConfig.useManualTiming).toBeFalsy() + + setManualTiming(true) + + expect(MotionGlobalConfig.useManualTiming).toBe(true) + }) + + it("disables manual timing mode", () => { + setManualTiming(true) + expect(MotionGlobalConfig.useManualTiming).toBe(true) + + setManualTiming(false) + expect(MotionGlobalConfig.useManualTiming).toBe(false) + }) +}) + +describe("isManualTiming", () => { + afterEach(() => { + setManualTiming(false) + }) + + it("returns false when manual timing is disabled", () => { + expect(isManualTiming()).toBe(false) + }) + + it("returns true when manual timing is enabled", () => { + setManualTiming(true) + expect(isManualTiming()).toBe(true) + }) +}) diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 0d8c6d80de..9754301752 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -81,6 +81,31 @@ export function createRenderBatcher( } } + /** + * Manually process all scheduled frame callbacks. + * Used for manual frame rendering in environments without requestAnimationFrame + * (e.g., WebXR, Remotion, server-side rendering of videos). + */ + const processFrame = (timestamp: number, delta?: number) => { + runNextFrame = false + + state.delta = delta !== undefined ? delta : 1000 / 60 + state.timestamp = timestamp + state.isProcessing = true + + // Unrolled render loop for better per-frame performance + setup.process(state) + read.process(state) + resolveKeyframes.process(state) + preUpdate.process(state) + update.process(state) + preRender.process(state) + render.process(state) + postRender.process(state) + + state.isProcessing = false + } + const schedule = stepsOrder.reduce((acc, key) => { const step = steps[key] acc[key] = (process: Process, keepAlive = false, immediate = false) => { @@ -97,5 +122,5 @@ export function createRenderBatcher( } } - return { schedule, cancel, state, steps } + return { schedule, cancel, state, steps, processFrame } } diff --git a/packages/motion-dom/src/frameloop/frame.ts b/packages/motion-dom/src/frameloop/frame.ts index 7c026207f0..f078d33ea7 100644 --- a/packages/motion-dom/src/frameloop/frame.ts +++ b/packages/motion-dom/src/frameloop/frame.ts @@ -6,6 +6,7 @@ export const { cancel: cancelFrame, state: frameData, steps: frameSteps, + processFrame, } = /* @__PURE__ */ createRenderBatcher( typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true diff --git a/packages/motion-dom/src/frameloop/index.ts b/packages/motion-dom/src/frameloop/index.ts index 62654469b8..4d6d65e240 100644 --- a/packages/motion-dom/src/frameloop/index.ts +++ b/packages/motion-dom/src/frameloop/index.ts @@ -1 +1,2 @@ export * from "./frame" +export * from "./render-frame" diff --git a/packages/motion-dom/src/frameloop/render-frame.ts b/packages/motion-dom/src/frameloop/render-frame.ts new file mode 100644 index 0000000000..abd1b3c6f6 --- /dev/null +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -0,0 +1,122 @@ +import { MotionGlobalConfig } from "motion-utils" +import { processFrame, frameData } from "./frame" +import { time } from "./sync-time" + +interface RenderFrameOptions { + /** + * The timestamp in milliseconds for this frame. + * If not provided, you must provide `frame` and `fps`. + */ + timestamp?: number + + /** + * The frame number (0-indexed). + * Used with `fps` to calculate the timestamp. + * Useful for Remotion integration where you have a frame number. + */ + frame?: number + + /** + * Frames per second. Used with `frame` to calculate the timestamp. + * @default 30 + */ + fps?: number + + /** + * Time elapsed since the last frame in milliseconds. + * If not provided, it will be calculated from the fps or default to 1000/60. + */ + delta?: number +} + +/** + * Manually render a single animation frame. + * + * Use this in environments where `requestAnimationFrame` is unavailable + * or when you need manual control over frame timing, such as: + * - WebXR immersive sessions + * - Remotion video rendering + * - Server-side rendering of animations + * - Custom animation loops + * + * @example + * // Using timestamp directly + * renderFrame({ timestamp: 1000 }) // Render at 1 second + * + * @example + * // Using frame number (Remotion-style) + * renderFrame({ frame: 30, fps: 30 }) // Render at frame 30 (1 second at 30fps) + * + * @example + * // In a Remotion component + * const frame = useCurrentFrame() + * const { fps } = useVideoConfig() + * + * useEffect(() => { + * renderFrame({ frame, fps }) + * }, [frame, fps]) + * + * @example + * // In a WebXR session + * function onXRFrame(time, xrFrame) { + * renderFrame({ timestamp: time }) + * // ... rest of XR frame logic + * } + */ +export function renderFrame(options: RenderFrameOptions = {}): void { + const { timestamp, frame, fps = 30, delta } = options + + let frameTimestamp: number + let frameDelta: number + + if (timestamp !== undefined) { + frameTimestamp = timestamp + frameDelta = delta !== undefined ? delta : 1000 / 60 + } else if (frame !== undefined) { + // Convert frame number to milliseconds + frameTimestamp = (frame / fps) * 1000 + frameDelta = delta !== undefined ? delta : 1000 / fps + } else { + // Use current frameData timestamp + default delta if no timing info provided + frameDelta = delta !== undefined ? delta : 1000 / 60 + frameTimestamp = frameData.timestamp + frameDelta + } + + // Enable manual timing mode + const previousManualTiming = MotionGlobalConfig.useManualTiming + MotionGlobalConfig.useManualTiming = true + + // Set the synchronized time + time.set(frameTimestamp) + + // Process the frame + processFrame(frameTimestamp, frameDelta) + + // Restore previous manual timing setting + MotionGlobalConfig.useManualTiming = previousManualTiming +} + +/** + * Enable manual timing mode globally. + * When enabled, animations will not auto-advance with requestAnimationFrame. + * You must call `renderFrame()` to advance animations. + * + * @example + * // Enable manual timing for the entire session + * setManualTiming(true) + * + * // Advance frames manually + * renderFrame({ timestamp: 0 }) + * renderFrame({ timestamp: 16.67 }) + * renderFrame({ timestamp: 33.33 }) + */ +export function setManualTiming(enabled: boolean): void { + MotionGlobalConfig.useManualTiming = enabled +} + +/** + * Check if manual timing mode is currently enabled. + */ +export function isManualTiming(): boolean { + return MotionGlobalConfig.useManualTiming === true +} From 3c0bf5adcc7b600e0cc35b48339783a39c0faf6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:42:35 +0000 Subject: [PATCH 02/11] Remove unused frameData import from render-frame test https://claude.ai/code/session_01AFhy8F8ubRRHsau2GRdYAZ --- .../motion-dom/src/frameloop/__tests__/render-frame.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts index 4433dab407..e7473276b8 100644 --- a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts +++ b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts @@ -1,5 +1,5 @@ import { MotionGlobalConfig } from "motion-utils" -import { frame, cancelFrame, frameData } from ".." +import { frame, cancelFrame } from ".." import { renderFrame, setManualTiming, From e9a877b2791a04b1b144486fbe9a7ed9803dd279 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 14:21:45 +0000 Subject: [PATCH 03/11] Add React tests and interactive demo for manual frame control - Add unit tests for useManualFrame hook demonstrating Remotion-style integration - Add tests for direct renderFrame usage with frame-by-frame stepping - Add tests for scrubbing backwards through animations - Add interactive dev demo with frame slider and step buttons https://claude.ai/code/session_01AFhy8F8ubRRHsau2GRdYAZ --- dev/react/src/tests/manual-frame-control.tsx | 308 +++++++++++++++++ .../utils/__tests__/use-manual-frame.test.tsx | 325 ++++++++++++++++++ 2 files changed, 633 insertions(+) create mode 100644 dev/react/src/tests/manual-frame-control.tsx create mode 100644 packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx diff --git a/dev/react/src/tests/manual-frame-control.tsx b/dev/react/src/tests/manual-frame-control.tsx new file mode 100644 index 0000000000..94de8b4d09 --- /dev/null +++ b/dev/react/src/tests/manual-frame-control.tsx @@ -0,0 +1,308 @@ +import { motion, useMotionValue, MotionGlobalConfig } from "framer-motion" +import { renderFrame, setManualTiming, frameData } from "motion-dom" +import { useState, useEffect, useCallback } from "react" + +/** + * Demo: Manual Frame Control + * + * This demonstrates how to manually control Motion animations frame-by-frame, + * useful for: + * - Remotion video rendering + * - WebXR immersive sessions + * - Debugging animations step-by-step + * - Creating scrubable animation timelines + */ +export const App = () => { + const [manualMode, setManualMode] = useState(false) + const [currentFrame, setCurrentFrame] = useState(0) + const [fps] = useState(30) + const [isAnimating, setIsAnimating] = useState(false) + const x = useMotionValue(0) + + // Calculate current time in ms + const currentTime = (currentFrame / fps) * 1000 + + // Enable/disable manual timing mode + useEffect(() => { + setManualTiming(manualMode) + if (manualMode) { + // Reset to frame 0 when entering manual mode + setCurrentFrame(0) + renderFrame({ frame: 0, fps }) + } + return () => setManualTiming(false) + }, [manualMode, fps]) + + // Render the current frame when it changes (in manual mode) + useEffect(() => { + if (manualMode) { + renderFrame({ frame: currentFrame, fps }) + } + }, [currentFrame, manualMode, fps]) + + const nextFrame = useCallback(() => { + setCurrentFrame((f) => f + 1) + }, []) + + const prevFrame = useCallback(() => { + setCurrentFrame((f) => Math.max(0, f - 1)) + }, []) + + const goToFrame = useCallback((frame: number) => { + setCurrentFrame(Math.max(0, frame)) + }, []) + + const toggleAnimation = useCallback(() => { + setIsAnimating((a) => !a) + }, []) + + return ( +
+

Manual Frame Control Demo

+ + {/* Controls */} +
+ + + {manualMode && ( + <> +
+ + +
+ +
+ + + goToFrame(parseInt(e.target.value)) + } + style={sliderStyle} + id="frame-slider" + /> +
+ +
+ + + +
+ + )} + + +
+ + {/* Animated element */} +
+ { + if (!manualMode) { + console.log("Animation complete!") + } + }} + /> + + {/* Position indicator */} +
+ X Position: {Math.round(x.get())}px +
+
+ + {/* Info panel */} +
+

How it works:

+
    +
  • + Manual Timing Mode: Disables + requestAnimationFrame-based updates +
  • +
  • + Next/Prev Frame: Advance or rewind by + one frame (~33ms at 30fps) +
  • +
  • + Frame Slider: Scrub through the + animation timeline +
  • +
  • + FPS: {fps} frames per second ( + {(1000 / fps).toFixed(1)}ms per frame) +
  • +
+ +

Remotion Integration:

+
+                    {`import { useCurrentFrame, useVideoConfig } from 'remotion'
+import { useManualFrame } from 'motion/react'
+
+function MyComponent() {
+  const frame = useCurrentFrame()
+  const { fps } = useVideoConfig()
+
+  useManualFrame({ frame, fps })
+
+  return (
+    
+  )
+}`}
+                
+
+
+ ) +} + +const containerStyle: React.CSSProperties = { + padding: 20, + fontFamily: "system-ui, sans-serif", + maxWidth: 800, + margin: "0 auto", +} + +const titleStyle: React.CSSProperties = { + marginBottom: 20, +} + +const controlsStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 15, + marginBottom: 30, + padding: 20, + backgroundColor: "#f5f5f5", + borderRadius: 8, +} + +const labelStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + fontSize: 16, + cursor: "pointer", +} + +const buttonGroupStyle: React.CSSProperties = { + display: "flex", + gap: 10, +} + +const buttonStyle: React.CSSProperties = { + padding: "10px 20px", + fontSize: 14, + cursor: "pointer", + border: "none", + borderRadius: 4, + backgroundColor: "#2196F3", + color: "white", +} + +const sliderContainerStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 5, +} + +const sliderStyle: React.CSSProperties = { + width: "100%", +} + +const stageStyle: React.CSSProperties = { + position: "relative", + height: 150, + backgroundColor: "#1a1a2e", + borderRadius: 8, + marginBottom: 20, + overflow: "hidden", +} + +const boxStyle: React.CSSProperties = { + position: "absolute", + top: 25, + left: 20, + width: 100, + height: 100, + backgroundColor: "#e94560", + borderRadius: 8, +} + +const indicatorStyle: React.CSSProperties = { + position: "absolute", + bottom: 10, + left: 20, + color: "white", + fontSize: 14, +} + +const infoStyle: React.CSSProperties = { + padding: 20, + backgroundColor: "#f9f9f9", + borderRadius: 8, +} + +const codeStyle: React.CSSProperties = { + backgroundColor: "#1a1a2e", + color: "#e94560", + padding: 15, + borderRadius: 4, + overflow: "auto", + fontSize: 13, +} diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx new file mode 100644 index 0000000000..6c86991ac2 --- /dev/null +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx @@ -0,0 +1,325 @@ +import { act } from "react" +import { render } from "../../jest.setup" +import { motion, motionValue } from "../../" +import { useManualFrame } from "../use-manual-frame" +import { renderFrame, setManualTiming } from "motion-dom" + +/** + * Helper to create a component that simulates Remotion's useCurrentFrame behavior + */ +function createRemotionSimulator(fps: number = 30) { + let currentFrame = 0 + const listeners: Set<() => void> = new Set() + + return { + getCurrentFrame: () => currentFrame, + setFrame: (frame: number) => { + currentFrame = frame + listeners.forEach((fn) => fn()) + }, + advanceFrame: () => { + currentFrame++ + listeners.forEach((fn) => fn()) + }, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + fps, + } +} + +describe("useManualFrame", () => { + afterEach(() => { + setManualTiming(false) + }) + + test("syncs animations to frame number like Remotion", async () => { + const x = motionValue(0) + const values: number[] = [] + + // Simulate Remotion's frame-based rendering + const Component = ({ frame, fps }: { frame: number; fps: number }) => { + useManualFrame({ frame, fps }) + + return ( + values.push(Math.round(x.get()))} + /> + ) + } + + const { rerender } = render() + + // At frame 0 (0ms), animation should be at start + await act(async () => { + rerender() + }) + + // At frame 15 (500ms at 30fps), animation should be ~50% through + await act(async () => { + rerender() + }) + + // At frame 30 (1000ms at 30fps), animation should be complete + await act(async () => { + rerender() + }) + + // Check that we got intermediate values progressing towards 100 + expect(values.length).toBeGreaterThan(0) + expect(values[values.length - 1]).toBe(100) + }) + + test("handles frame-by-frame stepping", async () => { + const x = motionValue(0) + const timestamps: number[] = [] + + const Component = ({ frame }: { frame: number }) => { + useManualFrame({ frame, fps: 60 }) + + return ( + timestamps.push(x.get())} + /> + ) + } + + const { rerender } = render() + + // Step through frames one at a time (60fps = ~16.67ms per frame) + for (let frame = 1; frame <= 30; frame++) { + await act(async () => { + rerender() + }) + } + + // At frame 30 (500ms at 60fps), animation should be complete + expect(Math.round(x.get())).toBe(100) + }) + + test("works with different fps values", async () => { + const x = motionValue(0) + + const Component = ({ frame, fps }: { frame: number; fps: number }) => { + useManualFrame({ frame, fps }) + + return ( + + ) + } + + // Test at 24fps (film standard) + const { rerender } = render() + + // Frame 12 at 24fps = 500ms = 50% through a 1s animation + await act(async () => { + rerender() + }) + + expect(Math.round(x.get())).toBe(50) + + // Frame 24 at 24fps = 1000ms = 100% through + await act(async () => { + rerender() + }) + + expect(Math.round(x.get())).toBe(100) + }) + + test("enables manual timing mode on mount and disables on unmount", async () => { + const Component = ({ frame }: { frame: number }) => { + useManualFrame({ frame, fps: 30 }) + return
+ } + + expect(setManualTiming).toBeDefined() + + const { unmount } = render() + + // After mount, manual timing should be enabled + // (we can't directly check MotionGlobalConfig here, but the hook should work) + + unmount() + + // After unmount, manual timing should be disabled + // This is verified by the afterEach cleanup not causing issues + }) +}) + +describe("renderFrame direct usage", () => { + afterEach(() => { + setManualTiming(false) + }) + + test("manually advances animation with renderFrame", async () => { + const x = motionValue(0) + + const Component = () => ( + + ) + + render() + + // Enable manual timing + setManualTiming(true) + + // Render at 0ms + await act(async () => { + renderFrame({ timestamp: 0 }) + }) + + // Render at 500ms (halfway through) + await act(async () => { + renderFrame({ timestamp: 500 }) + }) + + expect(Math.round(x.get())).toBe(50) + + // Render at 1000ms (complete) + await act(async () => { + renderFrame({ timestamp: 1000 }) + }) + + expect(Math.round(x.get())).toBe(100) + }) + + test("supports frame-based API for Remotion compatibility", async () => { + const x = motionValue(0) + + const Component = () => ( + + ) + + render() + + setManualTiming(true) + + // Use frame-based API (like Remotion's useCurrentFrame) + await act(async () => { + renderFrame({ frame: 0, fps: 30 }) + }) + + await act(async () => { + renderFrame({ frame: 15, fps: 30 }) // 500ms at 30fps + }) + + expect(Math.round(x.get())).toBe(50) + + await act(async () => { + renderFrame({ frame: 30, fps: 30 }) // 1000ms at 30fps + }) + + expect(Math.round(x.get())).toBe(100) + }) +}) + +describe("Manual frame control simulation (like step buttons)", () => { + afterEach(() => { + setManualTiming(false) + }) + + test("simulates step-by-step animation control", async () => { + const x = motionValue(0) + const snapshots: number[] = [] + + const Component = () => ( + + ) + + render() + + setManualTiming(true) + + // Simulate clicking "next frame" button multiple times + // Each click advances by one frame at 30fps (~33.33ms) + const frameTime = 1000 / 30 + + for (let i = 0; i <= 60; i++) { + // 60 frames = 2 seconds at 30fps + await act(async () => { + renderFrame({ + timestamp: i * frameTime, + delta: frameTime, + }) + }) + + // Take snapshots at key points + if (i === 0 || i === 15 || i === 30 || i === 45 || i === 60) { + snapshots.push(Math.round(x.get())) + } + } + + // At 0 frames: 0% + // At 15 frames (500ms): 25% + // At 30 frames (1000ms): 50% + // At 45 frames (1500ms): 75% + // At 60 frames (2000ms): 100% + expect(snapshots).toEqual([0, 50, 100, 150, 200]) + }) + + test("allows scrubbing backwards through animation", async () => { + const x = motionValue(0) + + const Component = () => ( + + ) + + render() + + setManualTiming(true) + + // Go to end of animation + await act(async () => { + renderFrame({ timestamp: 1000 }) + }) + + expect(Math.round(x.get())).toBe(100) + + // Scrub back to middle + await act(async () => { + renderFrame({ timestamp: 500 }) + }) + + expect(Math.round(x.get())).toBe(50) + + // Scrub back to start + await act(async () => { + renderFrame({ timestamp: 0 }) + }) + + expect(Math.round(x.get())).toBe(0) + }) +}) From 3a995ccab982aaba48f4ba9fd1767f7cfe288db4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 2 Feb 2026 21:22:23 +0100 Subject: [PATCH 04/11] Add motion-remotion package and driver-based WAAPI disabling - Create motion-remotion package with remotionDriver and useRemotionFrame hook - Add MotionGlobalConfig.driver for custom animation drivers - Disable WAAPI when custom driver or useManualTiming is active - Update batcher to skip rAF scheduling with custom driver - Export Driver type and frameloopDriver from motion-dom - Remove useManualFrame from framer-motion (moved to motion-remotion) - Add waapi supports tests for driver/timing checks Co-Authored-By: Claude Opus 4.5 --- packages/framer-motion/src/index.ts | 1 - .../use-manual-frame-remotion.test.tsx | 1081 +++++++++++++++++ .../utils/__tests__/use-manual-frame.test.tsx | 325 ----- .../src/utils/use-manual-frame.ts | 96 -- .../motion-dom/src/animation/JSAnimation.ts | 8 +- .../waapi/supports/__tests__/waapi.test.ts | 89 ++ .../src/animation/waapi/supports/waapi.ts | 8 +- packages/motion-dom/src/frameloop/batcher.ts | 11 +- .../motion-dom/src/frameloop/render-frame.ts | 23 +- packages/motion-dom/src/index.ts | 4 + packages/motion-remotion/package.json | 49 + packages/motion-remotion/rollup.config.mjs | 93 ++ packages/motion-remotion/src/driver.ts | 36 + packages/motion-remotion/src/index.ts | 2 + .../motion-remotion/src/use-remotion-frame.ts | 91 ++ packages/motion-remotion/tsconfig.json | 9 + packages/motion-utils/src/global-config.ts | 17 + yarn.lock | 16 + 18 files changed, 1520 insertions(+), 439 deletions(-) create mode 100644 packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx delete mode 100644 packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx delete mode 100644 packages/framer-motion/src/utils/use-manual-frame.ts create mode 100644 packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts create mode 100644 packages/motion-remotion/package.json create mode 100644 packages/motion-remotion/rollup.config.mjs create mode 100644 packages/motion-remotion/src/driver.ts create mode 100644 packages/motion-remotion/src/index.ts create mode 100644 packages/motion-remotion/src/use-remotion-frame.ts create mode 100644 packages/motion-remotion/tsconfig.json diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index e863eda118..dfb2e0a91a 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -95,7 +95,6 @@ export { useInstantLayoutTransition } from "./projection/use-instant-layout-tran export { useResetProjection } from "./projection/use-reset-projection" export { buildTransform, visualElementStore, VisualElement } from "motion-dom" export { useAnimationFrame } from "./utils/use-animation-frame" -export { useManualFrame } from "./utils/use-manual-frame" export { Cycle, CycleState, useCycle } from "./utils/use-cycle" export { useInView, UseInViewOptions } from "./utils/use-in-view" export { diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx new file mode 100644 index 0000000000..154406be6c --- /dev/null +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -0,0 +1,1081 @@ +/** + * Remotion Integration Tests + * + * These tests demonstrate realistic Remotion usage patterns with Motion, + * simulating how developers would use Motion animations in video rendering. + * + * In production, use the `motion-remotion` package which provides `useRemotionFrame`. + */ + +import { motionValue, Variants, renderFrame, setManualTiming } from "motion-dom" +import { createContext, useContext, ReactNode, useEffect, useRef } from "react" +import { act } from "react" +import { motion, AnimatePresence } from "../../" +import { render } from "../../jest.setup" + +/** + * Local implementation of frame syncing for tests. + * In production, use `useRemotionFrame` from `motion-remotion`. + */ +function useManualFrame({ frame, fps = 30 }: { frame: number; fps?: number }) { + const prevFrame = useRef(-1) + const hasInitialized = useRef(false) + + useEffect(() => { + setManualTiming(true) + return () => setManualTiming(false) + }, []) + + useEffect(() => { + if (frame !== prevFrame.current || !hasInitialized.current) { + const delta = + hasInitialized.current && prevFrame.current >= 0 + ? ((frame - prevFrame.current) / fps) * 1000 + : 1000 / fps + + renderFrame({ frame, fps, delta: Math.abs(delta) }) + prevFrame.current = frame + hasInitialized.current = true + } + }, [frame, fps]) +} + +/** + * Mock Remotion API + * Simulates the core hooks and components developers use in Remotion + */ +interface VideoConfig { + fps: number + width: number + height: number + durationInFrames: number + id: string +} + +interface RemotionContextValue { + frame: number + config: VideoConfig +} + +const RemotionContext = createContext(null) + +// Mock Remotion hooks +function useCurrentFrame(): number { + const ctx = useContext(RemotionContext) + if (!ctx) throw new Error("useCurrentFrame must be used within Remotion context") + return ctx.frame +} + +function useVideoConfig(): VideoConfig { + const ctx = useContext(RemotionContext) + if (!ctx) throw new Error("useVideoConfig must be used within Remotion context") + return ctx.config +} + +// Mock Remotion Sequence component - time-shifts children +function Sequence({ + from = 0, + children, +}: { + from?: number + durationInFrames?: number + children: ReactNode +}) { + const parentFrame = useCurrentFrame() + const config = useVideoConfig() + + // Sequence shifts the frame for children, similar to real Remotion + const relativeFrame = parentFrame - from + + return ( + + {relativeFrame >= 0 ? children : null} + + ) +} + +// Mock AbsoluteFill component +function AbsoluteFill({ children, style }: { children: ReactNode; style?: React.CSSProperties }) { + return ( +
+ {children} +
+ ) +} + +/** + * A Motion-enhanced component that integrates with Remotion + * This is how developers would typically use Motion in Remotion + */ +function MotionRemotionBridge({ children }: { children: ReactNode }) { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + + // Bridge Motion's animation system to Remotion's frame-based rendering + useManualFrame({ frame, fps }) + + return <>{children} +} + +describe("Remotion Integration - useManualFrame", () => { + afterEach(() => { + setManualTiming(false) + }) + + describe("Mocked Remotion Environment", () => { + test("renders correctly at typical Remotion FPS values (30fps)", async () => { + const x = motionValue(0) + const snapshots: number[] = [] + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, // 2 seconds + id: "test-30fps", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Test key frames: 0, 15 (0.5s), 30 (1s), 45 (1.5s), 60 (2s) + const keyFrames = [0, 15, 30, 45, 60] + + for (const frame of keyFrames) { + await act(async () => { + rerender() + }) + snapshots.push(Math.round(x.get())) + } + + // At 0 frames: animation starts + // At 15 frames (500ms): ~50% + // At 30 frames (1000ms): 100% + // At 45 & 60 frames: stays at 100% + expect(snapshots[1]).toBeCloseTo(50, -1) // ~50 at halfway + expect(snapshots[2]).toBe(100) // Complete at 1s + expect(snapshots[3]).toBe(100) // Stays complete + expect(snapshots[4]).toBe(100) // Stays complete + }) + + test("renders correctly at 60fps (smooth video)", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 60, + width: 1920, + height: 1080, + durationInFrames: 120, // 2 seconds + id: "test-60fps", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // At 30 frames at 60fps = 500ms = 50% + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(50) + + // At 60 frames at 60fps = 1000ms = 100% + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(100) + }) + + test("renders correctly at 24fps (film standard)", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 24, + width: 1920, + height: 1080, + durationInFrames: 48, // 2 seconds + id: "test-24fps", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // At 12 frames at 24fps = 500ms = 50% + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(50) + + // At 24 frames at 24fps = 1000ms = 100% + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(100) + }) + }) + + describe("Spring Animations (Motion advantage over Remotion)", () => { + test("spring with stiffness/damping produces natural overshoot", async () => { + const scale = motionValue(0) + const values: number[] = [] + + const config: VideoConfig = { + fps: 60, + width: 1920, + height: 1080, + durationInFrames: 120, + id: "spring-test", + } + + const Component = ({ frame }: { frame: number }) => ( + + + values.push(scale.get())} + /> + + + ) + + const { rerender } = render() + + // Render through the animation + for (let f = 1; f <= 90; f++) { + await act(async () => { + rerender() + }) + } + + // With low damping, spring should overshoot past 1 + const maxValue = Math.max(...values) + expect(maxValue).toBeGreaterThan(1) + + // Eventually settles to 1 + expect(Math.abs(scale.get() - 1)).toBeLessThan(0.01) + }) + + test("spring with high damping produces smooth approach", async () => { + const scale = motionValue(0) + const values: number[] = [] + + const config: VideoConfig = { + fps: 60, + width: 1920, + height: 1080, + durationInFrames: 120, + id: "spring-damped", + } + + const Component = ({ frame }: { frame: number }) => ( + + + values.push(scale.get())} + /> + + + ) + + const { rerender } = render() + + for (let f = 1; f <= 90; f++) { + await act(async () => { + rerender() + }) + } + + // With high damping, should not significantly overshoot + const maxValue = Math.max(...values) + expect(maxValue).toBeLessThan(1.05) // Allow tiny overshoot from numerical precision + }) + + test("bouncy entrance animation for video intro", async () => { + const y = motionValue(100) + const opacity = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "intro-bounce", + } + + // Simulates a title card bouncing in from below + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Render a few frames to start the animation + for (let f = 1; f <= 30; f++) { + await act(async () => { + rerender() + }) + } + + // After 30 frames (1 second), opacity should be fully animated + expect(opacity.get()).toBeCloseTo(1, 1) + + // Y should be approaching 0 with possible bounce + expect(Math.abs(y.get())).toBeLessThan(20) + }) + }) + + describe("AnimatePresence for Scene Transitions", () => { + test("exit animations complete before removal", async () => { + const opacity = motionValue(1) + let wasRemoved = false + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "exit-test", + } + + const Component = ({ + frame, + isVisible, + }: { + frame: number + isVisible: boolean + }) => ( + + + { + wasRemoved = true + }} + > + {isVisible && ( + + )} + + + + ) + + const { rerender } = render( + + ) + + // Render a few frames with component visible + await act(async () => { + rerender() + }) + + // Hide the component - should trigger exit animation + await act(async () => { + rerender() + }) + + // Advance through exit animation + for (let f = 11; f <= 30; f++) { + await act(async () => { + rerender() + }) + } + + // Exit animation should have completed + expect(wasRemoved).toBe(true) + }) + + test("cross-fade between scenes using mode='wait'", async () => { + const scene1Opacity = motionValue(1) + const scene2Opacity = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "crossfade-test", + } + + const Component = ({ + frame, + currentScene, + }: { + frame: number + currentScene: number + }) => ( + + + + {currentScene === 1 ? ( + + Scene 1 + + ) : ( + + Scene 2 + + )} + + + + ) + + const { rerender } = render( + + ) + + // Advance scene 1 entrance + for (let f = 1; f <= 15; f++) { + await act(async () => { + rerender() + }) + } + + // Scene 1 should be visible + expect(scene1Opacity.get()).toBeCloseTo(1, 1) + + // Switch to scene 2 + for (let f = 16; f <= 45; f++) { + await act(async () => { + rerender() + }) + } + + // Scene 2 should now be visible + expect(scene2Opacity.get()).toBeCloseTo(1, 1) + }) + }) + + describe("Staggered Children Animations", () => { + test("orchestrated reveals using variants and staggerChildren", async () => { + const opacities = [motionValue(0), motionValue(0), motionValue(0)] + const animationOrder: number[] = [] + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "stagger-test", + } + + const containerVariants: Variants = { + hidden: {}, + visible: { + transition: { + staggerChildren: 0.2, // 200ms between each child + }, + }, + } + + const itemVariants: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.1 } }, + } + + const Component = ({ frame }: { frame: number }) => ( + + + + {[0, 1, 2].map((i) => ( + { + if (!animationOrder.includes(i)) { + animationOrder.push(i) + } + }} + /> + ))} + + + + ) + + const { rerender } = render() + + // Render through animation (stagger of 200ms means items animate at 0ms, 200ms, 400ms) + for (let f = 1; f <= 30; f++) { + await act(async () => { + rerender() + }) + } + + // All items should be visible after 1 second + expect(opacities[0].get()).toBeCloseTo(1, 1) + expect(opacities[1].get()).toBeCloseTo(1, 1) + expect(opacities[2].get()).toBeCloseTo(1, 1) + }) + + test("title card with staggered text reveal", async () => { + const letterOpacities = Array(5) + .fill(0) + .map(() => motionValue(0)) + + const config: VideoConfig = { + fps: 60, + width: 1920, + height: 1080, + durationInFrames: 120, + id: "title-reveal", + } + + const containerVariants: Variants = { + hidden: {}, + visible: { + transition: { + staggerChildren: 0.05, // Fast stagger for text + }, + }, + } + + const letterVariants: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.1 }, + }, + } + + const Component = ({ frame }: { frame: number }) => ( + + + + {"HELLO".split("").map((letter, i) => ( + + {letter} + + ))} + + + + ) + + const { rerender } = render() + + // After 30 frames (0.5s at 60fps), all letters should be revealed + for (let f = 1; f <= 30; f++) { + await act(async () => { + rerender() + }) + } + + // First letter should definitely be visible + expect(letterOpacities[0].get()).toBeCloseTo(1, 1) + // Last letter should also be visible after stagger completes + expect(letterOpacities[4].get()).toBeCloseTo(1, 1) + }) + }) + + describe("Sequential Frame Rendering (Video Export)", () => { + /** + * Note: useManualFrame is designed for sequential forward rendering, + * which is the primary use case for Remotion video export. + * + * Backward scrubbing (for preview UX) is not supported because Motion + * animations are stateful - once they complete, they don't "un-complete". + * This is a fundamental difference from Remotion's stateless model where + * each frame is a pure function of the frame number. + * + * Users who need preview scrubbing should use Remotion's native + * interpolation functions instead. + */ + + test("sequential frame-by-frame rendering for video export", async () => { + const x = motionValue(0) + const snapshots: number[] = [] + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "sequential-export", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Simulate video export: sequential frames 0, 1, 2, ... 30 + for (let frame = 0; frame <= 30; frame++) { + await act(async () => { + rerender() + }) + if (frame % 10 === 0) { + snapshots.push(Math.round(x.get())) + } + } + + // At 30fps with 1s duration: + // frame 0 = 0ms = 0% + // frame 10 = 333ms = ~33% + // frame 20 = 667ms = ~67% + // frame 30 = 1000ms = 100% + expect(snapshots[0]).toBe(0) + expect(snapshots[1]).toBeCloseTo(33, -1) + expect(snapshots[2]).toBeCloseTo(67, -1) + expect(snapshots[3]).toBe(100) + }) + + test("animations stay at final value after completion", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "stay-complete", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Render through entire animation and beyond + for (let frame = 0; frame <= 60; frame++) { + await act(async () => { + rerender() + }) + } + + // Animation completes at frame 30, should stay at 100 through frame 60 + expect(Math.round(x.get())).toBe(100) + }) + }) + + describe("Sequence Component (time-shifted children)", () => { + test("animations in Sequence receive relative frame numbers", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "sequence-test", + } + + // Content that should animate relative to sequence start + const SequencedContent = () => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + + useManualFrame({ frame, fps }) + + return ( + + ) + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Before sequence starts (frame < 30), content should not be rendered + await act(async () => { + rerender() + }) + + // At frame 30, sequence starts (relative frame 0) + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(0) + + // At frame 45, relative frame is 15 (500ms at 30fps = 50%) + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(50) + + // At frame 60, relative frame is 30 (1000ms = 100%) + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(100) + }) + }) + + describe("Complete Remotion Composition Example", () => { + test("full video composition with intro, content, and outro", async () => { + const introOpacity = motionValue(0) + const contentScale = motionValue(0) + const outroY = motionValue(50) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 150, // 5 seconds + id: "full-composition", + } + + // A realistic video composition structure + const Composition = ({ frame }: { frame: number }) => ( + + + {/* Intro: frames 0-30 (first second) */} + + + + + {/* Main content: frames 30-120 */} + + + + + {/* Outro: frames 120-150 */} + + + + + + ) + + const IntroScene = ({ opacity }: { opacity: any }) => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + useManualFrame({ frame, fps }) + + return ( + + Intro + + ) + } + + const ContentScene = ({ scale }: { scale: any }) => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + useManualFrame({ frame, fps }) + + return ( + + Content + + ) + } + + const OutroScene = ({ y }: { y: any }) => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + useManualFrame({ frame, fps }) + + return ( + + Outro + + ) + } + + const { rerender } = render() + + // Test intro sequence + for (let f = 1; f <= 15; f++) { + await act(async () => { + rerender() + }) + } + expect(introOpacity.get()).toBeCloseTo(1, 1) // Intro faded in + + // Test content sequence + for (let f = 31; f <= 60; f++) { + await act(async () => { + rerender() + }) + } + expect(contentScale.get()).toBeCloseTo(1, 0) // Content scaled in + + // Test outro sequence + for (let f = 121; f <= 140; f++) { + await act(async () => { + rerender() + }) + } + expect(Math.abs(outroY.get())).toBeLessThan(10) // Outro slid in + }) + }) + + describe("Edge Cases", () => { + test("handles rapid frame changes (video export simulation)", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "rapid-frames", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Simulate rapid frame rendering (like video export) + for (let f = 0; f < 60; f++) { + await act(async () => { + rerender() + }) + } + + // Should end at correct final value + expect(Math.round(x.get())).toBe(100) + }) + + test("handles same frame being rendered multiple times", async () => { + const x = motionValue(0) + let renderCount = 0 + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "same-frame", + } + + const Component = ({ frame }: { frame: number }) => { + renderCount++ + return ( + + + + + + ) + } + + const { rerender } = render() + + const valueAfterFirst = x.get() + + // Re-render same frame multiple times + await act(async () => { + rerender() + }) + await act(async () => { + rerender() + }) + + // Value should remain stable + expect(x.get()).toBe(valueAfterFirst) + }) + + test("handles animation with zero duration", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 30, + id: "instant", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + await act(async () => { + rerender() + }) + + // Instant transition should complete immediately + expect(x.get()).toBe(100) + }) + }) +}) diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx deleted file mode 100644 index 6c86991ac2..0000000000 --- a/packages/framer-motion/src/utils/__tests__/use-manual-frame.test.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { act } from "react" -import { render } from "../../jest.setup" -import { motion, motionValue } from "../../" -import { useManualFrame } from "../use-manual-frame" -import { renderFrame, setManualTiming } from "motion-dom" - -/** - * Helper to create a component that simulates Remotion's useCurrentFrame behavior - */ -function createRemotionSimulator(fps: number = 30) { - let currentFrame = 0 - const listeners: Set<() => void> = new Set() - - return { - getCurrentFrame: () => currentFrame, - setFrame: (frame: number) => { - currentFrame = frame - listeners.forEach((fn) => fn()) - }, - advanceFrame: () => { - currentFrame++ - listeners.forEach((fn) => fn()) - }, - subscribe: (fn: () => void) => { - listeners.add(fn) - return () => listeners.delete(fn) - }, - fps, - } -} - -describe("useManualFrame", () => { - afterEach(() => { - setManualTiming(false) - }) - - test("syncs animations to frame number like Remotion", async () => { - const x = motionValue(0) - const values: number[] = [] - - // Simulate Remotion's frame-based rendering - const Component = ({ frame, fps }: { frame: number; fps: number }) => { - useManualFrame({ frame, fps }) - - return ( - values.push(Math.round(x.get()))} - /> - ) - } - - const { rerender } = render() - - // At frame 0 (0ms), animation should be at start - await act(async () => { - rerender() - }) - - // At frame 15 (500ms at 30fps), animation should be ~50% through - await act(async () => { - rerender() - }) - - // At frame 30 (1000ms at 30fps), animation should be complete - await act(async () => { - rerender() - }) - - // Check that we got intermediate values progressing towards 100 - expect(values.length).toBeGreaterThan(0) - expect(values[values.length - 1]).toBe(100) - }) - - test("handles frame-by-frame stepping", async () => { - const x = motionValue(0) - const timestamps: number[] = [] - - const Component = ({ frame }: { frame: number }) => { - useManualFrame({ frame, fps: 60 }) - - return ( - timestamps.push(x.get())} - /> - ) - } - - const { rerender } = render() - - // Step through frames one at a time (60fps = ~16.67ms per frame) - for (let frame = 1; frame <= 30; frame++) { - await act(async () => { - rerender() - }) - } - - // At frame 30 (500ms at 60fps), animation should be complete - expect(Math.round(x.get())).toBe(100) - }) - - test("works with different fps values", async () => { - const x = motionValue(0) - - const Component = ({ frame, fps }: { frame: number; fps: number }) => { - useManualFrame({ frame, fps }) - - return ( - - ) - } - - // Test at 24fps (film standard) - const { rerender } = render() - - // Frame 12 at 24fps = 500ms = 50% through a 1s animation - await act(async () => { - rerender() - }) - - expect(Math.round(x.get())).toBe(50) - - // Frame 24 at 24fps = 1000ms = 100% through - await act(async () => { - rerender() - }) - - expect(Math.round(x.get())).toBe(100) - }) - - test("enables manual timing mode on mount and disables on unmount", async () => { - const Component = ({ frame }: { frame: number }) => { - useManualFrame({ frame, fps: 30 }) - return
- } - - expect(setManualTiming).toBeDefined() - - const { unmount } = render() - - // After mount, manual timing should be enabled - // (we can't directly check MotionGlobalConfig here, but the hook should work) - - unmount() - - // After unmount, manual timing should be disabled - // This is verified by the afterEach cleanup not causing issues - }) -}) - -describe("renderFrame direct usage", () => { - afterEach(() => { - setManualTiming(false) - }) - - test("manually advances animation with renderFrame", async () => { - const x = motionValue(0) - - const Component = () => ( - - ) - - render() - - // Enable manual timing - setManualTiming(true) - - // Render at 0ms - await act(async () => { - renderFrame({ timestamp: 0 }) - }) - - // Render at 500ms (halfway through) - await act(async () => { - renderFrame({ timestamp: 500 }) - }) - - expect(Math.round(x.get())).toBe(50) - - // Render at 1000ms (complete) - await act(async () => { - renderFrame({ timestamp: 1000 }) - }) - - expect(Math.round(x.get())).toBe(100) - }) - - test("supports frame-based API for Remotion compatibility", async () => { - const x = motionValue(0) - - const Component = () => ( - - ) - - render() - - setManualTiming(true) - - // Use frame-based API (like Remotion's useCurrentFrame) - await act(async () => { - renderFrame({ frame: 0, fps: 30 }) - }) - - await act(async () => { - renderFrame({ frame: 15, fps: 30 }) // 500ms at 30fps - }) - - expect(Math.round(x.get())).toBe(50) - - await act(async () => { - renderFrame({ frame: 30, fps: 30 }) // 1000ms at 30fps - }) - - expect(Math.round(x.get())).toBe(100) - }) -}) - -describe("Manual frame control simulation (like step buttons)", () => { - afterEach(() => { - setManualTiming(false) - }) - - test("simulates step-by-step animation control", async () => { - const x = motionValue(0) - const snapshots: number[] = [] - - const Component = () => ( - - ) - - render() - - setManualTiming(true) - - // Simulate clicking "next frame" button multiple times - // Each click advances by one frame at 30fps (~33.33ms) - const frameTime = 1000 / 30 - - for (let i = 0; i <= 60; i++) { - // 60 frames = 2 seconds at 30fps - await act(async () => { - renderFrame({ - timestamp: i * frameTime, - delta: frameTime, - }) - }) - - // Take snapshots at key points - if (i === 0 || i === 15 || i === 30 || i === 45 || i === 60) { - snapshots.push(Math.round(x.get())) - } - } - - // At 0 frames: 0% - // At 15 frames (500ms): 25% - // At 30 frames (1000ms): 50% - // At 45 frames (1500ms): 75% - // At 60 frames (2000ms): 100% - expect(snapshots).toEqual([0, 50, 100, 150, 200]) - }) - - test("allows scrubbing backwards through animation", async () => { - const x = motionValue(0) - - const Component = () => ( - - ) - - render() - - setManualTiming(true) - - // Go to end of animation - await act(async () => { - renderFrame({ timestamp: 1000 }) - }) - - expect(Math.round(x.get())).toBe(100) - - // Scrub back to middle - await act(async () => { - renderFrame({ timestamp: 500 }) - }) - - expect(Math.round(x.get())).toBe(50) - - // Scrub back to start - await act(async () => { - renderFrame({ timestamp: 0 }) - }) - - expect(Math.round(x.get())).toBe(0) - }) -}) diff --git a/packages/framer-motion/src/utils/use-manual-frame.ts b/packages/framer-motion/src/utils/use-manual-frame.ts deleted file mode 100644 index 841831867d..0000000000 --- a/packages/framer-motion/src/utils/use-manual-frame.ts +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import { renderFrame, setManualTiming } from "motion-dom" -import { useContext, useEffect, useRef } from "react" -import { MotionConfigContext } from "../context/MotionConfigContext" - -interface UseManualFrameOptions { - /** - * The current frame number (0-indexed). - * Typically from Remotion's `useCurrentFrame()`. - */ - frame: number - - /** - * Frames per second of the video/animation. - * Typically from Remotion's `useVideoConfig().fps`. - * @default 30 - */ - fps?: number -} - -/** - * A hook for manually controlling Motion animations based on a frame number. - * - * This is designed for integration with video rendering frameworks like Remotion, - * or any environment where `requestAnimationFrame` is unavailable or - * animations need to be driven by an external timing source. - * - * @example - * // Basic usage with Remotion - * import { useCurrentFrame, useVideoConfig } from 'remotion' - * import { useManualFrame } from 'motion/react' - * - * function MyComponent() { - * const frame = useCurrentFrame() - * const { fps } = useVideoConfig() - * - * // This syncs Motion animations to Remotion's frame - * useManualFrame({ frame, fps }) - * - * return ( - * - * ) - * } - * - * @example - * // With a custom frame source - * function MyComponent({ frame }: { frame: number }) { - * useManualFrame({ frame, fps: 60 }) - * - * return ( - * - * ) - * } - */ -export function useManualFrame({ frame, fps = 30 }: UseManualFrameOptions) { - const { isStatic } = useContext(MotionConfigContext) - const prevFrame = useRef(-1) - const hasInitialized = useRef(false) - - // Enable manual timing on mount, disable on unmount - useEffect(() => { - if (isStatic) return - - setManualTiming(true) - - return () => { - setManualTiming(false) - } - }, [isStatic]) - - // Render the frame when it changes - useEffect(() => { - if (isStatic) return - - // Only render if frame has changed or on first render - if (frame !== prevFrame.current || !hasInitialized.current) { - const delta = - hasInitialized.current && prevFrame.current >= 0 - ? ((frame - prevFrame.current) / fps) * 1000 - : 1000 / fps - - renderFrame({ frame, fps, delta: Math.abs(delta) }) - - prevFrame.current = frame - hasInitialized.current = true - } - }, [frame, fps, isStatic]) -} diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index e89b768d63..831a3d33e7 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -2,6 +2,7 @@ import { clamp, invariant, millisecondsToSeconds, + MotionGlobalConfig, pipe, secondsToMilliseconds, } from "motion-utils" @@ -394,7 +395,12 @@ export class JSAnimation play() { if (this.isStopped) return - const { driver = frameloopDriver, startTime } = this.options + const { startTime } = this.options + // Priority: global driver > options driver > default frameloop driver + const driver = + MotionGlobalConfig.driver ?? + this.options.driver ?? + frameloopDriver if (!this.driver) { this.driver = driver((timestamp) => this.tick(timestamp)) diff --git a/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts new file mode 100644 index 0000000000..4b11c0142b --- /dev/null +++ b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts @@ -0,0 +1,89 @@ +import { MotionGlobalConfig } from "motion-utils" +import { supportsBrowserAnimation } from "../waapi" + +// Mock driver for testing +const mockDriver = () => ({ + start: () => {}, + stop: () => {}, + now: () => 0, +}) + +describe("supportsBrowserAnimation", () => { + afterEach(() => { + MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = false + }) + + test("returns false when a custom driver is set", () => { + MotionGlobalConfig.driver = mockDriver + + // Even with a valid accelerated value config, WAAPI should be disabled + const result = supportsBrowserAnimation({ + name: "opacity", + motionValue: { + owner: { + current: document.createElement("div"), + getProps: () => ({}), + }, + }, + } as any) + + expect(result).toBe(false) + }) + + test("returns false when useManualTiming is enabled", () => { + MotionGlobalConfig.useManualTiming = true + + // Even with a valid accelerated value config, WAAPI should be disabled + const result = supportsBrowserAnimation({ + name: "opacity", + motionValue: { + owner: { + current: document.createElement("div"), + getProps: () => ({}), + }, + }, + } as any) + + expect(result).toBe(false) + }) + + test("allows WAAPI when neither driver nor manual timing is set", () => { + MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = false + + // With a valid HTML element and accelerated property, should allow WAAPI + // (assuming browser supports it) + const result = supportsBrowserAnimation({ + name: "opacity", + motionValue: { + owner: { + current: document.createElement("div"), + getProps: () => ({}), + }, + }, + } as any) + + // In jsdom, Element.prototype.animate may not exist, so this could be false + // The key test is that it doesn't short-circuit on driver/timing check + // We verify by checking the function reaches the browser support check + expect(typeof result).toBe("boolean") + }) + + test("returns false for non-accelerated values", () => { + MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = false + + const result = supportsBrowserAnimation({ + name: "x", // Not an accelerated value + motionValue: { + owner: { + current: document.createElement("div"), + getProps: () => ({}), + }, + }, + } as any) + + expect(result).toBe(false) + }) +}) diff --git a/packages/motion-dom/src/animation/waapi/supports/waapi.ts b/packages/motion-dom/src/animation/waapi/supports/waapi.ts index 14762ad20f..51db8d19b3 100644 --- a/packages/motion-dom/src/animation/waapi/supports/waapi.ts +++ b/packages/motion-dom/src/animation/waapi/supports/waapi.ts @@ -1,4 +1,4 @@ -import { memo } from "motion-utils" +import { memo, MotionGlobalConfig } from "motion-utils" import { AnyResolvedKeyframe, ValueAnimationOptionsWithRenderContext, @@ -23,6 +23,12 @@ const supportsWaapi = /*@__PURE__*/ memo(() => export function supportsBrowserAnimation( options: ValueAnimationOptionsWithRenderContext ) { + // Disable WAAPI when manual timing or a custom driver is active + // In these cases, timing is controlled externally and WAAPI would desync + if (MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver) { + return false + } + const { motionValue, name, repeatDelay, repeatType, damping, type } = options diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 9754301752..5f74147612 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -66,7 +66,10 @@ export function createRenderBatcher( state.isProcessing = false - if (runNextFrame && allowKeepAlive) { + // Skip rAF scheduling when using manual timing or a custom driver + const skipScheduling = + MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver + if (runNextFrame && allowKeepAlive && !skipScheduling) { useDefaultElapsed = false scheduleNextBatch(processBatch) } @@ -76,7 +79,11 @@ export function createRenderBatcher( runNextFrame = true useDefaultElapsed = true - if (!state.isProcessing) { + // Skip rAF scheduling when using manual timing or a custom driver + // In these cases, processFrame() is called manually to advance animations + const skipScheduling = + MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver + if (!state.isProcessing && !skipScheduling) { scheduleNextBatch(processBatch) } } diff --git a/packages/motion-dom/src/frameloop/render-frame.ts b/packages/motion-dom/src/frameloop/render-frame.ts index abd1b3c6f6..d72233f596 100644 --- a/packages/motion-dom/src/frameloop/render-frame.ts +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -35,28 +35,22 @@ interface RenderFrameOptions { * Use this in environments where `requestAnimationFrame` is unavailable * or when you need manual control over frame timing, such as: * - WebXR immersive sessions - * - Remotion video rendering + * - Remotion video rendering (use with motion-remotion package) * - Server-side rendering of animations * - Custom animation loops * + * Note: For Remotion, use the `useRemotionFrame` hook from `motion-remotion` + * which handles driver setup and frame synchronization automatically. + * * @example * // Using timestamp directly * renderFrame({ timestamp: 1000 }) // Render at 1 second * * @example - * // Using frame number (Remotion-style) + * // Using frame number * renderFrame({ frame: 30, fps: 30 }) // Render at frame 30 (1 second at 30fps) * * @example - * // In a Remotion component - * const frame = useCurrentFrame() - * const { fps } = useVideoConfig() - * - * useEffect(() => { - * renderFrame({ frame, fps }) - * }, [frame, fps]) - * - * @example * // In a WebXR session * function onXRFrame(time, xrFrame) { * renderFrame({ timestamp: time }) @@ -82,14 +76,14 @@ export function renderFrame(options: RenderFrameOptions = {}): void { frameTimestamp = frameData.timestamp + frameDelta } - // Enable manual timing mode + // Temporarily enable manual timing mode during frame processing const previousManualTiming = MotionGlobalConfig.useManualTiming MotionGlobalConfig.useManualTiming = true // Set the synchronized time time.set(frameTimestamp) - // Process the frame + // Process the frame - this runs all registered callbacks processFrame(frameTimestamp, frameDelta) // Restore previous manual timing setting @@ -101,6 +95,9 @@ export function renderFrame(options: RenderFrameOptions = {}): void { * When enabled, animations will not auto-advance with requestAnimationFrame. * You must call `renderFrame()` to advance animations. * + * For Remotion integration, use the `motion-remotion` package instead, + * which provides a more complete solution with automatic WAAPI disabling. + * * @example * // Enable manual timing for the entire session * setManualTiming(true) diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index ef01fca0d8..cb3a6bce8e 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -72,6 +72,10 @@ export * from "./frameloop/microtask" export * from "./frameloop/sync-time" export * from "./frameloop/types" +// Animation drivers +export * from "./animation/drivers/types" +export { frameloopDriver } from "./animation/drivers/frame" + export * from "./gestures/drag/state/is-active" export * from "./gestures/drag/state/set-active" export * from "./gestures/drag/types" diff --git a/packages/motion-remotion/package.json b/packages/motion-remotion/package.json new file mode 100644 index 0000000000..a50744e445 --- /dev/null +++ b/packages/motion-remotion/package.json @@ -0,0 +1,49 @@ +{ + "name": "motion-remotion", + "version": "12.29.2", + "description": "Remotion integration for Motion animations", + "main": "dist/cjs/index.js", + "module": "dist/es/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/cjs/index.js", + "import": "./dist/es/index.mjs", + "default": "./dist/cjs/index.js" + }, + "./package.json": "./package.json" + }, + "types": "dist/index.d.ts", + "author": "Matt Perry", + "license": "MIT", + "repository": "https://github.com/motiondivision/motion", + "sideEffects": false, + "keywords": [ + "motion", + "remotion", + "animation", + "video", + "react" + ], + "scripts": { + "build": "yarn clean && tsc -p . && rollup -c", + "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --config --watch --no-watch.clearScreen\"", + "clean": "rm -rf types dist lib", + "prepack": "yarn build", + "postpublish": "git push --tags" + }, + "dependencies": { + "motion-dom": "^12.29.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "remotion": ">=4.0.0" + }, + "peerDependenciesMeta": { + "remotion": { + "optional": true + } + } +} diff --git a/packages/motion-remotion/rollup.config.mjs b/packages/motion-remotion/rollup.config.mjs new file mode 100644 index 0000000000..410ef430e7 --- /dev/null +++ b/packages/motion-remotion/rollup.config.mjs @@ -0,0 +1,93 @@ +import resolve from "@rollup/plugin-node-resolve" +import replace from "@rollup/plugin-replace" +import sourcemaps from "rollup-plugin-sourcemaps" +import dts from "rollup-plugin-dts" +import preserveDirectives from "rollup-plugin-preserve-directives" +import pkg from "./package.json" with { type: "json" } +import tsconfig from "./tsconfig.json" with { type: "json" } + +const config = { + input: "lib/index.js", +} + +export const replaceSettings = (env) => { + const replaceConfig = env + ? { + "process.env.NODE_ENV": JSON.stringify(env), + preventAssignment: false, + } + : { + preventAssignment: false, + } + + return replace(replaceConfig) +} + +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + ...Object.keys(pkg.optionalDependencies || {}), + "react/jsx-runtime", +] + +const cjs = Object.assign({}, config, { + input: "lib/index.js", + output: { + entryFileNames: `[name].js`, + dir: "dist/cjs", + format: "cjs", + exports: "named", + esModule: true, + sourcemap: true, + }, + plugins: [resolve(), replaceSettings(), sourcemaps()], + external, + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return + } + warn(warning) + } +}) + +export const es = Object.assign({}, config, { + input: ["lib/index.js"], + output: { + entryFileNames: "[name].mjs", + format: "es", + exports: "named", + preserveModules: true, + dir: "dist/es", + sourcemap: true, + }, + plugins: [resolve(), replaceSettings(), preserveDirectives(), sourcemaps()], + external, + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return + } + warn(warning) + } +}) + +const typePlugins = [dts({compilerOptions: {...tsconfig, baseUrl:"types"}})] + +function createTypes(input, file) { + return { + input, + output: { + format: "es", + file: file, + }, + plugins: typePlugins, + } +} + +const types = createTypes("types/index.d.ts", "dist/index.d.ts") + +// eslint-disable-next-line import/no-default-export +export default [ + cjs, + es, + types, +] diff --git a/packages/motion-remotion/src/driver.ts b/packages/motion-remotion/src/driver.ts new file mode 100644 index 0000000000..4eec8a71f3 --- /dev/null +++ b/packages/motion-remotion/src/driver.ts @@ -0,0 +1,36 @@ +import { frame, cancelFrame, frameData, FrameData } from "motion-dom" +import type { Driver } from "motion-dom" + +/** + * A driver for Remotion that uses the frame loop but doesn't auto-schedule rAF. + * Instead, it's driven by manual renderFrame() calls. + * + * When this driver is set globally via MotionGlobalConfig.driver: + * 1. Animations register their tick functions on the frame loop + * 2. The batcher skips rAF scheduling (because a custom driver is set) + * 3. Only renderFrame() calls will advance animations + * + * This ensures animations only progress when Remotion renders a frame. + */ +export const remotionDriver: Driver = (update) => { + const passTimestamp = ({ timestamp }: FrameData) => update(timestamp) + + return { + /** + * Register the animation's tick function on the frame loop. + * The batcher will skip rAF scheduling since a custom driver is set. + */ + start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), + + /** + * Unregister from the frame loop. + */ + stop: () => cancelFrame(passTimestamp), + + /** + * Returns the current frame timestamp from the global frameData. + * This is set by renderFrame() calls. + */ + now: () => frameData.timestamp, + } +} diff --git a/packages/motion-remotion/src/index.ts b/packages/motion-remotion/src/index.ts new file mode 100644 index 0000000000..a2f3f87bfd --- /dev/null +++ b/packages/motion-remotion/src/index.ts @@ -0,0 +1,2 @@ +export { remotionDriver } from "./driver" +export { useRemotionFrame } from "./use-remotion-frame" diff --git a/packages/motion-remotion/src/use-remotion-frame.ts b/packages/motion-remotion/src/use-remotion-frame.ts new file mode 100644 index 0000000000..19bc784683 --- /dev/null +++ b/packages/motion-remotion/src/use-remotion-frame.ts @@ -0,0 +1,91 @@ +"use client" + +import { renderFrame } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" +import { useEffect, useRef } from "react" +import { remotionDriver } from "./driver" + +interface UseRemotionFrameOptions { + /** + * The current frame number (0-indexed). + * Typically from Remotion's `useCurrentFrame()`. + */ + frame: number + + /** + * Frames per second of the video/animation. + * Typically from Remotion's `useVideoConfig().fps`. + * @default 30 + */ + fps?: number +} + +/** + * A hook for integrating Motion animations with Remotion's frame-based rendering. + * + * This hook: + * 1. Sets a custom driver that disables requestAnimationFrame + * 2. Disables WAAPI (which would use wall-clock time) + * 3. Calls renderFrame() when the frame changes + * + * Place this hook at the root of your Remotion composition to sync all + * Motion animations to Remotion's timeline. + * + * @example + * import { useCurrentFrame, useVideoConfig } from 'remotion' + * import { useRemotionFrame } from 'motion-remotion' + * + * function MyComposition() { + * const frame = useCurrentFrame() + * const { fps } = useVideoConfig() + * + * // Sync Motion animations to Remotion's frame + * useRemotionFrame({ frame, fps }) + * + * return ( + * + * ) + * } + * + * @example + * // Creating a reusable bridge component + * function MotionBridge({ children }: { children: React.ReactNode }) { + * const frame = useCurrentFrame() + * const { fps } = useVideoConfig() + * useRemotionFrame({ frame, fps }) + * return <>{children} + * } + */ +export function useRemotionFrame({ frame, fps = 30 }: UseRemotionFrameOptions) { + const prevFrame = useRef(-1) + const hasInitialized = useRef(false) + + // Set the global driver on mount, restore on unmount + useEffect(() => { + const previousDriver = MotionGlobalConfig.driver + MotionGlobalConfig.driver = remotionDriver + + return () => { + MotionGlobalConfig.driver = previousDriver + } + }, []) + + // Render the frame when it changes + useEffect(() => { + // Only render if frame has changed or on first render + if (frame !== prevFrame.current || !hasInitialized.current) { + const delta = + hasInitialized.current && prevFrame.current >= 0 + ? ((frame - prevFrame.current) / fps) * 1000 + : 1000 / fps + + renderFrame({ frame, fps, delta: Math.abs(delta) }) + + prevFrame.current = frame + hasInitialized.current = true + } + }, [frame, fps]) +} diff --git a/packages/motion-remotion/tsconfig.json b/packages/motion-remotion/tsconfig.json new file mode 100644 index 0000000000..8b7f9d5ce6 --- /dev/null +++ b/packages/motion-remotion/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "declarationDir": "types" + }, + "include": ["src/**/*"] +} diff --git a/packages/motion-utils/src/global-config.ts b/packages/motion-utils/src/global-config.ts index e240523092..4aa493b3a4 100644 --- a/packages/motion-utils/src/global-config.ts +++ b/packages/motion-utils/src/global-config.ts @@ -1,7 +1,24 @@ +/** + * Minimal driver interface for global config. + * The full Driver type is in motion-dom. + */ +interface GlobalDriver { + (update: (timestamp: number) => void): { + start: (keepAlive?: boolean) => void + stop: () => void + now: () => number + } +} + export const MotionGlobalConfig: { skipAnimations?: boolean instantAnimations?: boolean useManualTiming?: boolean WillChange?: any mix?: (a: T, b: T) => (p: number) => T + /** + * Custom animation driver. When set, WAAPI is disabled + * and all animations use this driver for timing. + */ + driver?: GlobalDriver } = {} diff --git a/yarn.lock b/yarn.lock index a86c263107..b72bd3dd45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11026,6 +11026,22 @@ __metadata: languageName: unknown linkType: soft +"motion-remotion@workspace:packages/motion-remotion": + version: 0.0.0-use.local + resolution: "motion-remotion@workspace:packages/motion-remotion" + dependencies: + motion-dom: ^12.29.2 + motion-utils: ^12.29.2 + tslib: ^2.4.0 + peerDependencies: + react: ^18.0.0 || ^19.0.0 + remotion: ">=4.0.0" + peerDependenciesMeta: + remotion: + optional: true + languageName: unknown + linkType: soft + "motion-utils@^12.29.2, motion-utils@workspace:packages/motion-utils": version: 0.0.0-use.local resolution: "motion-utils@workspace:packages/motion-utils" From ac910c7388205869f2938cb21ba2938c14662556 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 2 Feb 2026 21:39:16 +0100 Subject: [PATCH 05/11] Simplify to driver-only approach, remove useManualTiming - Remove useManualTiming from MotionGlobalConfig - Remove setManualTiming and isManualTiming functions - Update batcher, waapi, and sync-time to only check for driver - Update all tests to use driver instead of useManualTiming - Use useLayoutEffect in useRemotionFrame for proper timing Co-Authored-By: Claude Opus 4.5 --- dev/react/src/tests/manual-frame-control.tsx | 36 ++++++-- .../use-manual-frame-remotion.test.tsx | 29 +++++-- .../waapi/supports/__tests__/waapi.test.ts | 24 +---- .../src/animation/waapi/supports/waapi.ts | 6 +- .../frameloop/__tests__/render-frame.test.ts | 87 +++---------------- packages/motion-dom/src/frameloop/batcher.ts | 26 ++---- .../motion-dom/src/frameloop/render-frame.ts | 63 +++----------- .../motion-dom/src/frameloop/sync-time.ts | 2 +- .../motion-remotion/src/use-remotion-frame.ts | 5 +- packages/motion-utils/src/global-config.ts | 1 - 10 files changed, 95 insertions(+), 184 deletions(-) diff --git a/dev/react/src/tests/manual-frame-control.tsx b/dev/react/src/tests/manual-frame-control.tsx index 94de8b4d09..f5dd41eda7 100644 --- a/dev/react/src/tests/manual-frame-control.tsx +++ b/dev/react/src/tests/manual-frame-control.tsx @@ -1,6 +1,17 @@ -import { motion, useMotionValue, MotionGlobalConfig } from "framer-motion" -import { renderFrame, setManualTiming, frameData } from "motion-dom" -import { useState, useEffect, useCallback } from "react" +import { motion, useMotionValue } from "framer-motion" +import { renderFrame, frame, cancelFrame } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" +import { useCallback, useEffect, useState } from "react" + +// Manual driver that doesn't auto-schedule rAF +const manualDriver = (update: (t: number) => void) => { + const passTimestamp = ({ timestamp }: { timestamp: number }) => update(timestamp) + return { + start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), + stop: () => cancelFrame(passTimestamp), + now: () => 0, + } +} /** * Demo: Manual Frame Control @@ -22,15 +33,19 @@ export const App = () => { // Calculate current time in ms const currentTime = (currentFrame / fps) * 1000 - // Enable/disable manual timing mode + // Enable/disable manual timing mode via custom driver useEffect(() => { - setManualTiming(manualMode) if (manualMode) { + MotionGlobalConfig.driver = manualDriver // Reset to frame 0 when entering manual mode setCurrentFrame(0) renderFrame({ frame: 0, fps }) + } else { + MotionGlobalConfig.driver = undefined + } + return () => { + MotionGlobalConfig.driver = undefined } - return () => setManualTiming(false) }, [manualMode, fps]) // Render the current frame when it changes (in manual mode) @@ -165,6 +180,15 @@ export const App = () => {
+
+ +
+ {/* Info panel */}

How it works:

diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx index 154406be6c..495733755a 100644 --- a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -7,25 +7,35 @@ * In production, use the `motion-remotion` package which provides `useRemotionFrame`. */ -import { motionValue, Variants, renderFrame, setManualTiming } from "motion-dom" +import { motionValue, Variants, renderFrame, frame as frameLoop, cancelFrame, frameData } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" import { createContext, useContext, ReactNode, useEffect, useRef } from "react" import { act } from "react" import { motion, AnimatePresence } from "../../" import { render } from "../../jest.setup" +// Mock driver that doesn't auto-schedule rAF (similar to remotionDriver) +const mockRemotionDriver = (update: (t: number) => void) => { + const passTimestamp = ({ timestamp }: { timestamp: number }) => update(timestamp) + return { + start: (keepAlive = true) => frameLoop.update(passTimestamp, keepAlive), + stop: () => cancelFrame(passTimestamp), + now: () => frameData.timestamp, + } +} + /** * Local implementation of frame syncing for tests. * In production, use `useRemotionFrame` from `motion-remotion`. + * + * Note: The driver is set in beforeEach() to ensure it's set before + * any component renders. This is important because animations start + * during component initialization. */ function useManualFrame({ frame, fps = 30 }: { frame: number; fps?: number }) { const prevFrame = useRef(-1) const hasInitialized = useRef(false) - useEffect(() => { - setManualTiming(true) - return () => setManualTiming(false) - }, []) - useEffect(() => { if (frame !== prevFrame.current || !hasInitialized.current) { const delta = @@ -129,8 +139,13 @@ function MotionRemotionBridge({ children }: { children: ReactNode }) { } describe("Remotion Integration - useManualFrame", () => { + beforeEach(() => { + // Set driver before any component renders + MotionGlobalConfig.driver = mockRemotionDriver + }) + afterEach(() => { - setManualTiming(false) + MotionGlobalConfig.driver = undefined }) describe("Mocked Remotion Environment", () => { diff --git a/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts index 4b11c0142b..6bbaf9ef6b 100644 --- a/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts +++ b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts @@ -11,7 +11,6 @@ const mockDriver = () => ({ describe("supportsBrowserAnimation", () => { afterEach(() => { MotionGlobalConfig.driver = undefined - MotionGlobalConfig.useManualTiming = false }) test("returns false when a custom driver is set", () => { @@ -31,26 +30,8 @@ describe("supportsBrowserAnimation", () => { expect(result).toBe(false) }) - test("returns false when useManualTiming is enabled", () => { - MotionGlobalConfig.useManualTiming = true - - // Even with a valid accelerated value config, WAAPI should be disabled - const result = supportsBrowserAnimation({ - name: "opacity", - motionValue: { - owner: { - current: document.createElement("div"), - getProps: () => ({}), - }, - }, - } as any) - - expect(result).toBe(false) - }) - - test("allows WAAPI when neither driver nor manual timing is set", () => { + test("allows WAAPI when no custom driver is set", () => { MotionGlobalConfig.driver = undefined - MotionGlobalConfig.useManualTiming = false // With a valid HTML element and accelerated property, should allow WAAPI // (assuming browser supports it) @@ -65,14 +46,13 @@ describe("supportsBrowserAnimation", () => { } as any) // In jsdom, Element.prototype.animate may not exist, so this could be false - // The key test is that it doesn't short-circuit on driver/timing check + // The key test is that it doesn't short-circuit on driver check // We verify by checking the function reaches the browser support check expect(typeof result).toBe("boolean") }) test("returns false for non-accelerated values", () => { MotionGlobalConfig.driver = undefined - MotionGlobalConfig.useManualTiming = false const result = supportsBrowserAnimation({ name: "x", // Not an accelerated value diff --git a/packages/motion-dom/src/animation/waapi/supports/waapi.ts b/packages/motion-dom/src/animation/waapi/supports/waapi.ts index 51db8d19b3..1a7b234567 100644 --- a/packages/motion-dom/src/animation/waapi/supports/waapi.ts +++ b/packages/motion-dom/src/animation/waapi/supports/waapi.ts @@ -23,9 +23,9 @@ const supportsWaapi = /*@__PURE__*/ memo(() => export function supportsBrowserAnimation( options: ValueAnimationOptionsWithRenderContext ) { - // Disable WAAPI when manual timing or a custom driver is active - // In these cases, timing is controlled externally and WAAPI would desync - if (MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver) { + // Disable WAAPI when a custom driver is set (e.g., Remotion) + // Custom drivers control timing externally, so WAAPI would desync + if (MotionGlobalConfig.driver) { return false } diff --git a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts index e7473276b8..a24a15e889 100644 --- a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts +++ b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts @@ -1,15 +1,22 @@ import { MotionGlobalConfig } from "motion-utils" import { frame, cancelFrame } from ".." -import { - renderFrame, - setManualTiming, - isManualTiming, -} from "../render-frame" +import { renderFrame } from "../render-frame" + +// Mock driver that doesn't auto-schedule rAF +const mockDriver = () => ({ + start: () => {}, + stop: () => {}, + now: () => 0, +}) describe("renderFrame", () => { + beforeEach(() => { + // Set mock driver to prevent rAF scheduling + MotionGlobalConfig.driver = mockDriver + }) + afterEach(() => { - // Reset manual timing after each test - setManualTiming(false) + MotionGlobalConfig.driver = undefined }) it("processes scheduled callbacks with provided timestamp", () => { @@ -101,35 +108,6 @@ describe("renderFrame", () => { expect(values).toEqual([16]) }) - it("temporarily enables manual timing during frame processing", () => { - let timingDuringRender: boolean | undefined - - frame.update(() => { - timingDuringRender = MotionGlobalConfig.useManualTiming - }) - - // Ensure manual timing is off before - expect(MotionGlobalConfig.useManualTiming).toBeFalsy() - - renderFrame({ timestamp: 0 }) - - // Manual timing was enabled during render - expect(timingDuringRender).toBe(true) - - // Manual timing is restored after render - expect(MotionGlobalConfig.useManualTiming).toBeFalsy() - }) - - it("preserves previous manual timing setting after render", () => { - setManualTiming(true) - - frame.update(() => {}) - renderFrame({ timestamp: 0 }) - - // Should still be true - expect(MotionGlobalConfig.useManualTiming).toBe(true) - }) - it("supports incremental frame rendering", () => { const timestamps: number[] = [] @@ -149,40 +127,3 @@ describe("renderFrame", () => { expect(timestamps).toEqual([0, 1000 / 30, (2 * 1000) / 30]) }) }) - -describe("setManualTiming", () => { - afterEach(() => { - setManualTiming(false) - }) - - it("enables manual timing mode", () => { - expect(MotionGlobalConfig.useManualTiming).toBeFalsy() - - setManualTiming(true) - - expect(MotionGlobalConfig.useManualTiming).toBe(true) - }) - - it("disables manual timing mode", () => { - setManualTiming(true) - expect(MotionGlobalConfig.useManualTiming).toBe(true) - - setManualTiming(false) - expect(MotionGlobalConfig.useManualTiming).toBe(false) - }) -}) - -describe("isManualTiming", () => { - afterEach(() => { - setManualTiming(false) - }) - - it("returns false when manual timing is disabled", () => { - expect(isManualTiming()).toBe(false) - }) - - it("returns true when manual timing is enabled", () => { - setManualTiming(true) - expect(isManualTiming()).toBe(true) - }) -}) diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 5f74147612..693723173f 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -40,16 +40,12 @@ export function createRenderBatcher( } = steps const processBatch = () => { - const timestamp = MotionGlobalConfig.useManualTiming - ? state.timestamp - : performance.now() + const timestamp = performance.now() runNextFrame = false - if (!MotionGlobalConfig.useManualTiming) { - state.delta = useDefaultElapsed - ? 1000 / 60 - : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1) - } + state.delta = useDefaultElapsed + ? 1000 / 60 + : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1) state.timestamp = timestamp state.isProcessing = true @@ -66,10 +62,8 @@ export function createRenderBatcher( state.isProcessing = false - // Skip rAF scheduling when using manual timing or a custom driver - const skipScheduling = - MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver - if (runNextFrame && allowKeepAlive && !skipScheduling) { + // Skip rAF scheduling when using a custom driver (e.g., Remotion) + if (runNextFrame && allowKeepAlive && !MotionGlobalConfig.driver) { useDefaultElapsed = false scheduleNextBatch(processBatch) } @@ -79,11 +73,9 @@ export function createRenderBatcher( runNextFrame = true useDefaultElapsed = true - // Skip rAF scheduling when using manual timing or a custom driver - // In these cases, processFrame() is called manually to advance animations - const skipScheduling = - MotionGlobalConfig.useManualTiming || MotionGlobalConfig.driver - if (!state.isProcessing && !skipScheduling) { + // Skip rAF scheduling when using a custom driver (e.g., Remotion) + // In this case, processFrame() is called manually to advance animations + if (!state.isProcessing && !MotionGlobalConfig.driver) { scheduleNextBatch(processBatch) } } diff --git a/packages/motion-dom/src/frameloop/render-frame.ts b/packages/motion-dom/src/frameloop/render-frame.ts index d72233f596..cd39d637a8 100644 --- a/packages/motion-dom/src/frameloop/render-frame.ts +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -1,4 +1,3 @@ -import { MotionGlobalConfig } from "motion-utils" import { processFrame, frameData } from "./frame" import { time } from "./sync-time" @@ -32,30 +31,25 @@ interface RenderFrameOptions { /** * Manually render a single animation frame. * - * Use this in environments where `requestAnimationFrame` is unavailable - * or when you need manual control over frame timing, such as: - * - WebXR immersive sessions - * - Remotion video rendering (use with motion-remotion package) - * - Server-side rendering of animations - * - Custom animation loops - * - * Note: For Remotion, use the `useRemotionFrame` hook from `motion-remotion` - * which handles driver setup and frame synchronization automatically. + * Use this with a custom driver (e.g., `remotionDriver` from `motion-remotion`) + * to control animation timing externally. The custom driver prevents + * requestAnimationFrame from auto-advancing animations. * * @example - * // Using timestamp directly + * // Set up custom driver first + * import { remotionDriver } from 'motion-remotion' + * MotionGlobalConfig.driver = remotionDriver + * + * // Then render frames manually * renderFrame({ timestamp: 1000 }) // Render at 1 second * * @example - * // Using frame number + * // Using frame number (Remotion-style) * renderFrame({ frame: 30, fps: 30 }) // Render at frame 30 (1 second at 30fps) * * @example - * // In a WebXR session - * function onXRFrame(time, xrFrame) { - * renderFrame({ timestamp: time }) - * // ... rest of XR frame logic - * } + * // For Remotion, use the useRemotionFrame hook which handles this automatically + * import { useRemotionFrame } from 'motion-remotion' */ export function renderFrame(options: RenderFrameOptions = {}): void { const { timestamp, frame, fps = 30, delta } = options @@ -76,44 +70,9 @@ export function renderFrame(options: RenderFrameOptions = {}): void { frameTimestamp = frameData.timestamp + frameDelta } - // Temporarily enable manual timing mode during frame processing - const previousManualTiming = MotionGlobalConfig.useManualTiming - MotionGlobalConfig.useManualTiming = true - // Set the synchronized time time.set(frameTimestamp) // Process the frame - this runs all registered callbacks processFrame(frameTimestamp, frameDelta) - - // Restore previous manual timing setting - MotionGlobalConfig.useManualTiming = previousManualTiming -} - -/** - * Enable manual timing mode globally. - * When enabled, animations will not auto-advance with requestAnimationFrame. - * You must call `renderFrame()` to advance animations. - * - * For Remotion integration, use the `motion-remotion` package instead, - * which provides a more complete solution with automatic WAAPI disabling. - * - * @example - * // Enable manual timing for the entire session - * setManualTiming(true) - * - * // Advance frames manually - * renderFrame({ timestamp: 0 }) - * renderFrame({ timestamp: 16.67 }) - * renderFrame({ timestamp: 33.33 }) - */ -export function setManualTiming(enabled: boolean): void { - MotionGlobalConfig.useManualTiming = enabled -} - -/** - * Check if manual timing mode is currently enabled. - */ -export function isManualTiming(): boolean { - return MotionGlobalConfig.useManualTiming === true } diff --git a/packages/motion-dom/src/frameloop/sync-time.ts b/packages/motion-dom/src/frameloop/sync-time.ts index f90322a86f..a903bc812a 100644 --- a/packages/motion-dom/src/frameloop/sync-time.ts +++ b/packages/motion-dom/src/frameloop/sync-time.ts @@ -19,7 +19,7 @@ export const time = { now: (): number => { if (now === undefined) { time.set( - frameData.isProcessing || MotionGlobalConfig.useManualTiming + frameData.isProcessing || MotionGlobalConfig.driver ? frameData.timestamp : performance.now() ) diff --git a/packages/motion-remotion/src/use-remotion-frame.ts b/packages/motion-remotion/src/use-remotion-frame.ts index 19bc784683..65e25fd811 100644 --- a/packages/motion-remotion/src/use-remotion-frame.ts +++ b/packages/motion-remotion/src/use-remotion-frame.ts @@ -2,7 +2,7 @@ import { renderFrame } from "motion-dom" import { MotionGlobalConfig } from "motion-utils" -import { useEffect, useRef } from "react" +import { useEffect, useLayoutEffect, useRef } from "react" import { remotionDriver } from "./driver" interface UseRemotionFrameOptions { @@ -64,7 +64,8 @@ export function useRemotionFrame({ frame, fps = 30 }: UseRemotionFrameOptions) { const hasInitialized = useRef(false) // Set the global driver on mount, restore on unmount - useEffect(() => { + // Use layout effect to ensure driver is set before animations start + useLayoutEffect(() => { const previousDriver = MotionGlobalConfig.driver MotionGlobalConfig.driver = remotionDriver diff --git a/packages/motion-utils/src/global-config.ts b/packages/motion-utils/src/global-config.ts index 4aa493b3a4..5b7f6474f4 100644 --- a/packages/motion-utils/src/global-config.ts +++ b/packages/motion-utils/src/global-config.ts @@ -13,7 +13,6 @@ interface GlobalDriver { export const MotionGlobalConfig: { skipAnimations?: boolean instantAnimations?: boolean - useManualTiming?: boolean WillChange?: any mix?: (a: T, b: T) => (p: number) => T /** From a29cf2466e869ad535d51ce7f9b562bb20464e3b Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 09:31:03 +0100 Subject: [PATCH 06/11] Replace useRemotionFrame with MotionRemotion wrapper component Users now wrap their composition with instead of manually calling hooks. The component internally uses Remotion's useCurrentFrame/useVideoConfig and sets up the driver via useInsertionEffect. Also tightens comments across the branch. Co-Authored-By: Claude Opus 4.5 --- dev/react/src/tests/manual-frame-control.tsx | 20 ++-- .../use-manual-frame-remotion.test.tsx | 47 ++-------- packages/motion-remotion/package.json | 3 + .../motion-remotion/src/MotionRemotion.tsx | 53 +++++++++++ packages/motion-remotion/src/driver.ts | 25 +---- packages/motion-remotion/src/index.ts | 2 +- .../motion-remotion/src/use-remotion-frame.ts | 92 ------------------- yarn.lock | 11 +++ 8 files changed, 85 insertions(+), 168 deletions(-) create mode 100644 packages/motion-remotion/src/MotionRemotion.tsx delete mode 100644 packages/motion-remotion/src/use-remotion-frame.ts diff --git a/dev/react/src/tests/manual-frame-control.tsx b/dev/react/src/tests/manual-frame-control.tsx index f5dd41eda7..b525a7c3e5 100644 --- a/dev/react/src/tests/manual-frame-control.tsx +++ b/dev/react/src/tests/manual-frame-control.tsx @@ -213,20 +213,16 @@ export const App = () => {

Remotion Integration:

-                    {`import { useCurrentFrame, useVideoConfig } from 'remotion'
-import { useManualFrame } from 'motion/react'
-
-function MyComponent() {
-  const frame = useCurrentFrame()
-  const { fps } = useVideoConfig()
-
-  useManualFrame({ frame, fps })
+                    {`import { MotionRemotion } from 'motion-remotion'
 
+function MyComposition() {
   return (
-    
+    
+      
+    
   )
 }`}
                 
diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx index 495733755a..b14ffaadff 100644 --- a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -1,10 +1,7 @@ /** * Remotion Integration Tests * - * These tests demonstrate realistic Remotion usage patterns with Motion, - * simulating how developers would use Motion animations in video rendering. - * - * In production, use the `motion-remotion` package which provides `useRemotionFrame`. + * In production, wrap your composition with `` from `motion-remotion`. */ import { motionValue, Variants, renderFrame, frame as frameLoop, cancelFrame, frameData } from "motion-dom" @@ -14,7 +11,7 @@ import { act } from "react" import { motion, AnimatePresence } from "../../" import { render } from "../../jest.setup" -// Mock driver that doesn't auto-schedule rAF (similar to remotionDriver) +// Mock driver (same as remotionDriver but inline for tests) const mockRemotionDriver = (update: (t: number) => void) => { const passTimestamp = ({ timestamp }: { timestamp: number }) => update(timestamp) return { @@ -24,14 +21,7 @@ const mockRemotionDriver = (update: (t: number) => void) => { } } -/** - * Local implementation of frame syncing for tests. - * In production, use `useRemotionFrame` from `motion-remotion`. - * - * Note: The driver is set in beforeEach() to ensure it's set before - * any component renders. This is important because animations start - * during component initialization. - */ +// Local implementation - in production use `` from `motion-remotion` function useManualFrame({ frame, fps = 30 }: { frame: number; fps?: number }) { const prevFrame = useRef(-1) const hasInitialized = useRef(false) @@ -50,10 +40,7 @@ function useManualFrame({ frame, fps = 30 }: { frame: number; fps?: number }) { }, [frame, fps]) } -/** - * Mock Remotion API - * Simulates the core hooks and components developers use in Remotion - */ +// Mock Remotion API interface VideoConfig { fps: number width: number @@ -69,7 +56,6 @@ interface RemotionContextValue { const RemotionContext = createContext(null) -// Mock Remotion hooks function useCurrentFrame(): number { const ctx = useContext(RemotionContext) if (!ctx) throw new Error("useCurrentFrame must be used within Remotion context") @@ -82,7 +68,6 @@ function useVideoConfig(): VideoConfig { return ctx.config } -// Mock Remotion Sequence component - time-shifts children function Sequence({ from = 0, children, @@ -93,8 +78,6 @@ function Sequence({ }) { const parentFrame = useCurrentFrame() const config = useVideoConfig() - - // Sequence shifts the frame for children, similar to real Remotion const relativeFrame = parentFrame - from return ( @@ -106,7 +89,6 @@ function Sequence({ ) } -// Mock AbsoluteFill component function AbsoluteFill({ children, style }: { children: ReactNode; style?: React.CSSProperties }) { return (
{children} @@ -140,7 +116,6 @@ function MotionRemotionBridge({ children }: { children: ReactNode }) { describe("Remotion Integration - useManualFrame", () => { beforeEach(() => { - // Set driver before any component renders MotionGlobalConfig.driver = mockRemotionDriver }) @@ -695,18 +670,8 @@ describe("Remotion Integration - useManualFrame", () => { }) describe("Sequential Frame Rendering (Video Export)", () => { - /** - * Note: useManualFrame is designed for sequential forward rendering, - * which is the primary use case for Remotion video export. - * - * Backward scrubbing (for preview UX) is not supported because Motion - * animations are stateful - once they complete, they don't "un-complete". - * This is a fundamental difference from Remotion's stateless model where - * each frame is a pure function of the frame number. - * - * Users who need preview scrubbing should use Remotion's native - * interpolation functions instead. - */ + // Note: Designed for sequential forward rendering (video export). + // Backward scrubbing not supported - Motion animations are stateful. test("sequential frame-by-frame rendering for video export", async () => { const x = motionValue(0) diff --git a/packages/motion-remotion/package.json b/packages/motion-remotion/package.json index a50744e445..1711f926a1 100644 --- a/packages/motion-remotion/package.json +++ b/packages/motion-remotion/package.json @@ -45,5 +45,8 @@ "remotion": { "optional": true } + }, + "devDependencies": { + "remotion": "^4.0.417" } } diff --git a/packages/motion-remotion/src/MotionRemotion.tsx b/packages/motion-remotion/src/MotionRemotion.tsx new file mode 100644 index 0000000000..1c2e4461b0 --- /dev/null +++ b/packages/motion-remotion/src/MotionRemotion.tsx @@ -0,0 +1,53 @@ +"use client" + +import { renderFrame } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" +import { ReactNode, useEffect, useInsertionEffect, useRef } from "react" +import { useCurrentFrame, useVideoConfig } from "remotion" +import { remotionDriver } from "./driver" + +/** + * Wrap your Remotion composition with this component to sync + * Motion animations to Remotion's frame-based timeline. + * + * @example + * function MyComposition() { + * return ( + * + * + * + * ) + * } + */ +export function MotionRemotion({ children }: { children: ReactNode }) { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + const prevFrame = useRef(-1) + const hasInitialized = useRef(false) + + // Set the driver before any animations mount + useInsertionEffect(() => { + const previousDriver = MotionGlobalConfig.driver + MotionGlobalConfig.driver = remotionDriver + + return () => { + MotionGlobalConfig.driver = previousDriver + } + }, []) + + // Render frame when it changes + useEffect(() => { + if (frame !== prevFrame.current || !hasInitialized.current) { + const delta = + hasInitialized.current && prevFrame.current >= 0 + ? ((frame - prevFrame.current) / fps) * 1000 + : 1000 / fps + + renderFrame({ frame, fps, delta: Math.abs(delta) }) + prevFrame.current = frame + hasInitialized.current = true + } + }, [frame, fps]) + + return <>{children} +} diff --git a/packages/motion-remotion/src/driver.ts b/packages/motion-remotion/src/driver.ts index 4eec8a71f3..f79f2f8304 100644 --- a/packages/motion-remotion/src/driver.ts +++ b/packages/motion-remotion/src/driver.ts @@ -2,35 +2,16 @@ import { frame, cancelFrame, frameData, FrameData } from "motion-dom" import type { Driver } from "motion-dom" /** - * A driver for Remotion that uses the frame loop but doesn't auto-schedule rAF. - * Instead, it's driven by manual renderFrame() calls. - * - * When this driver is set globally via MotionGlobalConfig.driver: - * 1. Animations register their tick functions on the frame loop - * 2. The batcher skips rAF scheduling (because a custom driver is set) - * 3. Only renderFrame() calls will advance animations - * - * This ensures animations only progress when Remotion renders a frame. + * Animation driver for Remotion that bypasses requestAnimationFrame. + * When set via MotionGlobalConfig.driver, animations only advance + * when renderFrame() is called. */ export const remotionDriver: Driver = (update) => { const passTimestamp = ({ timestamp }: FrameData) => update(timestamp) return { - /** - * Register the animation's tick function on the frame loop. - * The batcher will skip rAF scheduling since a custom driver is set. - */ start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), - - /** - * Unregister from the frame loop. - */ stop: () => cancelFrame(passTimestamp), - - /** - * Returns the current frame timestamp from the global frameData. - * This is set by renderFrame() calls. - */ now: () => frameData.timestamp, } } diff --git a/packages/motion-remotion/src/index.ts b/packages/motion-remotion/src/index.ts index a2f3f87bfd..335bf9e844 100644 --- a/packages/motion-remotion/src/index.ts +++ b/packages/motion-remotion/src/index.ts @@ -1,2 +1,2 @@ +export { MotionRemotion } from "./MotionRemotion" export { remotionDriver } from "./driver" -export { useRemotionFrame } from "./use-remotion-frame" diff --git a/packages/motion-remotion/src/use-remotion-frame.ts b/packages/motion-remotion/src/use-remotion-frame.ts deleted file mode 100644 index 65e25fd811..0000000000 --- a/packages/motion-remotion/src/use-remotion-frame.ts +++ /dev/null @@ -1,92 +0,0 @@ -"use client" - -import { renderFrame } from "motion-dom" -import { MotionGlobalConfig } from "motion-utils" -import { useEffect, useLayoutEffect, useRef } from "react" -import { remotionDriver } from "./driver" - -interface UseRemotionFrameOptions { - /** - * The current frame number (0-indexed). - * Typically from Remotion's `useCurrentFrame()`. - */ - frame: number - - /** - * Frames per second of the video/animation. - * Typically from Remotion's `useVideoConfig().fps`. - * @default 30 - */ - fps?: number -} - -/** - * A hook for integrating Motion animations with Remotion's frame-based rendering. - * - * This hook: - * 1. Sets a custom driver that disables requestAnimationFrame - * 2. Disables WAAPI (which would use wall-clock time) - * 3. Calls renderFrame() when the frame changes - * - * Place this hook at the root of your Remotion composition to sync all - * Motion animations to Remotion's timeline. - * - * @example - * import { useCurrentFrame, useVideoConfig } from 'remotion' - * import { useRemotionFrame } from 'motion-remotion' - * - * function MyComposition() { - * const frame = useCurrentFrame() - * const { fps } = useVideoConfig() - * - * // Sync Motion animations to Remotion's frame - * useRemotionFrame({ frame, fps }) - * - * return ( - * - * ) - * } - * - * @example - * // Creating a reusable bridge component - * function MotionBridge({ children }: { children: React.ReactNode }) { - * const frame = useCurrentFrame() - * const { fps } = useVideoConfig() - * useRemotionFrame({ frame, fps }) - * return <>{children} - * } - */ -export function useRemotionFrame({ frame, fps = 30 }: UseRemotionFrameOptions) { - const prevFrame = useRef(-1) - const hasInitialized = useRef(false) - - // Set the global driver on mount, restore on unmount - // Use layout effect to ensure driver is set before animations start - useLayoutEffect(() => { - const previousDriver = MotionGlobalConfig.driver - MotionGlobalConfig.driver = remotionDriver - - return () => { - MotionGlobalConfig.driver = previousDriver - } - }, []) - - // Render the frame when it changes - useEffect(() => { - // Only render if frame has changed or on first render - if (frame !== prevFrame.current || !hasInitialized.current) { - const delta = - hasInitialized.current && prevFrame.current >= 0 - ? ((frame - prevFrame.current) / fps) * 1000 - : 1000 / fps - - renderFrame({ frame, fps, delta: Math.abs(delta) }) - - prevFrame.current = frame - hasInitialized.current = true - } - }, [frame, fps]) -} diff --git a/yarn.lock b/yarn.lock index b72bd3dd45..17f6d5df5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11032,6 +11032,7 @@ __metadata: dependencies: motion-dom: ^12.29.2 motion-utils: ^12.29.2 + remotion: ^4.0.417 tslib: ^2.4.0 peerDependencies: react: ^18.0.0 || ^19.0.0 @@ -13076,6 +13077,16 @@ __metadata: languageName: node linkType: hard +"remotion@npm:^4.0.417": + version: 4.0.417 + resolution: "remotion@npm:4.0.417" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 88c08e7fa1d4c243bdb9327734a6d94aa81afa3f94fe9c7767eef7b2b2fc2125eae464b0e5ea5759aa7a95cf17f7cc102a4c0c300f22571b14f2929a77382a9a + languageName: node + linkType: hard + "repeat-element@npm:^1.1.2": version: 1.1.4 resolution: "repeat-element@npm:1.1.4" From 0332ee47dedaa034aaa79c7335b44020ac5f3558 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 11:27:56 +0100 Subject: [PATCH 07/11] Add frame-based render caching for scrubbable Remotion animations Cache the visual output (styles, CSS variables, SVG attributes) of every motion element per frame during first render. On revisit (scrubbing), apply cached styles instead of recomputing, enabling backward scrubbing in Remotion previews regardless of animation statefulness. Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/index.ts | 2 + packages/motion-dom/src/render/frame-cache.ts | 69 +++++++++++++++++++ .../src/render/html/utils/render.ts | 38 ++++++++++ .../motion-dom/src/render/svg/utils/render.ts | 31 +++++++-- 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/motion-dom/src/render/frame-cache.ts diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index cb3a6bce8e..005e06a30b 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -90,6 +90,8 @@ export * from "./gestures/utils/is-primary-pointer" export * from "./node/types" +export { clearFrameCache, setCurrentFrame } from "./render/frame-cache" + export * from "./render/dom/parse-transform" export * from "./render/dom/style-computed" export * from "./render/dom/style-set" diff --git a/packages/motion-dom/src/render/frame-cache.ts b/packages/motion-dom/src/render/frame-cache.ts new file mode 100644 index 0000000000..8a7a3a8a00 --- /dev/null +++ b/packages/motion-dom/src/render/frame-cache.ts @@ -0,0 +1,69 @@ +type ElementStyles = Record + +/** + * Map> + * + * WeakMap ensures cached entries are garbage-collected + * if an element is unmounted. + */ +const styleCache = new Map>() + +/** + * Separate cache for SVG attributes, keyed identically. + */ +const attrCache = new Map>() + +let currentFrame: number | undefined = undefined + +export function setCurrentFrame(frame: number | undefined) { + currentFrame = frame +} + +export function getCurrentFrame(): number | undefined { + return currentFrame +} + +export function getCachedStyles( + element: Element +): ElementStyles | undefined { + if (currentFrame === undefined) return undefined + return styleCache.get(currentFrame)?.get(element) +} + +export function setCachedStyles( + element: Element, + styles: ElementStyles +) { + if (currentFrame === undefined) return + if (!styleCache.has(currentFrame)) { + styleCache.set(currentFrame, new WeakMap()) + } + styleCache.get(currentFrame)!.set(element, styles) +} + +export function getCachedAttrs( + element: Element +): ElementStyles | undefined { + if (currentFrame === undefined) return undefined + return attrCache.get(currentFrame)?.get(element) +} + +export function setCachedAttrs( + element: Element, + attrs: ElementStyles +) { + if (currentFrame === undefined) return + if (!attrCache.has(currentFrame)) { + attrCache.set(currentFrame, new WeakMap()) + } + attrCache.get(currentFrame)!.set(element, attrs) +} + +export function clearFrameCache() { + styleCache.clear() + attrCache.clear() +} + +export function isFrameCacheActive(): boolean { + return currentFrame !== undefined +} diff --git a/packages/motion-dom/src/render/html/utils/render.ts b/packages/motion-dom/src/render/html/utils/render.ts index dd20bda831..cc9eb2f3ef 100644 --- a/packages/motion-dom/src/render/html/utils/render.ts +++ b/packages/motion-dom/src/render/html/utils/render.ts @@ -1,4 +1,9 @@ import type { MotionStyle } from "../../VisualElement" +import { + getCachedStyles, + isFrameCacheActive, + setCachedStyles, +} from "../../frame-cache" import { HTMLRenderState } from "../types" export function renderHTML( @@ -9,6 +14,21 @@ export function renderHTML( ) { const elementStyle = element.style + // Cache hit: apply cached styles, skip all computation + if (isFrameCacheActive()) { + const cached = getCachedStyles(element) + if (cached) { + for (const key in cached) { + if (key.startsWith("--")) { + elementStyle.setProperty(key, cached[key]) + } else { + elementStyle[key as unknown as number] = cached[key] + } + } + return + } + } + let key: string for (key in style) { // CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type. @@ -23,4 +43,22 @@ export function renderHTML( // They can only be assigned using `setProperty`. elementStyle.setProperty(key, vars[key] as string) } + + // Cache the result after render + if (isFrameCacheActive()) { + const record: Record = {} + for (key in style) { + record[key] = elementStyle[key as unknown as number] + } + for (key in vars) { + record[key] = elementStyle.getPropertyValue(key) + } + // Capture projection-applied properties + if (projection) { + record["transform"] = elementStyle.transform + record["transformOrigin"] = elementStyle.transformOrigin + if (elementStyle.opacity) record["opacity"] = elementStyle.opacity + } + setCachedStyles(element, record) + } } diff --git a/packages/motion-dom/src/render/svg/utils/render.ts b/packages/motion-dom/src/render/svg/utils/render.ts index 5741dc03aa..623722dab6 100644 --- a/packages/motion-dom/src/render/svg/utils/render.ts +++ b/packages/motion-dom/src/render/svg/utils/render.ts @@ -1,5 +1,10 @@ import type { MotionStyle } from "../../VisualElement" import { camelToDash } from "../../dom/utils/camel-to-dash" +import { + getCachedAttrs, + isFrameCacheActive, + setCachedAttrs, +} from "../../frame-cache" import { renderHTML } from "../../html/utils/render" import { SVGRenderState } from "../types" import { camelCaseAttributes } from "./camel-case-attrs" @@ -10,12 +15,30 @@ export function renderSVG( _styleProp?: MotionStyle, projection?: any ) { + // renderHTML handles its own style caching renderHTML(element as any, renderState, undefined, projection) + if (isFrameCacheActive()) { + const cachedAttrs = getCachedAttrs(element) + if (cachedAttrs) { + for (const key in cachedAttrs) { + element.setAttribute(key, cachedAttrs[key]) + } + return + } + } + + const attrRecord: Record = {} for (const key in renderState.attrs) { - element.setAttribute( - !camelCaseAttributes.has(key) ? camelToDash(key) : key, - renderState.attrs[key] as string - ) + const attrName = !camelCaseAttributes.has(key) + ? camelToDash(key) + : key + const value = renderState.attrs[key] as string + element.setAttribute(attrName, value) + attrRecord[attrName] = value + } + + if (isFrameCacheActive()) { + setCachedAttrs(element, attrRecord) } } From 393509cf167c94df1358d0f491aeb62f0d2805ae Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 6 Feb 2026 08:17:51 +0100 Subject: [PATCH 08/11] Add layout animation scrubbing tests and fix microtask batcher with custom driver The microtask batcher's wake() was incorrectly skipping queueMicrotask scheduling when a custom driver was set, preventing layout animation setup (projection system's didUpdate via microtask.read) from running in Remotion. Fix: allow non-keepAlive batchers to schedule regardless of driver. Tests mock getBoundingClientRect per-element (JSDOM returns zeros which the projection system discards) and flush microtasks after layout changes. Co-Authored-By: Claude Opus 4.6 --- .../use-manual-frame-remotion.test.tsx | 859 ++++++++++++++++-- packages/motion-dom/src/frameloop/batcher.ts | 12 +- 2 files changed, 778 insertions(+), 93 deletions(-) diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx index b14ffaadff..d7a9d68b8d 100644 --- a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -4,9 +4,9 @@ * In production, wrap your composition with `` from `motion-remotion`. */ -import { motionValue, Variants, renderFrame, frame as frameLoop, cancelFrame, frameData } from "motion-dom" +import { motionValue, Variants, renderFrame, setCurrentFrame, clearFrameCache, frame as frameLoop, cancelFrame, frameData } from "motion-dom" import { MotionGlobalConfig } from "motion-utils" -import { createContext, useContext, ReactNode, useEffect, useRef } from "react" +import { createContext, useContext, ReactNode, useEffect, useLayoutEffect, useRef } from "react" import { act } from "react" import { motion, AnimatePresence } from "../../" import { render } from "../../jest.setup" @@ -21,25 +21,6 @@ const mockRemotionDriver = (update: (t: number) => void) => { } } -// Local implementation - in production use `` from `motion-remotion` -function useManualFrame({ frame, fps = 30 }: { frame: number; fps?: number }) { - const prevFrame = useRef(-1) - const hasInitialized = useRef(false) - - useEffect(() => { - if (frame !== prevFrame.current || !hasInitialized.current) { - const delta = - hasInitialized.current && prevFrame.current >= 0 - ? ((frame - prevFrame.current) / fps) * 1000 - : 1000 / fps - - renderFrame({ frame, fps, delta: Math.abs(delta) }) - prevFrame.current = frame - hasInitialized.current = true - } - }, [frame, fps]) -} - // Mock Remotion API interface VideoConfig { fps: number @@ -106,15 +87,42 @@ function AbsoluteFill({ children, style }: { children: ReactNode; style?: React. ) } +/** + * Test bridge that mirrors MotionRemotion from the plus repo. + * Uses renderFrame + setCurrentFrame directly (no useManualFrame hook). + */ function MotionRemotionBridge({ children }: { children: ReactNode }) { - const frame = useCurrentFrame() + const currentFrame = useCurrentFrame() const { fps } = useVideoConfig() - useManualFrame({ frame, fps }) + const prevFrame = useRef(-1) + + useLayoutEffect(() => { + setCurrentFrame(currentFrame) + + if (prevFrame.current < 0) { + renderFrame({ frame: currentFrame, fps }) + } else if (currentFrame > prevFrame.current) { + // Forward: render intermediate frames to build cache + for (let i = prevFrame.current + 1; i <= currentFrame; i++) { + setCurrentFrame(i) + renderFrame({ frame: i, fps }) + } + setCurrentFrame(currentFrame) + } else if (currentFrame < prevFrame.current) { + // Backward: JSAnimation fix handles time reversal + renderFrame({ frame: currentFrame, fps }) + } + + prevFrame.current = currentFrame + return () => setCurrentFrame(undefined) + }, [currentFrame, fps]) + + useEffect(() => () => clearFrameCache(), []) return <>{children} } -describe("Remotion Integration - useManualFrame", () => { +describe("Remotion Integration", () => { beforeEach(() => { MotionGlobalConfig.driver = mockRemotionDriver }) @@ -236,17 +244,17 @@ describe("Remotion Integration - useManualFrame", () => { const { rerender } = render() - // At 12 frames at 24fps = 500ms = 50% + // At 12 frames at 24fps = 500ms = ~50% await act(async () => { rerender() }) - expect(Math.round(x.get())).toBe(50) + expect(Math.round(x.get())).toBeCloseTo(50, -1) // At 24 frames at 24fps = 1000ms = 100% await act(async () => { rerender() }) - expect(Math.round(x.get())).toBe(100) + expect(Math.round(x.get())).toBeCloseTo(100, -1) }) }) @@ -391,6 +399,156 @@ describe("Remotion Integration - useManualFrame", () => { }) }) + describe("Backward Scrubbing", () => { + test("linear animation scrubs backward to correct intermediate values", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "backward-linear", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Forward to frame 30 (1s = 100%) + for (let f = 1; f <= 30; f++) { + await act(async () => { + rerender() + }) + } + expect(Math.round(x.get())).toBe(100) + + // Scrub backward to frame 15 (500ms = 50%) + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBeCloseTo(50, -1) + + // Scrub backward to frame 0 (0ms = 0%) + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBe(0) + }) + + test("spring animation scrubs backward correctly (analytical solution)", async () => { + const scale = motionValue(0) + + const config: VideoConfig = { + fps: 60, + width: 1920, + height: 1080, + durationInFrames: 120, + id: "backward-spring", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Record values on forward pass + const forwardValues: number[] = [] + for (let f = 1; f <= 60; f++) { + await act(async () => { + rerender() + }) + forwardValues.push(scale.get()) + } + + // Scrub backward to frame 15 — should match the value from the forward pass + await act(async () => { + rerender() + }) + expect(scale.get()).toBeCloseTo(forwardValues[14], 1) + + // Scrub backward to frame 5 + await act(async () => { + rerender() + }) + expect(scale.get()).toBeCloseTo(forwardValues[4], 1) + }) + + test("un-finish behavior: scrub past end, back to middle, forward past end again", async () => { + const x = motionValue(0) + + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 90, + id: "unfinish", + } + + const Component = ({ frame }: { frame: number }) => ( + + + + + + ) + + const { rerender } = render() + + // Forward past animation end (frame 30 = 1s) + for (let f = 1; f <= 45; f++) { + await act(async () => { + rerender() + }) + } + expect(Math.round(x.get())).toBe(100) + + // Scrub back to middle of animation + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBeCloseTo(50, -1) + + // Scrub forward past end again + for (let f = 16; f <= 45; f++) { + await act(async () => { + rerender() + }) + } + expect(Math.round(x.get())).toBe(100) + }) + }) + describe("AnimatePresence for Scene Transitions", () => { test("exit animations complete before removal", async () => { const opacity = motionValue(1) @@ -670,9 +828,6 @@ describe("Remotion Integration - useManualFrame", () => { }) describe("Sequential Frame Rendering (Video Export)", () => { - // Note: Designed for sequential forward rendering (video export). - // Backward scrubbing not supported - Motion animations are stateful. - test("sequential frame-by-frame rendering for video export", async () => { const x = motionValue(0) const snapshots: number[] = [] @@ -775,8 +930,24 @@ describe("Remotion Integration - useManualFrame", () => { const SequencedContent = () => { const frame = useCurrentFrame() const { fps } = useVideoConfig() - - useManualFrame({ frame, fps }) + const prevFrame = useRef(-1) + + useLayoutEffect(() => { + setCurrentFrame(frame) + if (prevFrame.current < 0) { + renderFrame({ frame, fps }) + } else if (frame > prevFrame.current) { + for (let i = prevFrame.current + 1; i <= frame; i++) { + setCurrentFrame(i) + renderFrame({ frame: i, fps }) + } + setCurrentFrame(frame) + } else if (frame < prevFrame.current) { + renderFrame({ frame, fps }) + } + prevFrame.current = frame + return () => setCurrentFrame(undefined) + }, [frame, fps]) return ( { id: "full-composition", } + const SceneBridge = ({ children }: { children: ReactNode }) => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + const prevFrame = useRef(-1) + + useLayoutEffect(() => { + setCurrentFrame(frame) + if (prevFrame.current < 0) { + renderFrame({ frame, fps }) + } else if (frame > prevFrame.current) { + for (let i = prevFrame.current + 1; i <= frame; i++) { + setCurrentFrame(i) + renderFrame({ frame: i, fps }) + } + setCurrentFrame(frame) + } else if (frame < prevFrame.current) { + renderFrame({ frame, fps }) + } + prevFrame.current = frame + return () => setCurrentFrame(undefined) + }, [frame, fps]) + + return <>{children} + } + // A realistic video composition structure const Composition = ({ frame }: { frame: number }) => ( {/* Intro: frames 0-30 (first second) */} - + + + Intro + + {/* Main content: frames 30-120 */} - + + + Content + + {/* Outro: frames 120-150 */} - + + + Outro + + ) - const IntroScene = ({ opacity }: { opacity: any }) => { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - useManualFrame({ frame, fps }) - - return ( - - Intro - - ) - } - - const ContentScene = ({ scale }: { scale: any }) => { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - useManualFrame({ frame, fps }) - - return ( - - Content - - ) - } - - const OutroScene = ({ y }: { y: any }) => { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - useManualFrame({ frame, fps }) - - return ( - - Outro - - ) - } - const { rerender } = render() // Test intro sequence @@ -1058,4 +1230,511 @@ describe("Remotion Integration - useManualFrame", () => { expect(x.get()).toBe(100) }) }) + + describe("Layout Animation Scrubbing", () => { + /** + * Helper to mock getBoundingClientRect per-element. + * The projection system discards snapshots when getBoundingClientRect + * returns zero-size boxes (create-projection-node.ts updateSnapshot), + * so we must provide non-zero boxes in JSDOM. + */ + function createLayoutMock() { + const boxes = new Map() + const original = Element.prototype.getBoundingClientRect + + Element.prototype.getBoundingClientRect = function () { + const box = boxes.get(this) + if (box) return box + return original.call(this) + } + + return { + setBox( + el: Element, + rect: { + left: number + top: number + width: number + height: number + } + ) { + boxes.set(el, { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON() {}, + } as DOMRect) + }, + cleanup() { + Element.prototype.getBoundingClientRect = original + boxes.clear() + }, + } + } + + /** + * The projection system schedules layout updates via queueMicrotask. + * React's act() doesn't always flush custom microtask batchers. + * This helper ensures the projection microtask queue is drained. + */ + async function flushMicrotasks() { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + let layoutMock: ReturnType + + beforeEach(() => { + layoutMock = createLayoutMock() + }) + + afterEach(() => { + layoutMock.cleanup() + }) + + /** + * Parse translate3d and scale from a CSS transform string. + * Returns { tx, ty, sx, sy } or null if transform is "none" or empty. + */ + function parseProjectionTransform(transform: string) { + if (!transform || transform === "none") return null + + let tx = 0, + ty = 0, + sx = 1, + sy = 1 + + const translate3dMatch = transform.match( + /translate3d\(\s*([-\d.]+)px,\s*([-\d.]+)px,\s*([-\d.]+)px\s*\)/ + ) + if (translate3dMatch) { + tx = parseFloat(translate3dMatch[1]) + ty = parseFloat(translate3dMatch[2]) + } + + const scaleMatch = transform.match( + /scale\(\s*([-\d.]+)(?:,\s*([-\d.]+))?\s*\)/ + ) + if (scaleMatch) { + sx = parseFloat(scaleMatch[1]) + sy = scaleMatch[2] !== undefined ? parseFloat(scaleMatch[2]) : sx + } + + return { tx, ty, sx, sy } + } + + test("basic layout animation generates projection transforms", async () => { + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "layout-basic", + } + + let elementRef: HTMLDivElement | null = null + + const LayoutComponent = ({ + frame, + wide, + }: { + frame: number + wide: boolean + }) => { + const ref = useRef(null) + + useLayoutEffect(() => { + if (ref.current) { + elementRef = ref.current + layoutMock.setBox( + ref.current, + wide + ? { + left: 200, + top: 0, + width: 200, + height: 100, + } + : { + left: 0, + top: 0, + width: 100, + height: 100, + } + ) + } + }, [wide]) + + return ( + + + + + + ) + } + + // Mount with box A (narrow) + const { rerender } = render( + , + false + ) + + // Advance a frame to initialize + await act(async () => { + rerender() + }) + + expect(elementRef).not.toBeNull() + + // Trigger layout change to box B (wide) + await act(async () => { + rerender() + }) + await flushMicrotasks() + + const transform = elementRef!.style.transform + const parsed = parseProjectionTransform(transform) + + // The projection system should have generated a non-identity transform + // to animate from box A to box B + expect(parsed).not.toBeNull() + if (parsed) { + // At the start of the animation, we expect either translation or scale + // to be non-identity (the element moved from 0,0 100x100 to 200,0 200x100) + const hasTranslation = parsed.tx !== 0 || parsed.ty !== 0 + const hasScale = parsed.sx !== 1 || parsed.sy !== 1 + expect(hasTranslation || hasScale).toBe(true) + } + + // Advance to animation end (30 frames at 30fps = 1s) + for (let f = 3; f <= 32; f++) { + await act(async () => { + rerender() + }) + } + + // At the end of the animation, transform should be identity + const endTransform = elementRef!.style.transform + const endParsed = parseProjectionTransform(endTransform) + if (endParsed) { + expect(Math.abs(endParsed.tx)).toBeLessThan(0.1) + expect(Math.abs(endParsed.ty)).toBeLessThan(0.1) + expect(endParsed.sx).toBeCloseTo(1, 1) + expect(endParsed.sy).toBeCloseTo(1, 1) + } + // "none" or empty is also acceptable at end + }) + + test("layout animation values progress over frames", async () => { + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "layout-progress", + } + + let elementRef: HTMLDivElement | null = null + + const LayoutComponent = ({ + frame, + wide, + }: { + frame: number + wide: boolean + }) => { + const ref = useRef(null) + + useLayoutEffect(() => { + if (ref.current) { + elementRef = ref.current + layoutMock.setBox( + ref.current, + wide + ? { + left: 200, + top: 0, + width: 200, + height: 100, + } + : { + left: 0, + top: 0, + width: 100, + height: 100, + } + ) + } + }, [wide]) + + return ( + + + + + + ) + } + + // Mount with box A + const { rerender } = render( + , + false + ) + + await act(async () => { + rerender() + }) + + // Trigger layout change + await act(async () => { + rerender() + }) + await flushMicrotasks() + + // Record transforms at early, middle, and late frames + const transforms: string[] = [] + + // Early frame + await act(async () => { + rerender() + }) + transforms.push(elementRef!.style.transform) + + // Middle frame (~halfway through 1s animation at 30fps) + await act(async () => { + rerender() + }) + transforms.push(elementRef!.style.transform) + + // Late frame (near end) + await act(async () => { + rerender() + }) + transforms.push(elementRef!.style.transform) + + // Verify that values change between frames (animation is progressing) + // At minimum, the early and late transforms should differ + expect(transforms[0]).not.toBe(transforms[2]) + }) + + test("backward scrubbing replays cached layout transforms", async () => { + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "layout-backward", + } + + let elementRef: HTMLDivElement | null = null + + const LayoutComponent = ({ + frame, + wide, + }: { + frame: number + wide: boolean + }) => { + const ref = useRef(null) + + useLayoutEffect(() => { + if (ref.current) { + elementRef = ref.current + layoutMock.setBox( + ref.current, + wide + ? { + left: 200, + top: 0, + width: 200, + height: 100, + } + : { + left: 0, + top: 0, + width: 100, + height: 100, + } + ) + } + }, [wide]) + + return ( + + + + + + ) + } + + // Mount with box A + const { rerender } = render( + , + false + ) + + await act(async () => { + rerender() + }) + + // Trigger layout change at frame 2 + await act(async () => { + rerender() + }) + await flushMicrotasks() + + // Record transform at key frames during forward pass + const forwardTransforms: Record = {} + + for (let f = 3; f <= 32; f++) { + await act(async () => { + rerender() + }) + if (f === 10 || f === 15 || f === 20) { + forwardTransforms[f] = elementRef!.style.transform + } + } + + // Scrub backward to frame 15 + await act(async () => { + rerender() + }) + const backwardTransform15 = elementRef!.style.transform + + // The backward-scrubbed transform should match what was recorded + // during the forward pass at the same frame + expect(backwardTransform15).toBe(forwardTransforms[15]) + }) + + test("layout animation with layout='position' (only translate, no scale)", async () => { + const config: VideoConfig = { + fps: 30, + width: 1920, + height: 1080, + durationInFrames: 60, + id: "layout-position", + } + + let elementRef: HTMLDivElement | null = null + + const LayoutComponent = ({ + frame, + moved, + }: { + frame: number + moved: boolean + }) => { + const ref = useRef(null) + + useLayoutEffect(() => { + if (ref.current) { + elementRef = ref.current + // Same size boxes, different positions + layoutMock.setBox( + ref.current, + moved + ? { + left: 200, + top: 100, + width: 100, + height: 100, + } + : { + left: 0, + top: 0, + width: 100, + height: 100, + } + ) + } + }, [moved]) + + return ( + + + + + + ) + } + + // Mount with box A + const { rerender } = render( + , + false + ) + + await act(async () => { + rerender() + }) + + // Trigger layout change + await act(async () => { + rerender() + }) + await flushMicrotasks() + + // Advance a few frames into the animation + await act(async () => { + rerender() + }) + + const transform = elementRef!.style.transform + const parsed = parseProjectionTransform(transform) + + // With layout="position" and same-sized boxes, + // should have translation but scale should be 1 + expect(parsed).not.toBeNull() + if (parsed) { + const hasTranslation = parsed.tx !== 0 || parsed.ty !== 0 + expect(hasTranslation).toBe(true) + expect(parsed.sx).toBeCloseTo(1, 1) + expect(parsed.sy).toBeCloseTo(1, 1) + } + }) + }) }) diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 693723173f..934f200c71 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -73,9 +73,15 @@ export function createRenderBatcher( runNextFrame = true useDefaultElapsed = true - // Skip rAF scheduling when using a custom driver (e.g., Remotion) - // In this case, processFrame() is called manually to advance animations - if (!state.isProcessing && !MotionGlobalConfig.driver) { + // Skip rAF scheduling when using a custom driver (e.g., Remotion). + // In this case, processFrame() is called manually to advance animations. + // But always allow scheduling for non-keepAlive batchers (microtask batcher) + // since those use queueMicrotask, not rAF, and are needed for + // layout animation setup regardless of driver. + if ( + !state.isProcessing && + (!MotionGlobalConfig.driver || !allowKeepAlive) + ) { scheduleNextBatch(processBatch) } } From 3dcf3de84d019f6a764cf161d79aff7ee4a92046 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 6 Feb 2026 08:18:24 +0100 Subject: [PATCH 09/11] Latest --- .../motion-dom/src/animation/JSAnimation.ts | 10 +- .../motion-dom/src/frameloop/render-frame.ts | 13 +-- packages/motion-remotion/package.json | 52 ----------- packages/motion-remotion/rollup.config.mjs | 93 ------------------- .../motion-remotion/src/MotionRemotion.tsx | 53 ----------- packages/motion-remotion/src/driver.ts | 17 ---- packages/motion-remotion/src/index.ts | 2 - packages/motion-remotion/tsconfig.json | 9 -- 8 files changed, 12 insertions(+), 237 deletions(-) delete mode 100644 packages/motion-remotion/package.json delete mode 100644 packages/motion-remotion/rollup.config.mjs delete mode 100644 packages/motion-remotion/src/MotionRemotion.tsx delete mode 100644 packages/motion-remotion/src/driver.ts delete mode 100644 packages/motion-remotion/src/index.ts delete mode 100644 packages/motion-remotion/tsconfig.json diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index 831a3d33e7..2e85b5e431 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -234,7 +234,11 @@ export class JSAnimation // If this animation has finished, set the current time to the total duration. if (this.state === "finished" && this.holdTime === null) { - this.currentTime = totalDuration + if (MotionGlobalConfig.driver && this.currentTime < totalDuration) { + this.state = "running" + } else { + this.currentTime = totalDuration + } } let elapsed = this.currentTime @@ -467,7 +471,9 @@ export class JSAnimation finish() { this.notifyFinished() - this.teardown() + if (!MotionGlobalConfig.driver) { + this.teardown() + } this.state = "finished" this.options.onComplete?.() diff --git a/packages/motion-dom/src/frameloop/render-frame.ts b/packages/motion-dom/src/frameloop/render-frame.ts index cd39d637a8..cf2db81684 100644 --- a/packages/motion-dom/src/frameloop/render-frame.ts +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -31,25 +31,20 @@ interface RenderFrameOptions { /** * Manually render a single animation frame. * - * Use this with a custom driver (e.g., `remotionDriver` from `motion-remotion`) - * to control animation timing externally. The custom driver prevents + * Use this with a custom driver (`MotionGlobalConfig.driver`) to control + * animation timing externally. The custom driver prevents * requestAnimationFrame from auto-advancing animations. * * @example * // Set up custom driver first - * import { remotionDriver } from 'motion-remotion' - * MotionGlobalConfig.driver = remotionDriver + * MotionGlobalConfig.driver = myCustomDriver * * // Then render frames manually * renderFrame({ timestamp: 1000 }) // Render at 1 second * * @example - * // Using frame number (Remotion-style) + * // Using frame number * renderFrame({ frame: 30, fps: 30 }) // Render at frame 30 (1 second at 30fps) - * - * @example - * // For Remotion, use the useRemotionFrame hook which handles this automatically - * import { useRemotionFrame } from 'motion-remotion' */ export function renderFrame(options: RenderFrameOptions = {}): void { const { timestamp, frame, fps = 30, delta } = options diff --git a/packages/motion-remotion/package.json b/packages/motion-remotion/package.json deleted file mode 100644 index 1711f926a1..0000000000 --- a/packages/motion-remotion/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "motion-remotion", - "version": "12.29.2", - "description": "Remotion integration for Motion animations", - "main": "dist/cjs/index.js", - "module": "dist/es/index.mjs", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/cjs/index.js", - "import": "./dist/es/index.mjs", - "default": "./dist/cjs/index.js" - }, - "./package.json": "./package.json" - }, - "types": "dist/index.d.ts", - "author": "Matt Perry", - "license": "MIT", - "repository": "https://github.com/motiondivision/motion", - "sideEffects": false, - "keywords": [ - "motion", - "remotion", - "animation", - "video", - "react" - ], - "scripts": { - "build": "yarn clean && tsc -p . && rollup -c", - "dev": "concurrently -c blue,red -n tsc,rollup --kill-others \"tsc --watch -p . --preserveWatchOutput\" \"rollup --config --watch --no-watch.clearScreen\"", - "clean": "rm -rf types dist lib", - "prepack": "yarn build", - "postpublish": "git push --tags" - }, - "dependencies": { - "motion-dom": "^12.29.2", - "motion-utils": "^12.29.2", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "remotion": ">=4.0.0" - }, - "peerDependenciesMeta": { - "remotion": { - "optional": true - } - }, - "devDependencies": { - "remotion": "^4.0.417" - } -} diff --git a/packages/motion-remotion/rollup.config.mjs b/packages/motion-remotion/rollup.config.mjs deleted file mode 100644 index 410ef430e7..0000000000 --- a/packages/motion-remotion/rollup.config.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import resolve from "@rollup/plugin-node-resolve" -import replace from "@rollup/plugin-replace" -import sourcemaps from "rollup-plugin-sourcemaps" -import dts from "rollup-plugin-dts" -import preserveDirectives from "rollup-plugin-preserve-directives" -import pkg from "./package.json" with { type: "json" } -import tsconfig from "./tsconfig.json" with { type: "json" } - -const config = { - input: "lib/index.js", -} - -export const replaceSettings = (env) => { - const replaceConfig = env - ? { - "process.env.NODE_ENV": JSON.stringify(env), - preventAssignment: false, - } - : { - preventAssignment: false, - } - - return replace(replaceConfig) -} - -const external = [ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.peerDependencies || {}), - ...Object.keys(pkg.optionalDependencies || {}), - "react/jsx-runtime", -] - -const cjs = Object.assign({}, config, { - input: "lib/index.js", - output: { - entryFileNames: `[name].js`, - dir: "dist/cjs", - format: "cjs", - exports: "named", - esModule: true, - sourcemap: true, - }, - plugins: [resolve(), replaceSettings(), sourcemaps()], - external, - onwarn(warning, warn) { - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { - return - } - warn(warning) - } -}) - -export const es = Object.assign({}, config, { - input: ["lib/index.js"], - output: { - entryFileNames: "[name].mjs", - format: "es", - exports: "named", - preserveModules: true, - dir: "dist/es", - sourcemap: true, - }, - plugins: [resolve(), replaceSettings(), preserveDirectives(), sourcemaps()], - external, - onwarn(warning, warn) { - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { - return - } - warn(warning) - } -}) - -const typePlugins = [dts({compilerOptions: {...tsconfig, baseUrl:"types"}})] - -function createTypes(input, file) { - return { - input, - output: { - format: "es", - file: file, - }, - plugins: typePlugins, - } -} - -const types = createTypes("types/index.d.ts", "dist/index.d.ts") - -// eslint-disable-next-line import/no-default-export -export default [ - cjs, - es, - types, -] diff --git a/packages/motion-remotion/src/MotionRemotion.tsx b/packages/motion-remotion/src/MotionRemotion.tsx deleted file mode 100644 index 1c2e4461b0..0000000000 --- a/packages/motion-remotion/src/MotionRemotion.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client" - -import { renderFrame } from "motion-dom" -import { MotionGlobalConfig } from "motion-utils" -import { ReactNode, useEffect, useInsertionEffect, useRef } from "react" -import { useCurrentFrame, useVideoConfig } from "remotion" -import { remotionDriver } from "./driver" - -/** - * Wrap your Remotion composition with this component to sync - * Motion animations to Remotion's frame-based timeline. - * - * @example - * function MyComposition() { - * return ( - * - * - * - * ) - * } - */ -export function MotionRemotion({ children }: { children: ReactNode }) { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - const prevFrame = useRef(-1) - const hasInitialized = useRef(false) - - // Set the driver before any animations mount - useInsertionEffect(() => { - const previousDriver = MotionGlobalConfig.driver - MotionGlobalConfig.driver = remotionDriver - - return () => { - MotionGlobalConfig.driver = previousDriver - } - }, []) - - // Render frame when it changes - useEffect(() => { - if (frame !== prevFrame.current || !hasInitialized.current) { - const delta = - hasInitialized.current && prevFrame.current >= 0 - ? ((frame - prevFrame.current) / fps) * 1000 - : 1000 / fps - - renderFrame({ frame, fps, delta: Math.abs(delta) }) - prevFrame.current = frame - hasInitialized.current = true - } - }, [frame, fps]) - - return <>{children} -} diff --git a/packages/motion-remotion/src/driver.ts b/packages/motion-remotion/src/driver.ts deleted file mode 100644 index f79f2f8304..0000000000 --- a/packages/motion-remotion/src/driver.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { frame, cancelFrame, frameData, FrameData } from "motion-dom" -import type { Driver } from "motion-dom" - -/** - * Animation driver for Remotion that bypasses requestAnimationFrame. - * When set via MotionGlobalConfig.driver, animations only advance - * when renderFrame() is called. - */ -export const remotionDriver: Driver = (update) => { - const passTimestamp = ({ timestamp }: FrameData) => update(timestamp) - - return { - start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), - stop: () => cancelFrame(passTimestamp), - now: () => frameData.timestamp, - } -} diff --git a/packages/motion-remotion/src/index.ts b/packages/motion-remotion/src/index.ts deleted file mode 100644 index 335bf9e844..0000000000 --- a/packages/motion-remotion/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MotionRemotion } from "./MotionRemotion" -export { remotionDriver } from "./driver" diff --git a/packages/motion-remotion/tsconfig.json b/packages/motion-remotion/tsconfig.json deleted file mode 100644 index 8b7f9d5ce6..0000000000 --- a/packages/motion-remotion/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../config/tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib", - "declarationDir": "types" - }, - "include": ["src/**/*"] -} From 114f505063908d60f405856eb7eff9fe9f3d956a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 6 Feb 2026 13:22:56 +0100 Subject: [PATCH 10/11] Latest --- dev/react/src/tests/manual-frame-control.tsx | 20 +--- .../use-manual-frame-remotion.test.tsx | 102 +++++++++++------- .../motion-dom/src/animation/JSAnimation.ts | 10 +- .../waapi/supports/__tests__/waapi.test.ts | 21 ++-- .../src/animation/waapi/supports/waapi.ts | 7 +- .../frameloop/__tests__/render-frame.test.ts | 13 +-- packages/motion-dom/src/frameloop/batcher.ts | 25 ++--- .../motion-dom/src/frameloop/render-frame.ts | 17 +-- .../motion-dom/src/frameloop/sync-time.ts | 3 +- packages/motion-dom/src/index.ts | 5 - packages/motion-dom/src/render/frame-cache.ts | 69 ------------ .../src/render/html/utils/render.ts | 38 ------- .../motion-dom/src/render/svg/utils/render.ts | 31 +----- packages/motion-utils/src/global-config.ts | 18 +--- 14 files changed, 112 insertions(+), 267 deletions(-) delete mode 100644 packages/motion-dom/src/render/frame-cache.ts diff --git a/dev/react/src/tests/manual-frame-control.tsx b/dev/react/src/tests/manual-frame-control.tsx index b525a7c3e5..f53af36eda 100644 --- a/dev/react/src/tests/manual-frame-control.tsx +++ b/dev/react/src/tests/manual-frame-control.tsx @@ -1,18 +1,8 @@ import { motion, useMotionValue } from "framer-motion" -import { renderFrame, frame, cancelFrame } from "motion-dom" +import { renderFrame } from "motion-dom" import { MotionGlobalConfig } from "motion-utils" import { useCallback, useEffect, useState } from "react" -// Manual driver that doesn't auto-schedule rAF -const manualDriver = (update: (t: number) => void) => { - const passTimestamp = ({ timestamp }: { timestamp: number }) => update(timestamp) - return { - start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), - stop: () => cancelFrame(passTimestamp), - now: () => 0, - } -} - /** * Demo: Manual Frame Control * @@ -33,18 +23,18 @@ export const App = () => { // Calculate current time in ms const currentTime = (currentFrame / fps) * 1000 - // Enable/disable manual timing mode via custom driver + // Enable/disable manual timing mode useEffect(() => { if (manualMode) { - MotionGlobalConfig.driver = manualDriver + MotionGlobalConfig.useManualTiming = true // Reset to frame 0 when entering manual mode setCurrentFrame(0) renderFrame({ frame: 0, fps }) } else { - MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = undefined } return () => { - MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = undefined } }, [manualMode, fps]) diff --git a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx index d7a9d68b8d..10232ce0c9 100644 --- a/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -4,23 +4,13 @@ * In production, wrap your composition with `` from `motion-remotion`. */ -import { motionValue, Variants, renderFrame, setCurrentFrame, clearFrameCache, frame as frameLoop, cancelFrame, frameData } from "motion-dom" +import { motionValue, Variants, renderFrame } from "motion-dom" import { MotionGlobalConfig } from "motion-utils" -import { createContext, useContext, ReactNode, useEffect, useLayoutEffect, useRef } from "react" +import { createContext, useContext, ReactNode, useInsertionEffect, useLayoutEffect, useRef } from "react" import { act } from "react" import { motion, AnimatePresence } from "../../" import { render } from "../../jest.setup" -// Mock driver (same as remotionDriver but inline for tests) -const mockRemotionDriver = (update: (t: number) => void) => { - const passTimestamp = ({ timestamp }: { timestamp: number }) => update(timestamp) - return { - start: (keepAlive = true) => frameLoop.update(passTimestamp, keepAlive), - stop: () => cancelFrame(passTimestamp), - now: () => frameData.timestamp, - } -} - // Mock Remotion API interface VideoConfig { fps: number @@ -89,48 +79,80 @@ function AbsoluteFill({ children, style }: { children: ReactNode; style?: React. /** * Test bridge that mirrors MotionRemotion from the plus repo. - * Uses renderFrame + setCurrentFrame directly (no useManualFrame hook). + * Uses renderFrame with style-capture for backward scrubbing. */ function MotionRemotionBridge({ children }: { children: ReactNode }) { const currentFrame = useCurrentFrame() const { fps } = useVideoConfig() const prevFrame = useRef(-1) + const containerRef = useRef(null) + const styleCacheRef = useRef }>>>(new Map()) + + useInsertionEffect(() => { + MotionGlobalConfig.useManualTiming = true + return () => { MotionGlobalConfig.useManualTiming = undefined } + }, []) useLayoutEffect(() => { - setCurrentFrame(currentFrame) + const captureStyles = (frameNum: number) => { + if (!containerRef.current) return + const elements = containerRef.current.querySelectorAll('*') + const frameStyles = new Map }>() + elements.forEach((el) => { + const entry: { style: string; attrs?: Record } = { + style: (el as HTMLElement).style.cssText, + } + if (el instanceof SVGElement && !(el instanceof HTMLElement)) { + const attrs: Record = {} + el.getAttributeNames().forEach((name) => { + attrs[name] = el.getAttribute(name)! + }) + entry.attrs = attrs + } + frameStyles.set(el, entry) + }) + styleCacheRef.current.set(frameNum, frameStyles) + } + + const applyCachedStyles = (frameNum: number) => { + const frameStyles = styleCacheRef.current.get(frameNum) + if (!frameStyles) return false + frameStyles.forEach((cached, el) => { + (el as HTMLElement).style.cssText = cached.style + if (cached.attrs) { + for (const name in cached.attrs) { + (el as SVGElement).setAttribute(name, cached.attrs[name]) + } + } + }) + return true + } if (prevFrame.current < 0) { renderFrame({ frame: currentFrame, fps }) + captureStyles(currentFrame) } else if (currentFrame > prevFrame.current) { - // Forward: render intermediate frames to build cache + // Forward: render intermediate frames for (let i = prevFrame.current + 1; i <= currentFrame; i++) { - setCurrentFrame(i) renderFrame({ frame: i, fps }) + captureStyles(i) } - setCurrentFrame(currentFrame) } else if (currentFrame < prevFrame.current) { - // Backward: JSAnimation fix handles time reversal - renderFrame({ frame: currentFrame, fps }) + // Backward: re-render from 0 to update motionValues, + // then apply cached styles for layout animations + for (let i = 0; i <= currentFrame; i++) { + renderFrame({ frame: i, fps }) + } + applyCachedStyles(currentFrame) } prevFrame.current = currentFrame - return () => setCurrentFrame(undefined) }, [currentFrame, fps]) - useEffect(() => () => clearFrameCache(), []) - - return <>{children} + return
{children}
} describe("Remotion Integration", () => { - beforeEach(() => { - MotionGlobalConfig.driver = mockRemotionDriver - }) - - afterEach(() => { - MotionGlobalConfig.driver = undefined - }) - describe("Mocked Remotion Environment", () => { test("renders correctly at typical Remotion FPS values (30fps)", async () => { const x = motionValue(0) @@ -932,21 +954,22 @@ describe("Remotion Integration", () => { const { fps } = useVideoConfig() const prevFrame = useRef(-1) + useInsertionEffect(() => { + MotionGlobalConfig.useManualTiming = true + return () => { MotionGlobalConfig.useManualTiming = undefined } + }, []) + useLayoutEffect(() => { - setCurrentFrame(frame) if (prevFrame.current < 0) { renderFrame({ frame, fps }) } else if (frame > prevFrame.current) { for (let i = prevFrame.current + 1; i <= frame; i++) { - setCurrentFrame(i) renderFrame({ frame: i, fps }) } - setCurrentFrame(frame) } else if (frame < prevFrame.current) { renderFrame({ frame, fps }) } prevFrame.current = frame - return () => setCurrentFrame(undefined) }, [frame, fps]) return ( @@ -1013,21 +1036,22 @@ describe("Remotion Integration", () => { const { fps } = useVideoConfig() const prevFrame = useRef(-1) + useInsertionEffect(() => { + MotionGlobalConfig.useManualTiming = true + return () => { MotionGlobalConfig.useManualTiming = undefined } + }, []) + useLayoutEffect(() => { - setCurrentFrame(frame) if (prevFrame.current < 0) { renderFrame({ frame, fps }) } else if (frame > prevFrame.current) { for (let i = prevFrame.current + 1; i <= frame; i++) { - setCurrentFrame(i) renderFrame({ frame: i, fps }) } - setCurrentFrame(frame) } else if (frame < prevFrame.current) { renderFrame({ frame, fps }) } prevFrame.current = frame - return () => setCurrentFrame(undefined) }, [frame, fps]) return <>{children} diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index 2e85b5e431..7003be3636 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -234,7 +234,7 @@ export class JSAnimation // If this animation has finished, set the current time to the total duration. if (this.state === "finished" && this.holdTime === null) { - if (MotionGlobalConfig.driver && this.currentTime < totalDuration) { + if (MotionGlobalConfig.useManualTiming && this.currentTime < totalDuration) { this.state = "running" } else { this.currentTime = totalDuration @@ -400,11 +400,7 @@ export class JSAnimation if (this.isStopped) return const { startTime } = this.options - // Priority: global driver > options driver > default frameloop driver - const driver = - MotionGlobalConfig.driver ?? - this.options.driver ?? - frameloopDriver + const driver = this.options.driver ?? frameloopDriver if (!this.driver) { this.driver = driver((timestamp) => this.tick(timestamp)) @@ -471,7 +467,7 @@ export class JSAnimation finish() { this.notifyFinished() - if (!MotionGlobalConfig.driver) { + if (!MotionGlobalConfig.useManualTiming) { this.teardown() } this.state = "finished" diff --git a/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts index 6bbaf9ef6b..4e279c8c45 100644 --- a/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts +++ b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts @@ -1,20 +1,13 @@ import { MotionGlobalConfig } from "motion-utils" import { supportsBrowserAnimation } from "../waapi" -// Mock driver for testing -const mockDriver = () => ({ - start: () => {}, - stop: () => {}, - now: () => 0, -}) - describe("supportsBrowserAnimation", () => { afterEach(() => { - MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = undefined }) - test("returns false when a custom driver is set", () => { - MotionGlobalConfig.driver = mockDriver + test("returns false when useManualTiming is set", () => { + MotionGlobalConfig.useManualTiming = true // Even with a valid accelerated value config, WAAPI should be disabled const result = supportsBrowserAnimation({ @@ -30,8 +23,8 @@ describe("supportsBrowserAnimation", () => { expect(result).toBe(false) }) - test("allows WAAPI when no custom driver is set", () => { - MotionGlobalConfig.driver = undefined + test("allows WAAPI when useManualTiming is not set", () => { + MotionGlobalConfig.useManualTiming = undefined // With a valid HTML element and accelerated property, should allow WAAPI // (assuming browser supports it) @@ -46,13 +39,13 @@ describe("supportsBrowserAnimation", () => { } as any) // In jsdom, Element.prototype.animate may not exist, so this could be false - // The key test is that it doesn't short-circuit on driver check + // The key test is that it doesn't short-circuit on useManualTiming check // We verify by checking the function reaches the browser support check expect(typeof result).toBe("boolean") }) test("returns false for non-accelerated values", () => { - MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = undefined const result = supportsBrowserAnimation({ name: "x", // Not an accelerated value diff --git a/packages/motion-dom/src/animation/waapi/supports/waapi.ts b/packages/motion-dom/src/animation/waapi/supports/waapi.ts index 1a7b234567..558de41a05 100644 --- a/packages/motion-dom/src/animation/waapi/supports/waapi.ts +++ b/packages/motion-dom/src/animation/waapi/supports/waapi.ts @@ -23,12 +23,6 @@ const supportsWaapi = /*@__PURE__*/ memo(() => export function supportsBrowserAnimation( options: ValueAnimationOptionsWithRenderContext ) { - // Disable WAAPI when a custom driver is set (e.g., Remotion) - // Custom drivers control timing externally, so WAAPI would desync - if (MotionGlobalConfig.driver) { - return false - } - const { motionValue, name, repeatDelay, repeatType, damping, type } = options @@ -47,6 +41,7 @@ export function supportsBrowserAnimation( const { onUpdate, transformTemplate } = motionValue!.owner!.getProps() return ( + !MotionGlobalConfig.useManualTiming && supportsWaapi() && name && acceleratedValues.has(name) && diff --git a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts index a24a15e889..f48eae1259 100644 --- a/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts +++ b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts @@ -2,21 +2,14 @@ import { MotionGlobalConfig } from "motion-utils" import { frame, cancelFrame } from ".." import { renderFrame } from "../render-frame" -// Mock driver that doesn't auto-schedule rAF -const mockDriver = () => ({ - start: () => {}, - stop: () => {}, - now: () => 0, -}) - describe("renderFrame", () => { beforeEach(() => { - // Set mock driver to prevent rAF scheduling - MotionGlobalConfig.driver = mockDriver + // Set useManualTiming to prevent rAF scheduling + MotionGlobalConfig.useManualTiming = true }) afterEach(() => { - MotionGlobalConfig.driver = undefined + MotionGlobalConfig.useManualTiming = undefined }) it("processes scheduled callbacks with provided timestamp", () => { diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 934f200c71..f61a9614f8 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -40,12 +40,19 @@ export function createRenderBatcher( } = steps const processBatch = () => { - const timestamp = performance.now() + const timestamp = MotionGlobalConfig.useManualTiming + ? state.timestamp + : performance.now() runNextFrame = false - state.delta = useDefaultElapsed - ? 1000 / 60 - : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1) + if (!MotionGlobalConfig.useManualTiming) { + state.delta = useDefaultElapsed + ? 1000 / 60 + : Math.max( + Math.min(timestamp - state.timestamp, maxElapsed), + 1 + ) + } state.timestamp = timestamp state.isProcessing = true @@ -62,8 +69,7 @@ export function createRenderBatcher( state.isProcessing = false - // Skip rAF scheduling when using a custom driver (e.g., Remotion) - if (runNextFrame && allowKeepAlive && !MotionGlobalConfig.driver) { + if (runNextFrame && allowKeepAlive && !MotionGlobalConfig.useManualTiming) { useDefaultElapsed = false scheduleNextBatch(processBatch) } @@ -73,14 +79,9 @@ export function createRenderBatcher( runNextFrame = true useDefaultElapsed = true - // Skip rAF scheduling when using a custom driver (e.g., Remotion). - // In this case, processFrame() is called manually to advance animations. - // But always allow scheduling for non-keepAlive batchers (microtask batcher) - // since those use queueMicrotask, not rAF, and are needed for - // layout animation setup regardless of driver. if ( !state.isProcessing && - (!MotionGlobalConfig.driver || !allowKeepAlive) + (!MotionGlobalConfig.useManualTiming || !allowKeepAlive) ) { scheduleNextBatch(processBatch) } diff --git a/packages/motion-dom/src/frameloop/render-frame.ts b/packages/motion-dom/src/frameloop/render-frame.ts index cf2db81684..d06427261b 100644 --- a/packages/motion-dom/src/frameloop/render-frame.ts +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -1,3 +1,4 @@ +import { MotionGlobalConfig } from "motion-utils" import { processFrame, frameData } from "./frame" import { time } from "./sync-time" @@ -31,15 +32,10 @@ interface RenderFrameOptions { /** * Manually render a single animation frame. * - * Use this with a custom driver (`MotionGlobalConfig.driver`) to control - * animation timing externally. The custom driver prevents - * requestAnimationFrame from auto-advancing animations. + * Temporarily enables `useManualTiming` mode during frame processing + * to prevent requestAnimationFrame from auto-advancing animations. * * @example - * // Set up custom driver first - * MotionGlobalConfig.driver = myCustomDriver - * - * // Then render frames manually * renderFrame({ timestamp: 1000 }) // Render at 1 second * * @example @@ -65,9 +61,16 @@ export function renderFrame(options: RenderFrameOptions = {}): void { frameTimestamp = frameData.timestamp + frameDelta } + // Temporarily enable manual timing mode during frame processing + const previousManualTiming = MotionGlobalConfig.useManualTiming + MotionGlobalConfig.useManualTiming = true + // Set the synchronized time time.set(frameTimestamp) // Process the frame - this runs all registered callbacks processFrame(frameTimestamp, frameDelta) + + // Restore previous manual timing setting + MotionGlobalConfig.useManualTiming = previousManualTiming } diff --git a/packages/motion-dom/src/frameloop/sync-time.ts b/packages/motion-dom/src/frameloop/sync-time.ts index a903bc812a..be5e910a84 100644 --- a/packages/motion-dom/src/frameloop/sync-time.ts +++ b/packages/motion-dom/src/frameloop/sync-time.ts @@ -19,7 +19,8 @@ export const time = { now: (): number => { if (now === undefined) { time.set( - frameData.isProcessing || MotionGlobalConfig.driver + frameData.isProcessing || + MotionGlobalConfig.useManualTiming ? frameData.timestamp : performance.now() ) diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 005e06a30b..6edb7892b1 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -72,9 +72,6 @@ export * from "./frameloop/microtask" export * from "./frameloop/sync-time" export * from "./frameloop/types" -// Animation drivers -export * from "./animation/drivers/types" -export { frameloopDriver } from "./animation/drivers/frame" export * from "./gestures/drag/state/is-active" export * from "./gestures/drag/state/set-active" @@ -90,8 +87,6 @@ export * from "./gestures/utils/is-primary-pointer" export * from "./node/types" -export { clearFrameCache, setCurrentFrame } from "./render/frame-cache" - export * from "./render/dom/parse-transform" export * from "./render/dom/style-computed" export * from "./render/dom/style-set" diff --git a/packages/motion-dom/src/render/frame-cache.ts b/packages/motion-dom/src/render/frame-cache.ts deleted file mode 100644 index 8a7a3a8a00..0000000000 --- a/packages/motion-dom/src/render/frame-cache.ts +++ /dev/null @@ -1,69 +0,0 @@ -type ElementStyles = Record - -/** - * Map> - * - * WeakMap ensures cached entries are garbage-collected - * if an element is unmounted. - */ -const styleCache = new Map>() - -/** - * Separate cache for SVG attributes, keyed identically. - */ -const attrCache = new Map>() - -let currentFrame: number | undefined = undefined - -export function setCurrentFrame(frame: number | undefined) { - currentFrame = frame -} - -export function getCurrentFrame(): number | undefined { - return currentFrame -} - -export function getCachedStyles( - element: Element -): ElementStyles | undefined { - if (currentFrame === undefined) return undefined - return styleCache.get(currentFrame)?.get(element) -} - -export function setCachedStyles( - element: Element, - styles: ElementStyles -) { - if (currentFrame === undefined) return - if (!styleCache.has(currentFrame)) { - styleCache.set(currentFrame, new WeakMap()) - } - styleCache.get(currentFrame)!.set(element, styles) -} - -export function getCachedAttrs( - element: Element -): ElementStyles | undefined { - if (currentFrame === undefined) return undefined - return attrCache.get(currentFrame)?.get(element) -} - -export function setCachedAttrs( - element: Element, - attrs: ElementStyles -) { - if (currentFrame === undefined) return - if (!attrCache.has(currentFrame)) { - attrCache.set(currentFrame, new WeakMap()) - } - attrCache.get(currentFrame)!.set(element, attrs) -} - -export function clearFrameCache() { - styleCache.clear() - attrCache.clear() -} - -export function isFrameCacheActive(): boolean { - return currentFrame !== undefined -} diff --git a/packages/motion-dom/src/render/html/utils/render.ts b/packages/motion-dom/src/render/html/utils/render.ts index cc9eb2f3ef..dd20bda831 100644 --- a/packages/motion-dom/src/render/html/utils/render.ts +++ b/packages/motion-dom/src/render/html/utils/render.ts @@ -1,9 +1,4 @@ import type { MotionStyle } from "../../VisualElement" -import { - getCachedStyles, - isFrameCacheActive, - setCachedStyles, -} from "../../frame-cache" import { HTMLRenderState } from "../types" export function renderHTML( @@ -14,21 +9,6 @@ export function renderHTML( ) { const elementStyle = element.style - // Cache hit: apply cached styles, skip all computation - if (isFrameCacheActive()) { - const cached = getCachedStyles(element) - if (cached) { - for (const key in cached) { - if (key.startsWith("--")) { - elementStyle.setProperty(key, cached[key]) - } else { - elementStyle[key as unknown as number] = cached[key] - } - } - return - } - } - let key: string for (key in style) { // CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type. @@ -43,22 +23,4 @@ export function renderHTML( // They can only be assigned using `setProperty`. elementStyle.setProperty(key, vars[key] as string) } - - // Cache the result after render - if (isFrameCacheActive()) { - const record: Record = {} - for (key in style) { - record[key] = elementStyle[key as unknown as number] - } - for (key in vars) { - record[key] = elementStyle.getPropertyValue(key) - } - // Capture projection-applied properties - if (projection) { - record["transform"] = elementStyle.transform - record["transformOrigin"] = elementStyle.transformOrigin - if (elementStyle.opacity) record["opacity"] = elementStyle.opacity - } - setCachedStyles(element, record) - } } diff --git a/packages/motion-dom/src/render/svg/utils/render.ts b/packages/motion-dom/src/render/svg/utils/render.ts index 623722dab6..5741dc03aa 100644 --- a/packages/motion-dom/src/render/svg/utils/render.ts +++ b/packages/motion-dom/src/render/svg/utils/render.ts @@ -1,10 +1,5 @@ import type { MotionStyle } from "../../VisualElement" import { camelToDash } from "../../dom/utils/camel-to-dash" -import { - getCachedAttrs, - isFrameCacheActive, - setCachedAttrs, -} from "../../frame-cache" import { renderHTML } from "../../html/utils/render" import { SVGRenderState } from "../types" import { camelCaseAttributes } from "./camel-case-attrs" @@ -15,30 +10,12 @@ export function renderSVG( _styleProp?: MotionStyle, projection?: any ) { - // renderHTML handles its own style caching renderHTML(element as any, renderState, undefined, projection) - if (isFrameCacheActive()) { - const cachedAttrs = getCachedAttrs(element) - if (cachedAttrs) { - for (const key in cachedAttrs) { - element.setAttribute(key, cachedAttrs[key]) - } - return - } - } - - const attrRecord: Record = {} for (const key in renderState.attrs) { - const attrName = !camelCaseAttributes.has(key) - ? camelToDash(key) - : key - const value = renderState.attrs[key] as string - element.setAttribute(attrName, value) - attrRecord[attrName] = value - } - - if (isFrameCacheActive()) { - setCachedAttrs(element, attrRecord) + element.setAttribute( + !camelCaseAttributes.has(key) ? camelToDash(key) : key, + renderState.attrs[key] as string + ) } } diff --git a/packages/motion-utils/src/global-config.ts b/packages/motion-utils/src/global-config.ts index 5b7f6474f4..e240523092 100644 --- a/packages/motion-utils/src/global-config.ts +++ b/packages/motion-utils/src/global-config.ts @@ -1,23 +1,7 @@ -/** - * Minimal driver interface for global config. - * The full Driver type is in motion-dom. - */ -interface GlobalDriver { - (update: (timestamp: number) => void): { - start: (keepAlive?: boolean) => void - stop: () => void - now: () => number - } -} - export const MotionGlobalConfig: { skipAnimations?: boolean instantAnimations?: boolean + useManualTiming?: boolean WillChange?: any mix?: (a: T, b: T) => (p: number) => T - /** - * Custom animation driver. When set, WAAPI is disabled - * and all animations use this driver for timing. - */ - driver?: GlobalDriver } = {} From 30f32cfa37c0c539123cec037c0d34b468c3d7ac Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 6 Feb 2026 13:42:34 +0100 Subject: [PATCH 11/11] v12.34.0-alpha.0 --- dev/html/package.json | 8 ++--- dev/next/package.json | 4 +-- dev/react-19/package.json | 4 +-- dev/react/package.json | 4 +-- lerna.json | 2 +- packages/framer-motion/package.json | 4 +-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 +-- yarn.lock | 49 +++++++---------------------- 9 files changed, 27 insertions(+), 54 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 3d815866f6..f0f16bfc7c 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.33.1", + "version": "12.34.0-alpha.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.33.1", - "motion": "^12.33.1", - "motion-dom": "^12.33.1", + "framer-motion": "^12.34.0-alpha.0", + "motion": "^12.34.0-alpha.0", + "motion-dom": "^12.34.0-alpha.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 61da8f8a81..26cb30f1c5 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.33.1", + "version": "12.34.0-alpha.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.34.0-alpha.0", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 996031615a..1c451f7522 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.33.1", + "version": "12.34.0-alpha.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.34.0-alpha.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a8baa0f678..c08a7281b3 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.33.1", + "version": "12.34.0-alpha.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.33.1", + "framer-motion": "^12.34.0-alpha.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index d1f0925b41..5eb569362f 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.33.1", + "version": "12.34.0-alpha.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 6da9f86767..8fa737ca84 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.33.1", + "version": "12.34.0-alpha.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.33.1", + "motion-dom": "^12.34.0-alpha.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 0bfb737ecf..9a8bd4ad86 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.33.1", + "version": "12.34.0-alpha.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 2519b7dd05..afc71abf43 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.33.1", + "version": "12.34.0-alpha.0", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.33.1", + "framer-motion": "^12.34.0-alpha.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 17f6d5df5f..efc902b936 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.33.1, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.34.0-alpha.0, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.33.1 + motion-dom: ^12.34.0-alpha.0 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8209,9 +8209,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.33.1 - motion: ^12.33.1 - motion-dom: ^12.33.1 + framer-motion: ^12.34.0-alpha.0 + motion: ^12.34.0-alpha.0 + motion-dom: ^12.34.0-alpha.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10953,7 +10953,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.33.1, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.34.0-alpha.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11026,34 +11026,17 @@ __metadata: languageName: unknown linkType: soft -"motion-remotion@workspace:packages/motion-remotion": - version: 0.0.0-use.local - resolution: "motion-remotion@workspace:packages/motion-remotion" - dependencies: - motion-dom: ^12.29.2 - motion-utils: ^12.29.2 - remotion: ^4.0.417 - tslib: ^2.4.0 - peerDependencies: - react: ^18.0.0 || ^19.0.0 - remotion: ">=4.0.0" - peerDependenciesMeta: - remotion: - optional: true - languageName: unknown - linkType: soft - "motion-utils@^12.29.2, motion-utils@workspace:packages/motion-utils": version: 0.0.0-use.local resolution: "motion-utils@workspace:packages/motion-utils" languageName: unknown linkType: soft -"motion@^12.33.1, motion@workspace:packages/motion": +"motion@^12.34.0-alpha.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.33.1 + framer-motion: ^12.34.0-alpha.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11170,7 +11153,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.33.1 + motion: ^12.34.0-alpha.0 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12642,7 +12625,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.33.1 + motion: ^12.34.0-alpha.0 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12726,7 +12709,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.33.1 + framer-motion: ^12.34.0-alpha.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -13077,16 +13060,6 @@ __metadata: languageName: node linkType: hard -"remotion@npm:^4.0.417": - version: 4.0.417 - resolution: "remotion@npm:4.0.417" - peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 88c08e7fa1d4c243bdb9327734a6d94aa81afa3f94fe9c7767eef7b2b2fc2125eae464b0e5ea5759aa7a95cf17f7cc102a4c0c300f22571b14f2929a77382a9a - languageName: node - linkType: hard - "repeat-element@npm:^1.1.2": version: 1.1.4 resolution: "repeat-element@npm:1.1.4"