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:
2026-06-02 05:20:43 -05:00
parent 0aa44afa43
commit ff07d8c492
39 changed files with 1451 additions and 55 deletions
+310
View File
@@ -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;