UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

289 lines (278 loc) • 9.26 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var motionUtils = require('motion-utils'); const stepsOrder = [ "read", // Read "resolveKeyframes", // Write/Read/Write/Read "update", // Compute "preRender", // Compute "render", // Write "postRender", // Compute ]; const statsBuffer = { value: null, addProjectionMetrics: null, }; function createRenderStep(runNextFrame, stepName) { /** * We create and reuse two queues, one to queue jobs for the current frame * and one for the next. We reuse to avoid triggering GC after x frames. */ let thisFrame = new Set(); let nextFrame = new Set(); /** * Track whether we're currently processing jobs in this step. This way * we can decide whether to schedule new jobs for this frame or next. */ let isProcessing = false; let flushNextFrame = false; /** * A set of processes which were marked keepAlive when scheduled. */ const toKeepAlive = new WeakSet(); let latestFrameData = { delta: 0.0, timestamp: 0.0, isProcessing: false, }; let numCalls = 0; function triggerCallback(callback) { if (toKeepAlive.has(callback)) { step.schedule(callback); runNextFrame(); } numCalls++; callback(latestFrameData); } const step = { /** * Schedule a process to run on the next frame. */ schedule: (callback, keepAlive = false, immediate = false) => { const addToCurrentFrame = immediate && isProcessing; const queue = addToCurrentFrame ? thisFrame : nextFrame; if (keepAlive) toKeepAlive.add(callback); if (!queue.has(callback)) queue.add(callback); return callback; }, /** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { nextFrame.delete(callback); toKeepAlive.delete(callback); }, /** * Execute all schedule callbacks. */ process: (frameData) => { latestFrameData = frameData; /** * If we're already processing we've probably been triggered by a flushSync * inside an existing process. Instead of executing, mark flushNextFrame * as true and ensure we flush the following frame at the end of this one. */ if (isProcessing) { flushNextFrame = true; return; } isProcessing = true; [thisFrame, nextFrame] = [nextFrame, thisFrame]; // Execute this frame thisFrame.forEach(triggerCallback); /** * If we're recording stats then */ if (stepName && statsBuffer.value) { statsBuffer.value.frameloop[stepName].push(numCalls); } numCalls = 0; // Clear the frame so no callbacks remain. This is to avoid // memory leaks should this render step not run for a while. thisFrame.clear(); isProcessing = false; if (flushNextFrame) { flushNextFrame = false; step.process(frameData); } }, }; return step; } const maxElapsed = 40; function createRenderBatcher(scheduleNextBatch, allowKeepAlive) { let runNextFrame = false; let useDefaultElapsed = true; const state = { delta: 0.0, timestamp: 0.0, isProcessing: false, }; const flagRunNextFrame = () => (runNextFrame = true); const steps = stepsOrder.reduce((acc, key) => { acc[key] = createRenderStep(flagRunNextFrame, allowKeepAlive ? key : undefined); return acc; }, {}); const { read, resolveKeyframes, update, preRender, render, postRender } = steps; const processBatch = () => { const timestamp = performance.now(); runNextFrame = false; { state.delta = useDefaultElapsed ? 1000 / 60 : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1); } state.timestamp = timestamp; state.isProcessing = true; // Unrolled render loop for better per-frame performance read.process(state); resolveKeyframes.process(state); update.process(state); preRender.process(state); render.process(state); postRender.process(state); state.isProcessing = false; if (runNextFrame && allowKeepAlive) { useDefaultElapsed = false; scheduleNextBatch(processBatch); } }; const wake = () => { runNextFrame = true; useDefaultElapsed = true; if (!state.isProcessing) { scheduleNextBatch(processBatch); } }; const schedule = stepsOrder.reduce((acc, key) => { const step = steps[key]; acc[key] = (process, keepAlive = false, immediate = false) => { if (!runNextFrame) wake(); return step.schedule(process, keepAlive, immediate); }; return acc; }, {}); const cancel = (process) => { for (let i = 0; i < stepsOrder.length; i++) { steps[stepsOrder[i]].cancel(process); } }; return { schedule, cancel, state, steps }; } const { schedule: frame, cancel: cancelFrame, state: frameData, steps: frameSteps, } = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : motionUtils.noop, true); const activeAnimations = { layout: 0, mainThread: 0, waapi: 0, }; function record() { const { value } = statsBuffer; if (value === null) { cancelFrame(record); return; } value.frameloop.rate.push(frameData.delta); value.animations.mainThread.push(activeAnimations.mainThread); value.animations.waapi.push(activeAnimations.waapi); value.animations.layout.push(activeAnimations.layout); } function mean(values) { return values.reduce((acc, value) => acc + value, 0) / values.length; } function summarise(values, calcAverage = mean) { if (values.length === 0) { return { min: 0, max: 0, avg: 0, }; } return { min: Math.min(...values), max: Math.max(...values), avg: calcAverage(values), }; } const msToFps = (ms) => Math.round(1000 / ms); function clearStatsBuffer() { statsBuffer.value = null; statsBuffer.addProjectionMetrics = null; } function reportStats() { const { value } = statsBuffer; if (!value) { throw new Error("Stats are not being measured"); } clearStatsBuffer(); cancelFrame(record); const summary = { frameloop: { rate: summarise(value.frameloop.rate), read: summarise(value.frameloop.read), resolveKeyframes: summarise(value.frameloop.resolveKeyframes), update: summarise(value.frameloop.update), preRender: summarise(value.frameloop.preRender), render: summarise(value.frameloop.render), postRender: summarise(value.frameloop.postRender), }, animations: { mainThread: summarise(value.animations.mainThread), waapi: summarise(value.animations.waapi), layout: summarise(value.animations.layout), }, layoutProjection: { nodes: summarise(value.layoutProjection.nodes), calculatedTargetDeltas: summarise(value.layoutProjection.calculatedTargetDeltas), calculatedProjections: summarise(value.layoutProjection.calculatedProjections), }, }; /** * Convert the rate to FPS */ const { rate } = summary.frameloop; rate.min = msToFps(rate.min); rate.max = msToFps(rate.max); rate.avg = msToFps(rate.avg); [rate.min, rate.max] = [rate.max, rate.min]; return summary; } function recordStats() { if (statsBuffer.value) { clearStatsBuffer(); throw new Error("Stats are already being measured"); } const newStatsBuffer = statsBuffer; newStatsBuffer.value = { frameloop: { rate: [], read: [], resolveKeyframes: [], update: [], preRender: [], render: [], postRender: [], }, animations: { mainThread: [], waapi: [], layout: [], }, layoutProjection: { nodes: [], calculatedTargetDeltas: [], calculatedProjections: [], }, }; newStatsBuffer.addProjectionMetrics = (metrics) => { const { layoutProjection } = newStatsBuffer.value; layoutProjection.nodes.push(metrics.nodes); layoutProjection.calculatedTargetDeltas.push(metrics.calculatedTargetDeltas); layoutProjection.calculatedProjections.push(metrics.calculatedProjections); }; frame.postRender(record, true); return reportStats; } exports.recordStats = recordStats;