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/dev/react/src/tests/manual-frame-control.tsx b/dev/react/src/tests/manual-frame-control.tsx new file mode 100644 index 0000000000..f53af36eda --- /dev/null +++ b/dev/react/src/tests/manual-frame-control.tsx @@ -0,0 +1,318 @@ +import { motion, useMotionValue } from "framer-motion" +import { renderFrame } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" +import { useCallback, useEffect, useState } 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(() => { + if (manualMode) { + MotionGlobalConfig.useManualTiming = true + // Reset to frame 0 when entering manual mode + setCurrentFrame(0) + renderFrame({ frame: 0, fps }) + } else { + MotionGlobalConfig.useManualTiming = undefined + } + return () => { + MotionGlobalConfig.useManualTiming = undefined + } + }, [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:

+ + +

Remotion Integration:

+
+                    {`import { MotionRemotion } from 'motion-remotion'
+
+function MyComposition() {
+  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/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/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..10232ce0c9 --- /dev/null +++ b/packages/framer-motion/src/utils/__tests__/use-manual-frame-remotion.test.tsx @@ -0,0 +1,1764 @@ +/** + * Remotion Integration Tests + * + * In production, wrap your composition with `` from `motion-remotion`. + */ + +import { motionValue, Variants, renderFrame } from "motion-dom" +import { MotionGlobalConfig } from "motion-utils" +import { createContext, useContext, ReactNode, useInsertionEffect, useLayoutEffect, useRef } from "react" +import { act } from "react" +import { motion, AnimatePresence } from "../../" +import { render } from "../../jest.setup" + +// Mock Remotion API +interface VideoConfig { + fps: number + width: number + height: number + durationInFrames: number + id: string +} + +interface RemotionContextValue { + frame: number + config: VideoConfig +} + +const RemotionContext = createContext(null) + +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 +} + +function Sequence({ + from = 0, + children, +}: { + from?: number + durationInFrames?: number + children: ReactNode +}) { + const parentFrame = useCurrentFrame() + const config = useVideoConfig() + const relativeFrame = parentFrame - from + + return ( + + {relativeFrame >= 0 ? children : null} + + ) +} + +function AbsoluteFill({ children, style }: { children: ReactNode; style?: React.CSSProperties }) { + return ( +
+ {children} +
+ ) +} + +/** + * Test bridge that mirrors MotionRemotion from the plus repo. + * 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(() => { + 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 + for (let i = prevFrame.current + 1; i <= currentFrame; i++) { + renderFrame({ frame: i, fps }) + captureStyles(i) + } + } else if (currentFrame < prevFrame.current) { + // 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 + }, [currentFrame, fps]) + + return
{children}
+} + +describe("Remotion Integration", () => { + 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())).toBeCloseTo(50, -1) + + // At 24 frames at 24fps = 1000ms = 100% + await act(async () => { + rerender() + }) + expect(Math.round(x.get())).toBeCloseTo(100, -1) + }) + }) + + 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("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) + 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)", () => { + 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() + const prevFrame = useRef(-1) + + useInsertionEffect(() => { + MotionGlobalConfig.useManualTiming = true + return () => { MotionGlobalConfig.useManualTiming = undefined } + }, []) + + useLayoutEffect(() => { + if (prevFrame.current < 0) { + renderFrame({ frame, fps }) + } else if (frame > prevFrame.current) { + for (let i = prevFrame.current + 1; i <= frame; i++) { + renderFrame({ frame: i, fps }) + } + } else if (frame < prevFrame.current) { + renderFrame({ frame, fps }) + } + prevFrame.current = frame + }, [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", + } + + const SceneBridge = ({ children }: { children: ReactNode }) => { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + const prevFrame = useRef(-1) + + useInsertionEffect(() => { + MotionGlobalConfig.useManualTiming = true + return () => { MotionGlobalConfig.useManualTiming = undefined } + }, []) + + useLayoutEffect(() => { + if (prevFrame.current < 0) { + renderFrame({ frame, fps }) + } else if (frame > prevFrame.current) { + for (let i = prevFrame.current + 1; i <= frame; i++) { + renderFrame({ frame: i, fps }) + } + } else if (frame < prevFrame.current) { + renderFrame({ frame, fps }) + } + prevFrame.current = frame + }, [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 { 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) + }) + }) + + 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/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-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index e89b768d63..7003be3636 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" @@ -233,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.useManualTiming && this.currentTime < totalDuration) { + this.state = "running" + } else { + this.currentTime = totalDuration + } } let elapsed = this.currentTime @@ -394,7 +399,8 @@ export class JSAnimation play() { if (this.isStopped) return - const { driver = frameloopDriver, startTime } = this.options + const { startTime } = this.options + const driver = this.options.driver ?? frameloopDriver if (!this.driver) { this.driver = driver((timestamp) => this.tick(timestamp)) @@ -461,7 +467,9 @@ export class JSAnimation finish() { this.notifyFinished() - this.teardown() + if (!MotionGlobalConfig.useManualTiming) { + this.teardown() + } this.state = "finished" this.options.onComplete?.() 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..4e279c8c45 --- /dev/null +++ b/packages/motion-dom/src/animation/waapi/supports/__tests__/waapi.test.ts @@ -0,0 +1,62 @@ +import { MotionGlobalConfig } from "motion-utils" +import { supportsBrowserAnimation } from "../waapi" + +describe("supportsBrowserAnimation", () => { + afterEach(() => { + MotionGlobalConfig.useManualTiming = undefined + }) + + test("returns false when useManualTiming is set", () => { + 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 useManualTiming is not set", () => { + MotionGlobalConfig.useManualTiming = undefined + + // 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 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.useManualTiming = undefined + + 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..558de41a05 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, @@ -41,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 new file mode 100644 index 0000000000..f48eae1259 --- /dev/null +++ b/packages/motion-dom/src/frameloop/__tests__/render-frame.test.ts @@ -0,0 +1,122 @@ +import { MotionGlobalConfig } from "motion-utils" +import { frame, cancelFrame } from ".." +import { renderFrame } from "../render-frame" + +describe("renderFrame", () => { + beforeEach(() => { + // Set useManualTiming to prevent rAF scheduling + MotionGlobalConfig.useManualTiming = true + }) + + afterEach(() => { + MotionGlobalConfig.useManualTiming = undefined + }) + + 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("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]) + }) +}) diff --git a/packages/motion-dom/src/frameloop/batcher.ts b/packages/motion-dom/src/frameloop/batcher.ts index 0d8c6d80de..f61a9614f8 100644 --- a/packages/motion-dom/src/frameloop/batcher.ts +++ b/packages/motion-dom/src/frameloop/batcher.ts @@ -48,7 +48,10 @@ export function createRenderBatcher( if (!MotionGlobalConfig.useManualTiming) { state.delta = useDefaultElapsed ? 1000 / 60 - : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1) + : Math.max( + Math.min(timestamp - state.timestamp, maxElapsed), + 1 + ) } state.timestamp = timestamp @@ -66,7 +69,7 @@ export function createRenderBatcher( state.isProcessing = false - if (runNextFrame && allowKeepAlive) { + if (runNextFrame && allowKeepAlive && !MotionGlobalConfig.useManualTiming) { useDefaultElapsed = false scheduleNextBatch(processBatch) } @@ -76,11 +79,39 @@ export function createRenderBatcher( runNextFrame = true useDefaultElapsed = true - if (!state.isProcessing) { + if ( + !state.isProcessing && + (!MotionGlobalConfig.useManualTiming || !allowKeepAlive) + ) { scheduleNextBatch(processBatch) } } + /** + * 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 +128,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..d06427261b --- /dev/null +++ b/packages/motion-dom/src/frameloop/render-frame.ts @@ -0,0 +1,76 @@ +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. + * + * Temporarily enables `useManualTiming` mode during frame processing + * to prevent requestAnimationFrame from auto-advancing animations. + * + * @example + * renderFrame({ timestamp: 1000 }) // Render at 1 second + * + * @example + * // Using frame number + * renderFrame({ frame: 30, fps: 30 }) // Render at frame 30 (1 second at 30fps) + */ +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 + } + + // 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 f90322a86f..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.useManualTiming + 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 ef01fca0d8..6edb7892b1 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -72,6 +72,7 @@ export * from "./frameloop/microtask" export * from "./frameloop/sync-time" export * from "./frameloop/types" + export * from "./gestures/drag/state/is-active" export * from "./gestures/drag/state/set-active" export * from "./gestures/drag/types" 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 a86c263107..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: @@ -11032,11 +11032,11 @@ __metadata: 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": "*" @@ -11153,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 @@ -12625,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 @@ -12709,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