Last active
November 21, 2022 20:33
-
-
Save marcusstenbeck/0731354da1b8894839a387b00cb021eb to your computer and use it in GitHub Desktop.
Remotion + Popmotion
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as popmotion from 'popmotion'; | |
import { pop } from './RemotionPop'; | |
const easeOutQuint = popmotion.cubicBezier(0.22, 1, 0.36, 1); | |
const ExampleComponent = () => { | |
// you can animate any normal HTML element, ex. `pop.h2` or `pop.span` | |
return ( | |
<pop.div | |
// animationOptions is optional, but usually you would set this | |
animationOptions={{ | |
elapsed: -500, | |
duration: 1000, | |
ease: popmotion.linear, | |
}} | |
style={{ | |
// shorthand, will animate using the animationOptions/defaults | |
opacity: [0, 1], | |
// override animationOptions/defaults | |
// here setting a new easing function for this prop | |
transform: { | |
value: ['translateY(20px) scale(0.5)', 'translateY(0px) scale(1)'], | |
ease: easeOutQuint | |
}, | |
// regular CSS property | |
backgroundColor: 'salmon' | |
}} | |
> | |
Remotion + Popmotion | |
</pop.div> | |
); | |
}; | |
export default ExampleComponent; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as popmotion from 'popmotion'; | |
import { Animation, AnimationOptions, KeyframeOptions } from 'popmotion'; | |
import React, { | |
CSSProperties, | |
DetailedHTMLFactory, | |
ForwardRefExoticComponent, | |
HTMLAttributes, | |
PropsWithoutRef, | |
ReactHTML, | |
RefAttributes, | |
useMemo, | |
} from 'react'; | |
import { useSeconds } from './useMakeDriver'; | |
function useSeconds() { | |
const config = useVideoConfig(); | |
const currentFrame = useCurrentFrame(); | |
return currentFrame / config.fps; | |
} | |
function playTime( | |
time: number, | |
options: Omit<PopKeyframeOptions<number>, 'from' | 'to'> | |
) { | |
const elapsed = options?.elapsed ?? 0; | |
const duration = options?.duration ?? 1000; | |
const direction = options?.repeatType ?? 'loop'; | |
const loops = options?.repeat ?? 1; | |
const offsetTime = Math.max(0, time + elapsed); | |
let adjustedTime = offsetTime; | |
const currentCycle = Math.floor(offsetTime / duration); | |
const hasFinishedLooping = currentCycle >= loops; | |
// only keep the cycle that hasn't finished | |
adjustedTime = adjustedTime % duration; | |
const isOddCycle = currentCycle % 2 !== 0; | |
if ((direction === 'mirror' || direction === 'reverse') && isOddCycle) { | |
adjustedTime = duration - adjustedTime; | |
} | |
if (hasFinishedLooping) { | |
const isFinalCycleOdd = (loops - 1) % 2 !== 0; | |
const didFinishReversed = | |
(direction === 'mirror' || direction === 'reverse') && isFinalCycleOdd; | |
adjustedTime = didFinishReversed ? 0 : duration; | |
} | |
return adjustedTime; | |
} | |
function createPopComponent<Props extends PopProps, Instance>({ Component }) { | |
const PopComponent = (props: Props, externalRef?: React.Ref<Instance>) => { | |
const seconds = useSeconds(); | |
const { style, animationOptions, ...htmlProps } = props; | |
const animations: Record< | |
string, | |
{ | |
options: PopKeyframeOptions; | |
animation: Animation<unknown>; | |
} | |
> = useMemo(() => { | |
if (!style || typeof style === 'string') return {}; | |
return Object.entries(style).reduce((acc, [key, value]) => { | |
if (!value || typeof value === 'string' || typeof value === 'number') { | |
return acc; | |
} | |
let options: Record<string, unknown> = {}; | |
if (Array.isArray(value)) { | |
options.to = value; | |
options.duration = animationOptions.duration; | |
options.ease = animationOptions.ease; | |
} else { | |
options = { ...value }; | |
options.duration = value.duration ?? animationOptions.duration; | |
options.ease = value.ease ?? animationOptions.ease; | |
} | |
return { | |
...acc, | |
[key]: { | |
options, | |
animation: popmotion.keyframes(options), | |
}, | |
}; | |
}, {}); | |
}, [animationOptions.duration, animationOptions.ease, style]); | |
const interpolatedStyle: CSSProperties = useMemo(() => { | |
const time = seconds * 1000; | |
const elementTime = playTime(time, animationOptions); | |
return Object.entries(animations).reduce((acc, [key, v]) => { | |
const duration = v?.options?.duration ?? animationOptions?.duration; | |
const ease = v?.options?.ease ?? animationOptions?.ease; | |
const propertyTime = playTime(elementTime, { | |
...v.options, | |
duration, | |
ease, | |
}); | |
return { ...acc, [key]: v.animation.next(propertyTime).value }; | |
}, {}); | |
}, [animationOptions, animations, seconds]); | |
const finalStyle = useMemo( | |
() => ({ ...style, ...interpolatedStyle }), | |
[interpolatedStyle, style] | |
) as CSSProperties; | |
return <Component ref={externalRef} {...htmlProps} style={finalStyle} />; | |
}; | |
return React.forwardRef(PopComponent); | |
} | |
type UnionStringArray<T extends Readonly<string[]>> = T[number]; | |
const htmlElements = [ | |
'a', | |
'abbr', | |
'address', | |
'area', | |
'article', | |
'aside', | |
'audio', | |
'b', | |
'base', | |
'bdi', | |
'bdo', | |
'big', | |
'blockquote', | |
'body', | |
'br', | |
'button', | |
'canvas', | |
'caption', | |
'cite', | |
'code', | |
'col', | |
'colgroup', | |
'data', | |
'datalist', | |
'dd', | |
'del', | |
'details', | |
'dfn', | |
'dialog', | |
'div', | |
'dl', | |
'dt', | |
'em', | |
'embed', | |
'fieldset', | |
'figcaption', | |
'figure', | |
'footer', | |
'form', | |
'h1', | |
'h2', | |
'h3', | |
'h4', | |
'h5', | |
'h6', | |
'head', | |
'header', | |
'hgroup', | |
'hr', | |
'html', | |
'i', | |
'iframe', | |
'img', | |
'input', | |
'ins', | |
'kbd', | |
'keygen', | |
'label', | |
'legend', | |
'li', | |
'link', | |
'main', | |
'map', | |
'mark', | |
'menu', | |
'menuitem', | |
'meta', | |
'meter', | |
'nav', | |
'noscript', | |
'object', | |
'ol', | |
'optgroup', | |
'option', | |
'output', | |
'p', | |
'param', | |
'picture', | |
'pre', | |
'progress', | |
'q', | |
'rp', | |
'rt', | |
'ruby', | |
's', | |
'samp', | |
'script', | |
'section', | |
'select', | |
'small', | |
'source', | |
'span', | |
'strong', | |
'style', | |
'sub', | |
'summary', | |
'sup', | |
'table', | |
'tbody', | |
'td', | |
'textarea', | |
'tfoot', | |
'th', | |
'thead', | |
'time', | |
'title', | |
'tr', | |
'track', | |
'u', | |
'ul', | |
'var', | |
'video', | |
'wbr', | |
'webview', | |
] as const; | |
type HTMLElements = UnionStringArray<typeof htmlElements>; | |
/** | |
* Support for React component props | |
*/ | |
type UnwrapFactoryAttributes<F> = F extends DetailedHTMLFactory<infer P, any> | |
? P | |
: never; | |
type UnwrapFactoryElement<F> = F extends DetailedHTMLFactory<any, infer P> | |
? P | |
: never; | |
export type ForwardRefComponent<T, P> = ForwardRefExoticComponent< | |
PropsWithoutRef<P> & RefAttributes<T> | |
>; | |
type PopKeyframeOptions<V = number> = KeyframeOptions<V> & { | |
// from?: AnimationOptions<V>['from']; | |
elapsed?: AnimationOptions<V>['elapsed']; | |
repeat?: AnimationOptions<V>['repeat']; | |
repeatDelay?: AnimationOptions<V>['repeatDelay']; | |
repeatType?: AnimationOptions<V>['repeatType']; | |
// right now only keyframes is supported | |
// type?: AnimationOptions<V>['type'] | |
}; | |
type PopCSSProperties = { | |
[K in keyof CSSProperties]: | |
| CSSProperties[K] | |
| CSSProperties[K][] | |
| PopKeyframeOptions<CSSProperties[K]>; | |
}; | |
type PopProps = { | |
animationOptions?: Omit<PopKeyframeOptions, 'from' | 'to'>; | |
style?: PopCSSProperties; | |
}; | |
type HTMLAttributesWithoutPopProps< | |
Attributes extends HTMLAttributes<Element>, | |
Element extends HTMLElement | |
> = { [K in Exclude<keyof Attributes, keyof PopProps>]?: Attributes[K] }; | |
type HTMLPopProps<TagName extends keyof ReactHTML> = | |
HTMLAttributesWithoutPopProps< | |
UnwrapFactoryAttributes<ReactHTML[TagName]>, | |
UnwrapFactoryElement<ReactHTML[TagName]> | |
> & | |
PopProps; | |
type HTMLPopComponents = { | |
[K in HTMLElements]: ForwardRefComponent< | |
UnwrapFactoryElement<ReactHTML[K]>, | |
HTMLPopProps<K> | |
>; | |
}; | |
type CustomDomComponent<Props> = React.ForwardRefExoticComponent< | |
React.PropsWithoutRef<Props> & React.RefAttributes<SVGElement | HTMLElement> | |
>; | |
function createPopProxy() { | |
function custom<Props>( | |
Component: string | React.ComponentType<Props> | |
): CustomDomComponent<Props> { | |
return createPopComponent<Props, HTMLElement | SVGElement>({ Component }); | |
} | |
/** | |
* A cache of generated `pop` components, e.g `pop.div`, `pop.input` etc. | |
* Rather than generating them anew every render. | |
*/ | |
const componentCache = new Map<string, any>(); | |
return new Proxy(custom, { | |
/** | |
* Called when `pop` is referenced with a prop: `pop.div`, `pop.input` etc. | |
* The prop name is passed through as `key` and we can use that to generate a `pop` | |
* DOM component with that name. | |
*/ | |
get: (_target, key: string) => { | |
/** | |
* If this element doesn't exist in the component cache, create it and cache. | |
*/ | |
if (!componentCache.has(key)) { | |
componentCache.set(key, custom(key)); | |
} | |
return componentCache.get(key)!; | |
}, | |
}) as typeof custom & HTMLPopComponents; | |
} | |
export const pop = createPopProxy(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment