feat: replace Remotion with custom Bradly Engine (Phases 1-3)
- Phase 1: Custom animation engine (interpolate, spring, Easing) - Phase 2: Custom composition components (AbsoluteFill, Sequence, Img, Video, Audio) - Phase 3: BradlyPlayer with rAF frame loop, imperative API, controls Migrated 24 files from remotion/@remotion/player imports to src/engine/. All type errors from migration resolved. Pre-existing errors remain unchanged.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* BradlyPlayer — Drop-in replacement for Remotion's <Player>.
|
||||
*
|
||||
* Frame-based composition renderer with:
|
||||
* - requestAnimationFrame loop for smooth playback
|
||||
* - Imperative API via ref (play, pause, seekTo, getCurrentFrame)
|
||||
* - Event system (frameupdate, play, pause, ended)
|
||||
* - Optional built-in controls
|
||||
* - Loop and autoPlay support
|
||||
*
|
||||
* Usage:
|
||||
* <BradlyPlayer
|
||||
* ref={playerRef}
|
||||
* component={MyComposition}
|
||||
* inputProps={{ ... }}
|
||||
* durationInFrames={150}
|
||||
* compositionWidth={1080}
|
||||
* compositionHeight={1920}
|
||||
* fps={30}
|
||||
* controls
|
||||
* />
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { PlayerStateContext, VideoConfigContext, SequenceFrameContext } from './PlayerContext';
|
||||
import type { BradlyPlayerRef, VideoConfig } from './types';
|
||||
import { PlayerControls } from './PlayerControls';
|
||||
|
||||
interface BradlyPlayerProps<T> {
|
||||
/** The React component to render as the composition */
|
||||
component: React.ComponentType<T>;
|
||||
/** Props to pass to the composition component */
|
||||
inputProps: T;
|
||||
/** Total duration of the composition in frames */
|
||||
durationInFrames: number;
|
||||
/** Width of the composition in pixels */
|
||||
compositionWidth: number;
|
||||
/** Height of the composition in pixels */
|
||||
compositionHeight: number;
|
||||
/** Frames per second */
|
||||
fps: number;
|
||||
/** Show built-in playback controls */
|
||||
controls?: boolean;
|
||||
/** Loop playback */
|
||||
loop?: boolean;
|
||||
/** Auto-start playback */
|
||||
autoPlay?: boolean;
|
||||
/** Allow clicking the player to toggle play/pause */
|
||||
clickToPlay?: boolean;
|
||||
/** CSS styles for the outer container */
|
||||
style?: React.CSSProperties;
|
||||
/** CSS class for the outer container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function BradlyPlayerInner<T>(
|
||||
props: BradlyPlayerProps<T>,
|
||||
ref: React.ForwardedRef<BradlyPlayerRef>
|
||||
) {
|
||||
const {
|
||||
component: Component,
|
||||
inputProps,
|
||||
durationInFrames,
|
||||
compositionWidth,
|
||||
compositionHeight,
|
||||
fps,
|
||||
controls = false,
|
||||
loop = false,
|
||||
autoPlay = false,
|
||||
clickToPlay = true,
|
||||
style,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [playing, setPlaying] = useState(autoPlay);
|
||||
const frameRef = useRef(0);
|
||||
const playingRef = useRef(autoPlay);
|
||||
const rafRef = useRef<number>(0);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const listenersRef = useRef<Map<string, Set<(e: any) => void>>>(new Map());
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
frameRef.current = frame;
|
||||
}, [frame]);
|
||||
|
||||
useEffect(() => {
|
||||
playingRef.current = playing;
|
||||
}, [playing]);
|
||||
|
||||
// ── Event System ──
|
||||
|
||||
const emit = useCallback((event: string, detail: any) => {
|
||||
const handlers = listenersRef.current.get(event);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler({ detail });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Frame Loop ──
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
|
||||
lastTimeRef.current = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - lastTimeRef.current;
|
||||
const frameDuration = 1000 / fps;
|
||||
|
||||
if (elapsed >= frameDuration) {
|
||||
const framesToAdvance = Math.floor(elapsed / frameDuration);
|
||||
lastTimeRef.current = now - (elapsed % frameDuration);
|
||||
|
||||
setFrame((prev) => {
|
||||
let next = prev + framesToAdvance;
|
||||
|
||||
if (next >= durationInFrames) {
|
||||
if (loop) {
|
||||
next = next % durationInFrames;
|
||||
} else {
|
||||
next = durationInFrames - 1;
|
||||
// Stop playback
|
||||
setPlaying(false);
|
||||
emit('ended', { frame: next });
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
emit('frameupdate', { frame: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [playing, fps, durationInFrames, loop, emit]);
|
||||
|
||||
// ── Imperative API ──
|
||||
|
||||
const play = useCallback(() => {
|
||||
setPlaying(true);
|
||||
emit('play', { frame: frameRef.current });
|
||||
}, [emit]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setPlaying(false);
|
||||
emit('pause', { frame: frameRef.current });
|
||||
}, [emit]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (playingRef.current) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [play, pause]);
|
||||
|
||||
const seekTo = useCallback((targetFrame: number) => {
|
||||
const clamped = Math.max(0, Math.min(durationInFrames - 1, Math.round(targetFrame)));
|
||||
setFrame(clamped);
|
||||
frameRef.current = clamped;
|
||||
emit('frameupdate', { frame: clamped });
|
||||
}, [durationInFrames, emit]);
|
||||
|
||||
const getCurrentFrame = useCallback(() => frameRef.current, []);
|
||||
const isPlaying = useCallback(() => playingRef.current, []);
|
||||
const getDuration = useCallback(() => durationInFrames, [durationInFrames]);
|
||||
|
||||
const addEventListener = useCallback(
|
||||
(event: string, handler: (e: any) => void) => {
|
||||
if (!listenersRef.current.has(event)) {
|
||||
listenersRef.current.set(event, new Set());
|
||||
}
|
||||
listenersRef.current.get(event)!.add(handler);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeEventListener = useCallback(
|
||||
(event: string, handler: (e: any) => void) => {
|
||||
listenersRef.current.get(event)?.delete(handler);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seekTo,
|
||||
getCurrentFrame,
|
||||
isPlaying,
|
||||
getDuration,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
}), [play, pause, toggle, seekTo, getCurrentFrame, isPlaying, getDuration, addEventListener, removeEventListener]);
|
||||
|
||||
// ── Context Values ──
|
||||
|
||||
const videoConfig: VideoConfig = useMemo(() => ({
|
||||
fps,
|
||||
width: compositionWidth,
|
||||
height: compositionHeight,
|
||||
durationInFrames,
|
||||
}), [fps, compositionWidth, compositionHeight, durationInFrames]);
|
||||
|
||||
const playerState = useMemo(() => ({
|
||||
frame,
|
||||
playing,
|
||||
}), [frame, playing]);
|
||||
|
||||
// ── Click to play/pause ──
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (clickToPlay) {
|
||||
toggle();
|
||||
}
|
||||
}, [clickToPlay, toggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
...style,
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{/* Composition viewport — maintains aspect ratio */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: controls ? 'calc(100% - 32px)' : '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Inner composition — scaled to fit */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: compositionWidth,
|
||||
height: compositionHeight,
|
||||
transformOrigin: 'top left',
|
||||
// Scale composition to fit the container
|
||||
// We use CSS containment for performance
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && el.parentElement) {
|
||||
const parent = el.parentElement;
|
||||
const scaleX = parent.clientWidth / compositionWidth;
|
||||
const scaleY = parent.clientHeight / compositionHeight;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
el.style.transform = `scale(${scale})`;
|
||||
el.style.left = `${(parent.clientWidth - compositionWidth * scale) / 2}px`;
|
||||
el.style.top = `${(parent.clientHeight - compositionHeight * scale) / 2}px`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VideoConfigContext.Provider value={videoConfig}>
|
||||
<PlayerStateContext.Provider value={playerState}>
|
||||
<SequenceFrameContext.Provider value={null}>
|
||||
<Component {...inputProps} />
|
||||
</SequenceFrameContext.Provider>
|
||||
</PlayerStateContext.Provider>
|
||||
</VideoConfigContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{controls && (
|
||||
<PlayerControls
|
||||
frame={frame}
|
||||
durationInFrames={durationInFrames}
|
||||
fps={fps}
|
||||
playing={playing}
|
||||
onPlay={play}
|
||||
onPause={pause}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// forwardRef with generic support
|
||||
export const BradlyPlayer = forwardRef(BradlyPlayerInner) as <T>(
|
||||
props: BradlyPlayerProps<T> & { ref?: React.ForwardedRef<BradlyPlayerRef>; key?: React.Key }
|
||||
) => React.ReactElement | null;
|
||||
Reference in New Issue
Block a user