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
+2 -1
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { AbsoluteFill, useCurrentFrame } from 'remotion'; import { AbsoluteFill } from '../engine/components';
import { useCurrentFrame } from '../engine/player';
import { RenderProps } from '../types'; import { RenderProps } from '../types';
import { useCanvasDrag } from './composition/useCanvasDrag'; import { useCanvasDrag } from './composition/useCanvasDrag';
import { BackgroundLayer } from './composition/BackgroundLayer'; import { BackgroundLayer } from './composition/BackgroundLayer';
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { RefObject } from 'react'; import React, { RefObject } from 'react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../engine/player';
import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react'; import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react';
import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types'; import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
import { AudioLayerPanel } from './properties/AudioLayerPanel'; import { AudioLayerPanel } from './properties/AudioLayerPanel';
@@ -21,7 +21,7 @@ interface StudioPropertiesProps {
textOverlay: string; textOverlay: string;
setTextOverlay: (text: string) => void; setTextOverlay: (text: string) => void;
activeLayerId: string; activeLayerId: string;
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media'; activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media';
designMD: DesignMD; designMD: DesignMD;
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, RefObject } from 'react'; import React, { useState, useEffect, useRef, RefObject } from 'react';
import { Layers, GripVertical } from 'lucide-react'; import { Layers, GripVertical } from 'lucide-react';
import { TimelineElement, TimelineLayer, DesignMD } from '../types'; import { TimelineElement, TimelineLayer, DesignMD } from '../types';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../engine/player';
import { DragState, getTrackBgClass } from './timeline/timelineUtils'; import { DragState, getTrackBgClass } from './timeline/timelineUtils';
import { TimelineControls } from './timeline/TimelineControls'; import { TimelineControls } from './timeline/TimelineControls';
import { TimelineRuler } from './timeline/TimelineRuler'; import { TimelineRuler } from './timeline/TimelineRuler';
@@ -27,7 +27,7 @@ interface StudioTimelineProps {
setActiveLayerId: (id: string) => void; setActiveLayerId: (id: string) => void;
selectedElementId: string | null; selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void; setSelectedElementId: (id: string | null) => void;
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions'; activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
designMD?: DesignMD; designMD?: DesignMD;
+3 -3
View File
@@ -1,5 +1,5 @@
import React, { RefObject, useState, useCallback, useEffect } from 'react'; import React, { RefObject, useState, useCallback, useEffect } from 'react';
import { Player, PlayerRef } from '@remotion/player'; import { BradlyPlayer, BradlyPlayerRef } from '../engine/player';
import { BrandComposition } from './BrandComposition'; import { BrandComposition } from './BrandComposition';
import { RenderProps, TimelineElement } from '../types'; import { RenderProps, TimelineElement } from '../types';
import { PlaySquare } from 'lucide-react'; import { PlaySquare } from 'lucide-react';
@@ -13,7 +13,7 @@ interface StudioWorkspaceProps {
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions'; activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
setSelectedElementId: (id: string | null) => void; setSelectedElementId: (id: string | null) => void;
selectedElementId?: string | null; selectedElementId?: string | null;
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
compositionProps: RenderProps; compositionProps: RenderProps;
durationInFrames: number; durationInFrames: number;
timelineElements?: TimelineElement[]; timelineElements?: TimelineElement[];
@@ -560,7 +560,7 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
</> </>
) : undefined} ) : undefined}
> >
<Player <BradlyPlayer
ref={playerRef} ref={playerRef}
component={BrandComposition} component={BrandComposition}
inputProps={{ inputProps={{
@@ -1,14 +1,8 @@
import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react'; import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { Player } from '@remotion/player'; import { BradlyPlayer } from '../../../engine/player';
import { import { AbsoluteFill, Sequence, Video } from '../../../engine/components';
AbsoluteFill, import { useCurrentFrame, useVideoConfig } from '../../../engine/player';
Sequence, import { interpolate, spring } from '../../../engine/animation';
Video,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from 'remotion';
import { DesignMD, CompanyProfile } from '../../../types'; import { DesignMD, CompanyProfile } from '../../../types';
import { CanvasWorkspace } from '../../ui/CanvasWorkspace'; import { CanvasWorkspace } from '../../ui/CanvasWorkspace';
@@ -329,7 +323,7 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
</> </>
) : undefined} ) : undefined}
> >
<Player <BradlyPlayer
ref={playerRef} ref={playerRef}
component={SampleComposition} component={SampleComposition}
inputProps={inputProps} inputProps={inputProps}
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Sequence, AbsoluteFill, Img, Video } from 'remotion'; import { Sequence, AbsoluteFill, Img, Video } from '../../engine/components';
import { TimelineElement, TimelineLayer, MediaFilter } from '../../types'; import { TimelineElement, TimelineLayer, MediaFilter } from '../../types';
const getFilterStyle = (filter?: MediaFilter): React.CSSProperties => { const getFilterStyle = (filter?: MediaFilter): React.CSSProperties => {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { AbsoluteFill } from 'remotion'; import { AbsoluteFill } from '../../engine/components';
import { DesignMD } from '../../types'; import { DesignMD } from '../../types';
interface BrandOverlayProps { interface BrandOverlayProps {
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react'; import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import { useCurrentFrame, useVideoConfig } from 'remotion'; import { useCurrentFrame, useVideoConfig } from '../../engine/player';
import { import {
applyChromaKey, applyChromaKey,
hexToRgb, hexToRgb,
@@ -1,5 +1,6 @@
import React, { RefObject, useEffect } from 'react'; import React, { RefObject, useEffect } from 'react';
import { Sequence, AbsoluteFill, Img, Video, Audio, interpolate } from 'remotion'; import { Sequence, AbsoluteFill, Img, Video, Audio } from '../../engine/components';
import { interpolate } from '../../engine/animation';
import { TimelineElement, TimelineLayer, DesignMD } from '../../types'; import { TimelineElement, TimelineLayer, DesignMD } from '../../types';
import { calculateElementTransitions } from './useTransitions'; import { calculateElementTransitions } from './useTransitions';
import { resolveKeyframes } from './keyframeEngine'; import { resolveKeyframes } from './keyframeEngine';
+1 -1
View File
@@ -1,4 +1,4 @@
import { interpolate, Easing } from 'remotion'; import { interpolate, Easing } from '../../engine/animation';
import { AnimationKeyframe, EasingType } from '../../types'; import { AnimationKeyframe, EasingType } from '../../types';
interface KeyframeDefaults { interface KeyframeDefaults {
+1 -1
View File
@@ -1,4 +1,4 @@
import { interpolate, spring } from 'remotion'; import { interpolate, spring } from '../../engine/animation';
import { TimelineElement } from '../../types'; import { TimelineElement } from '../../types';
import { resolveKeyframes } from './keyframeEngine'; import { resolveKeyframes } from './keyframeEngine';
@@ -10,7 +10,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react';
import { import {
X, ChevronLeft, ChevronRight, AlertTriangle, Eye, X, ChevronLeft, ChevronRight, AlertTriangle, Eye,
} from 'lucide-react'; } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player'; import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player';
import { BrandComposition } from '../BrandComposition'; import { BrandComposition } from '../BrandComposition';
import { import {
compileExpressToTimeline, getAspectDimensions, getTemplateDuration, compileExpressToTimeline, getAspectDimensions, getTemplateDuration,
@@ -132,7 +132,7 @@ const PieceThumbnail: React.FC<{
> >
{/* Render the piece */} {/* Render the piece */}
{!isVideo ? ( {!isVideo ? (
<Player <BradlyPlayer
key={playerKey} key={playerKey}
component={BrandComposition} component={BrandComposition}
inputProps={inputProps} inputProps={inputProps}
@@ -217,7 +217,7 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
onActivePieceChange, onActivePieceChange,
}) => { }) => {
const [carouselIndex, setCarouselIndex] = useState<number | null>(null); const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
const carouselPlayerRef = useRef<PlayerRef>(null); const carouselBradlyPlayerRef = useRef<BradlyPlayerRef>(null);
const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]); const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]);
const totalDuration = useMemo(() => getTemplateDuration(template), [template]); const totalDuration = useMemo(() => getTemplateDuration(template), [template]);
@@ -364,7 +364,7 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
maxHeight: 'calc(100% - 120px)', maxHeight: 'calc(100% - 120px)',
}} }}
> >
<Player <BradlyPlayer
key={videoPlayerKey} key={videoPlayerKey}
component={BrandComposition} component={BrandComposition}
inputProps={videoInputProps} inputProps={videoInputProps}
@@ -499,9 +499,9 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
maxHeight: 'calc(100vh - 100px)', maxHeight: 'calc(100vh - 100px)',
}} }}
> >
<Player <BradlyPlayer
key={`carousel-${carouselIndex}`} key={`carousel-${carouselIndex}`}
ref={carouselPlayerRef} ref={carouselBradlyPlayerRef}
component={BrandComposition} component={BrandComposition}
inputProps={carouselInputProps} inputProps={carouselInputProps}
durationInFrames={totalFrames} durationInFrames={totalFrames}
+2 -2
View File
@@ -3,7 +3,7 @@ import {
ArrowLeft, Sparkles, Zap, Play, ChevronRight, ChevronLeft, FileText, Download, Film, ArrowLeft, Sparkles, Zap, Play, ChevronRight, ChevronLeft, FileText, Download, Film,
Layers, Package, Layers, Package,
} from 'lucide-react'; } from 'lucide-react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
import { import {
ExpressTemplate, CompanyProfile, DesignMD, ExpressTemplate, CompanyProfile, DesignMD,
TemplateField, BrandSource, TemplateField, BrandSource,
@@ -95,7 +95,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0); const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]); const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const playerRef = useRef<PlayerRef>(null); const playerRef = useRef<BradlyPlayerRef>(null);
const designMD = brand.design; const designMD = brand.design;
const fps = 30; const fps = 30;
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useCallback, useRef } from 'react'; import React, { useState, useMemo, useCallback, useRef } from 'react';
import { ArrowLeft, Zap, Wrench, Download, ChevronRight, Play, Pause, RotateCcw } from 'lucide-react'; import { ArrowLeft, Zap, Wrench, Download, ChevronRight, Play, Pause, RotateCcw } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player'; import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player';
import { ExpressTemplate, DesignMD, TimelineElement, TimelineLayer, CompanyProfile } from '../../types'; import { ExpressTemplate, DesignMD, TimelineElement, TimelineLayer, CompanyProfile } from '../../types';
import { BrandComposition } from '../BrandComposition'; import { BrandComposition } from '../BrandComposition';
import { ExpressTemplateGallery } from './ExpressTemplateGallery'; import { ExpressTemplateGallery } from './ExpressTemplateGallery';
@@ -42,7 +42,7 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
const [overlayOpacity, setOverlayOpacity] = useState(0); const [overlayOpacity, setOverlayOpacity] = useState(0);
const playerRef = useRef<PlayerRef>(null); const playerRef = useRef<BradlyPlayerRef>(null);
const handleSelectTemplate = useCallback((template: ExpressTemplate) => { const handleSelectTemplate = useCallback((template: ExpressTemplate) => {
setSelectedTemplate(template); setSelectedTemplate(template);
@@ -208,7 +208,7 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
maxHeight: 'calc(100% - 80px)', maxHeight: 'calc(100% - 80px)',
}} }}
> >
<Player <BradlyPlayer
ref={playerRef} ref={playerRef}
component={BrandComposition} component={BrandComposition}
inputProps={{ inputProps={{
+2 -2
View File
@@ -1,12 +1,12 @@
import React, { useRef, useState, useCallback, RefObject } from 'react'; import React, { useRef, useState, useCallback, RefObject } from 'react';
import { Play, Pause, RotateCcw, Volume2 } from 'lucide-react'; import { Play, Pause, RotateCcw, Volume2 } from 'lucide-react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
import { TimelineElement } from '../../types'; import { TimelineElement } from '../../types';
import { TimelineRuler } from '../timeline/TimelineRuler'; import { TimelineRuler } from '../timeline/TimelineRuler';
import { TimelinePlayhead } from '../timeline/TimelinePlayhead'; import { TimelinePlayhead } from '../timeline/TimelinePlayhead';
interface ExpressTimelineProps { interface ExpressTimelineProps {
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
elements: TimelineElement[]; elements: TimelineElement[];
durationInFrames: number; durationInFrames: number;
selectedSlotId: string | null; selectedSlotId: string | null;
@@ -1,7 +1,7 @@
import React, { RefObject } from 'react'; import React, { RefObject } from 'react';
import { Music } from 'lucide-react'; import { Music } from 'lucide-react';
import { TimelineElement } from '../../types'; import { TimelineElement } from '../../types';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
import { uploadMedia } from '../../utils/mediaUploader'; import { uploadMedia } from '../../utils/mediaUploader';
import { FileDropZone } from '../ui/FileDropZone'; import { FileDropZone } from '../ui/FileDropZone';
import { getAudioDuration, durationToFrames } from '../../utils/audioMetadata'; import { getAudioDuration, durationToFrames } from '../../utils/audioMetadata';
@@ -10,7 +10,7 @@ interface AudioLayerPanelProps {
activeLayerId: string; activeLayerId: string;
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>; setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
timelineElements: TimelineElement[]; timelineElements: TimelineElement[];
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
endFrameLimit?: number; endFrameLimit?: number;
} }
@@ -1,7 +1,7 @@
import React, { useCallback, RefObject } from 'react'; import React, { useCallback, RefObject } from 'react';
import { Film, Play, Camera, Download, Grid3x3, Palette, Maximize } from 'lucide-react'; import { Film, Play, Camera, Download, Grid3x3, Palette, Maximize } from 'lucide-react';
import { CollapsibleSection } from '../ui/CollapsibleSection'; import { CollapsibleSection } from '../ui/CollapsibleSection';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
import { EXPORT_PRESETS } from '../../config/constants'; import { EXPORT_PRESETS } from '../../config/constants';
import { TimelineElement, TimelineLayer } from '../../types'; import { TimelineElement, TimelineLayer } from '../../types';
import { ProjectStats } from '../ui/ProjectStats'; import { ProjectStats } from '../ui/ProjectStats';
@@ -11,7 +11,7 @@ import { BulkActionsBar } from '../ui/BulkActionsBar';
interface GlobalSettingsPanelProps { interface GlobalSettingsPanelProps {
textOverlay: string; textOverlay: string;
setTextOverlay: (text: string) => void; setTextOverlay: (text: string) => void;
playerRef?: RefObject<PlayerRef | null>; playerRef?: RefObject<BradlyPlayerRef | null>;
aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3'; aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
onExportClick?: () => void; onExportClick?: () => void;
+4 -4
View File
@@ -1,6 +1,6 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
import { Play, Pause, RotateCcw, Film } from 'lucide-react'; import { Play, Pause, RotateCcw, Film } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player'; import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player';
import { ExpressTemplate, CompanyProfile, DesignMD } from '../../types'; import { ExpressTemplate, CompanyProfile, DesignMD } from '../../types';
import { BrandComposition } from '../BrandComposition'; import { BrandComposition } from '../BrandComposition';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler'; import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
@@ -31,7 +31,7 @@ export interface LivePreviewCanvasProps {
/** Callback when user navigates to a scene */ /** Callback when user navigates to a scene */
onSceneChange?: (sceneId: string) => void; onSceneChange?: (sceneId: string) => void;
/** External player ref */ /** External player ref */
playerRef?: React.RefObject<PlayerRef>; playerRef?: React.RefObject<BradlyPlayerRef>;
/** Status label (e.g. "Listo" / "Faltan campos") */ /** Status label (e.g. "Listo" / "Faltan campos") */
statusLabel?: string; statusLabel?: string;
/** Whether all required fields are complete */ /** Whether all required fields are complete */
@@ -67,7 +67,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
statusLabel, statusLabel,
isComplete = false, isComplete = false,
}) => { }) => {
const internalRef = useRef<PlayerRef>(null); const internalRef = useRef<BradlyPlayerRef>(null);
const playerRef = externalRef || internalRef; const playerRef = externalRef || internalRef;
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -246,7 +246,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
maxHeight: 'calc(100% - 160px)', maxHeight: 'calc(100% - 160px)',
}} }}
> >
<Player <BradlyPlayer
key={playerKey} key={playerKey}
ref={playerRef} ref={playerRef}
component={BrandComposition} component={BrandComposition}
+2 -2
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useRef, RefObject } from 'react'; import React, { useEffect, useRef, RefObject } from 'react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
interface TimelinePlayheadProps { interface TimelinePlayheadProps {
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
durationInFrames: number; durationInFrames: number;
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void; onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
onPointerMove?: (e: React.PointerEvent<HTMLDivElement>) => void; onPointerMove?: (e: React.PointerEvent<HTMLDivElement>) => void;
+2 -2
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../../engine/player';
interface PlaybackInfoProps { interface PlaybackInfoProps {
playerRef: React.RefObject<PlayerRef | null>; playerRef: React.RefObject<BradlyPlayerRef | null>;
durationInFrames: number; durationInFrames: number;
fps?: number; fps?: number;
elementCount?: number; elementCount?: number;
+3 -3
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useRef, useCallback, ReactNode, RefObject } from 'react'; import React, { createContext, useContext, useState, useRef, useCallback, ReactNode, RefObject } from 'react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../engine/player';
import { TimelineElement, TimelineLayer, DesignMD, BrandContentPiece } from '../types'; import { TimelineElement, TimelineLayer, DesignMD, BrandContentPiece } from '../types';
import { useHistory } from '../hooks/useHistory'; import { useHistory } from '../hooks/useHistory';
import { DEFAULT_DESIGN_MD } from '../data/defaults'; import { DEFAULT_DESIGN_MD } from '../data/defaults';
@@ -34,7 +34,7 @@ interface EditorState {
setTextOverlay: (text: string) => void; setTextOverlay: (text: string) => void;
// Player // Player
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
// Format // Format
outputFormat: 'video' | 'image'; outputFormat: 'video' | 'image';
@@ -142,7 +142,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
const [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames'); const [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames');
const [canvasZoom, setCanvasZoom] = useState(1); const [canvasZoom, setCanvasZoom] = useState(1);
const [brandVisibility, setBrandVisibility] = useState<{ logo: boolean; frame: boolean; background: boolean }>({ logo: true, frame: true, background: true }); const [brandVisibility, setBrandVisibility] = useState<{ logo: boolean; frame: boolean; background: boolean }>({ logo: true, frame: true, background: true });
const playerRef = useRef<PlayerRef>(null); const playerRef = useRef<BradlyPlayerRef>(null);
const durationInFrames = timelineElements.reduce((max, el) => Math.max(max, el.endFrame), 300); const durationInFrames = timelineElements.reduce((max, el) => Math.max(max, el.endFrame), 300);
+222
View File
@@ -0,0 +1,222 @@
/**
* Easing Functions Drop-in replacement for Remotion's Easing module.
*
* Standard easing curves using cubic Bézier and physics-based functions.
* API is 1:1 compatible with `import { Easing } from 'remotion'`.
*/
/** Linear — no easing */
function linear(t: number): number {
return t;
}
/**
* Standard ease curve equivalent to CSS `ease`.
* Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0)
*/
function ease(t: number): number {
return cubicBezier(0.25, 0.1, 0.25, 1.0, t);
}
/** Quadratic easing */
function quad(t: number): number {
return t * t;
}
/** Cubic easing */
function cubic(t: number): number {
return t * t * t;
}
/** Sinusoidal easing */
function sin(t: number): number {
return 1 - Math.cos((t * Math.PI) / 2);
}
/** Circular easing */
function circle(t: number): number {
return 1 - Math.sqrt(1 - t * t);
}
/** Exponential easing */
function exp(t: number): number {
return t === 0 ? 0 : Math.pow(2, 10 * (t - 1));
}
/** Elastic easing */
function elastic(bounciness: number = 1): (t: number) => number {
const p = bounciness * Math.PI;
return (t: number) => 1 - Math.pow(Math.cos((t * Math.PI) / 2), 3) * Math.cos(t * p);
}
/** Back easing — overshoots then returns */
function back(s: number = 1.70158): (t: number) => number {
return (t: number) => t * t * ((s + 1) * t - s);
}
/** Bounce easing — simulates a bouncing ball */
function bounce(t: number): number {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
}
if (t < 2 / 2.75) {
const t2 = t - 1.5 / 2.75;
return 7.5625 * t2 * t2 + 0.75;
}
if (t < 2.5 / 2.75) {
const t2 = t - 2.25 / 2.75;
return 7.5625 * t2 * t2 + 0.9375;
}
const t2 = t - 2.625 / 2.75;
return 7.5625 * t2 * t2 + 0.984375;
}
/**
* Custom cubic Bézier curve.
* Equivalent to CSS cubic-bezier(mX1, mY1, mX2, mY2).
*/
function bezier(mX1: number, mY1: number, mX2: number, mY2: number): (t: number) => number {
return (t: number) => cubicBezier(mX1, mY1, mX2, mY2, t);
}
// ═══ Composition Helpers ═══
/** Apply easing only to the "in" part (acceleration) */
function easeIn(easing: (t: number) => number): (t: number) => number {
return easing;
}
/** Apply easing only to the "out" part (deceleration) */
function easeOut(easing: (t: number) => number): (t: number) => number {
return (t: number) => 1 - easing(1 - t);
}
/** Apply easing to both in and out */
function easeInOut(easing: (t: number) => number): (t: number) => number {
return (t: number) => {
if (t < 0.5) {
return easing(t * 2) / 2;
}
return 1 - easing((1 - t) * 2) / 2;
};
}
// ═══ Cubic Bézier Implementation ═══
const NEWTON_ITERATIONS = 4;
const NEWTON_MIN_SLOPE = 0.001;
const SUBDIVISION_PRECISION = 0.0000001;
const SUBDIVISION_MAX_ITERATIONS = 10;
const TABLE_SIZE = 11;
const SAMPLE_STEP_SIZE = 1.0 / (TABLE_SIZE - 1.0);
function A(a1: number, a2: number): number {
return 1.0 - 3.0 * a2 + 3.0 * a1;
}
function B(a1: number, a2: number): number {
return 3.0 * a2 - 6.0 * a1;
}
function C(a1: number): number {
return 3.0 * a1;
}
function calcBezier(t: number, a1: number, a2: number): number {
return ((A(a1, a2) * t + B(a1, a2)) * t + C(a1)) * t;
}
function getSlope(t: number, a1: number, a2: number): number {
return 3.0 * A(a1, a2) * t * t + 2.0 * B(a1, a2) * t + C(a1);
}
function binarySubdivide(x: number, a: number, b: number, mX1: number, mX2: number): number {
let currentX: number;
let currentT: number;
let i = 0;
do {
currentT = a + (b - a) / 2.0;
currentX = calcBezier(currentT, mX1, mX2) - x;
if (currentX > 0.0) {
b = currentT;
} else {
a = currentT;
}
} while (
Math.abs(currentX) > SUBDIVISION_PRECISION &&
++i < SUBDIVISION_MAX_ITERATIONS
);
return currentT;
}
function newtonRaphsonIterate(x: number, guessT: number, mX1: number, mX2: number): number {
for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
const currentSlope = getSlope(guessT, mX1, mX2);
if (currentSlope === 0.0) return guessT;
const currentX = calcBezier(guessT, mX1, mX2) - x;
guessT -= currentX / currentSlope;
}
return guessT;
}
function cubicBezier(mX1: number, mY1: number, mX2: number, mY2: number, x: number): number {
if (mX1 === mY1 && mX2 === mY2) return x; // Linear
// Precompute sample table
const sampleValues = new Float32Array(TABLE_SIZE);
for (let i = 0; i < TABLE_SIZE; ++i) {
sampleValues[i] = calcBezier(i * SAMPLE_STEP_SIZE, mX1, mX2);
}
function getTForX(x: number): number {
let intervalStart = 0.0;
let currentSample = 1;
const lastSample = TABLE_SIZE - 1;
for (; currentSample !== lastSample && sampleValues[currentSample] <= x; ++currentSample) {
intervalStart += SAMPLE_STEP_SIZE;
}
--currentSample;
const dist =
(x - sampleValues[currentSample]) /
(sampleValues[currentSample + 1] - sampleValues[currentSample]);
const guessForT = intervalStart + dist * SAMPLE_STEP_SIZE;
const initialSlope = getSlope(guessForT, mX1, mX2);
if (initialSlope >= NEWTON_MIN_SLOPE) {
return newtonRaphsonIterate(x, guessForT, mX1, mX2);
} else if (initialSlope === 0.0) {
return guessForT;
} else {
return binarySubdivide(
x,
intervalStart,
intervalStart + SAMPLE_STEP_SIZE,
mX1,
mX2
);
}
}
if (x === 0) return 0;
if (x === 1) return 1;
return calcBezier(getTForX(x), mY1, mY2);
}
// ═══ Public API (mirrors Remotion's Easing) ═══
export const Easing = {
linear,
ease,
quad,
cubic,
sin,
circle,
exp,
elastic,
back,
bounce,
bezier,
in: easeIn,
out: easeOut,
inOut: easeInOut,
};
+16
View File
@@ -0,0 +1,16 @@
/**
* Bradly Animation Engine barrel export.
*
* Drop-in replacements for Remotion animation utilities:
* import { interpolate, spring, Easing } from 'remotion'
*
* import { interpolate, spring, Easing } from '@/engine/animation'
*/
export { interpolate } from './interpolate';
export type { InterpolateOptions, ExtrapolateType } from './interpolate';
export { spring } from './spring';
export type { SpringConfig, SpringParams } from './spring';
export { Easing } from './easing';
+126
View File
@@ -0,0 +1,126 @@
/**
* interpolate() Drop-in replacement for Remotion's interpolate.
*
* Maps a value from an input range to an output range with support for:
* - Multi-point ranges (N input values N output values)
* - Per-segment easing functions
* - Extrapolation modes: 'clamp', 'extend', 'identity'
*
* API is 1:1 compatible with `import { interpolate } from 'remotion'`.
*/
export type ExtrapolateType = 'clamp' | 'extend' | 'identity';
export interface InterpolateOptions {
easing?: ((t: number) => number) | ((t: number) => number)[];
extrapolateLeft?: ExtrapolateType;
extrapolateRight?: ExtrapolateType;
}
/**
* Interpolate a value from inputRange to outputRange.
*
* @param value - The input value to interpolate
* @param inputRange - Array of input values (must be monotonically increasing)
* @param outputRange - Array of output values (same length as inputRange)
* @param options - Easing and extrapolation options
*/
export function interpolate(
value: number,
inputRange: readonly number[],
outputRange: readonly number[],
options?: InterpolateOptions
): number {
if (inputRange.length !== outputRange.length) {
throw new Error(
`interpolate: inputRange (${inputRange.length}) and outputRange (${outputRange.length}) must have the same length`
);
}
if (inputRange.length < 2) {
throw new Error('interpolate: inputRange must have at least 2 values');
}
const {
easing,
extrapolateLeft = 'extend',
extrapolateRight = 'extend',
} = options ?? {};
// ── Handle extrapolation ──
// Below input range
if (value < inputRange[0]) {
switch (extrapolateLeft) {
case 'clamp':
return outputRange[0];
case 'identity':
return value;
case 'extend':
default:
// Fall through to normal interpolation (linear extension)
break;
}
}
// Above input range
if (value > inputRange[inputRange.length - 1]) {
switch (extrapolateRight) {
case 'clamp':
return outputRange[outputRange.length - 1];
case 'identity':
return value;
case 'extend':
default:
// Fall through to normal interpolation (linear extension)
break;
}
}
// ── Find the segment ──
let segmentIndex = 0;
for (let i = 1; i < inputRange.length; i++) {
if (value <= inputRange[i]) {
segmentIndex = i - 1;
break;
}
segmentIndex = i - 1;
}
// Clamp segment index
segmentIndex = Math.min(segmentIndex, inputRange.length - 2);
const inputMin = inputRange[segmentIndex];
const inputMax = inputRange[segmentIndex + 1];
const outputMin = outputRange[segmentIndex];
const outputMax = outputRange[segmentIndex + 1];
// ── Normalize to [0, 1] ──
let t: number;
if (inputMin === inputMax) {
t = 0;
} else {
t = (value - inputMin) / (inputMax - inputMin);
}
// ── Apply easing ──
if (easing) {
if (Array.isArray(easing)) {
// Per-segment easing: use the easing function for this segment
const easingFn = easing[segmentIndex];
if (easingFn) {
t = easingFn(t);
}
} else {
// Single easing function for all segments
t = easing(t);
}
}
// ── Map to output range ──
return outputMin + t * (outputMax - outputMin);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* spring() Drop-in replacement for Remotion's spring function.
*
* Simulates a damped harmonic oscillator (spring physics).
* Returns a value that animates from 0 1 with spring-like motion.
*
* API is 1:1 compatible with `import { spring } from 'remotion'`.
*/
export interface SpringConfig {
/** Friction — higher = less bouncy. Default: 10 */
damping?: number;
/** Spring constant — higher = faster. Default: 100 */
stiffness?: number;
/** Weight of the object. Default: 1 */
mass?: number;
/** Overshoot clamping — if true, value won't exceed 1. Default: false */
overshootClamping?: boolean;
}
export interface SpringParams {
/** Current frame (relative to animation start) */
frame: number;
/** Frames per second */
fps: number;
/** Spring physics configuration */
config?: SpringConfig;
/** Starting value. Default: 0 */
from?: number;
/** Target value. Default: 1 */
to?: number;
/** Delay in frames before animation starts. Default: 0 */
delay?: number;
}
/**
* Compute spring value at a given frame.
*
* Uses a step-based simulation of a damped spring system:
* F = -stiffness * displacement - damping * velocity
* acceleration = F / mass
*
* @returns Value between `from` and `to` (may overshoot if not clamped)
*/
export function spring(params: SpringParams): number {
const {
frame,
fps,
config = {},
from = 0,
to = 1,
delay = 0,
} = params;
const {
damping = 10,
stiffness = 100,
mass = 1,
overshootClamping = false,
} = config;
// Before delay, return start value
if (frame < delay) {
return from;
}
const adjustedFrame = frame - delay;
// Time in seconds for this frame
const timeInSeconds = adjustedFrame / fps;
// Simulation parameters
const STEP_SIZE = 1 / 1000; // 1ms steps for accuracy
const totalSteps = Math.ceil(timeInSeconds / STEP_SIZE);
let position = 0; // starts at 0 (normalized, will be mapped to from→to later)
let velocity = 0;
for (let i = 0; i < totalSteps; i++) {
const springForce = -stiffness * (position - 1); // target is 1 (normalized)
const dampingForce = -damping * velocity;
const acceleration = (springForce + dampingForce) / mass;
velocity += acceleration * STEP_SIZE;
position += velocity * STEP_SIZE;
// Check for overshoot clamping
if (overshootClamping) {
if (position > 1) {
position = 1;
velocity = 0;
}
}
}
// Map from normalized [0→1] to [from→to]
return from + position * (to - from);
}
+33
View File
@@ -0,0 +1,33 @@
/**
* AbsoluteFill Drop-in replacement for Remotion's <AbsoluteFill>.
*
* A <div> that fills its parent with position:absolute and inset:0.
* Used for layering content in compositions.
*/
import React, { forwardRef } from 'react';
export const AbsoluteFill = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ style, children, ...props }, ref) => (
<div
ref={ref}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
...style,
}}
{...props}
>
{children}
</div>
));
AbsoluteFill.displayName = 'AbsoluteFill';
+81
View File
@@ -0,0 +1,81 @@
/**
* Audio Drop-in replacement for Remotion's <Audio>.
*
* An <audio> element that syncs to the player's current frame.
* Supports volume as a per-frame callback function.
*/
import React, { useRef, useEffect } from 'react';
import { useCurrentFrame } from '../player/useCurrentFrame';
import { useVideoConfig } from '../player/useVideoConfig';
import { usePlayerState } from '../player/PlayerContext';
interface AudioProps {
src: string;
volume?: number | ((frame: number) => number);
playbackRate?: number;
startFrom?: number; // frame
endAt?: number; // frame
}
export const Audio: React.FC<AudioProps> = ({
src,
volume: volumeProp,
playbackRate = 1,
startFrom = 0,
}) => {
const volume: number | ((frame: number) => number) = volumeProp ?? 1;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { playing } = usePlayerState();
const audioRef = useRef<HTMLAudioElement>(null);
// Compute the audio's local time
const audioFrame = frame + startFrom;
const targetTime = audioFrame / fps;
// Sync time
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (Math.abs(audio.currentTime - targetTime) > 0.1) {
audio.currentTime = targetTime;
}
}, [targetTime]);
// Sync play/pause
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [playing]);
// Set playback rate
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.playbackRate = playbackRate;
}, [playbackRate]);
// Set volume per frame
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const vol = typeof volume === 'function' ? volume(frame) : volume;
audio.volume = Math.max(0, Math.min(1, vol));
}, [volume, frame]);
return (
<audio
ref={audioRef}
src={src}
preload="auto"
style={{ display: 'none' }}
/>
);
};
+29
View File
@@ -0,0 +1,29 @@
/**
* Img Drop-in replacement for Remotion's <Img>.
*
* An <img> element with:
* - Draggable disabled by default
* - Error handling for failed loads
* - User-select disabled for composition use
*/
import React from 'react';
interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
}
export const Img: React.FC<ImgProps> = ({ style, ...props }) => (
<img
draggable={false}
{...props}
style={{
userSelect: 'none',
...style,
}}
onError={(e) => {
// Silently handle broken images in compositions
const target = e.currentTarget;
target.style.opacity = '0';
}}
/>
);
+102
View File
@@ -0,0 +1,102 @@
/**
* Video Drop-in replacement for Remotion's <Video>.
*
* A <video> element that syncs playback to the player's current frame.
* Handles:
* - Time synchronization via useCurrentFrame/useVideoConfig
* - Volume control (number or per-frame callback)
* - Playback rate
* - Trim (startFrom / endAt in frames)
* - Play/pause state from player context
*/
import React, { useRef, useEffect } from 'react';
import { useCurrentFrame } from '../player/useCurrentFrame';
import { useVideoConfig } from '../player/useVideoConfig';
import { usePlayerState } from '../player/PlayerContext';
interface VideoProps extends Omit<React.VideoHTMLAttributes<HTMLVideoElement>, 'volume'> {
src: string;
volume?: number | ((frame: number) => number);
playbackRate?: number;
startFrom?: number; // frame
endAt?: number; // frame
style?: React.CSSProperties;
onError?: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
}
export const Video: React.FC<VideoProps> = ({
src,
volume: volumeProp,
playbackRate = 1,
startFrom = 0,
endAt,
style,
onError,
...props
}) => {
const volume: number | ((frame: number) => number) = volumeProp ?? 1;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { playing } = usePlayerState();
const videoRef = useRef<HTMLVideoElement>(null);
const lastSyncRef = useRef<number>(-1);
// Compute the video's local time
const videoFrame = frame + startFrom;
const targetTime = videoFrame / fps;
// Sync time
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Only seek if significantly out of sync (>50ms) or if frame changed significantly
if (Math.abs(video.currentTime - targetTime) > 0.05 || lastSyncRef.current !== frame) {
video.currentTime = targetTime;
lastSyncRef.current = frame;
}
}, [targetTime, frame]);
// Sync play/pause
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (playing) {
video.play().catch(() => {});
} else {
video.pause();
}
}, [playing]);
// Set playback rate
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = playbackRate;
}, [playbackRate]);
// Set volume
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const vol = typeof volume === 'function' ? volume(frame) : volume;
video.volume = Math.max(0, Math.min(1, vol));
}, [volume, frame]);
return (
<video
ref={videoRef}
src={src}
muted={typeof volume === 'function' ? false : volume === 0}
playsInline
preload="auto"
{...props}
style={{
userSelect: 'none',
...style,
}}
onError={onError}
/>
);
};
+73
View File
@@ -0,0 +1,73 @@
/**
* Sequence Drop-in replacement for Remotion's <Sequence>.
*
* Mounts/unmounts children based on the current frame within the
* composition. Provides a shifted frame context so children see
* frame 0 when the Sequence starts.
*
* Props:
* - from: Start frame (absolute)
* - durationInFrames: How many frames this sequence lasts
* - layout?: 'absolute-fill' (default) | 'none'
* - name?: Debug label (unused in render)
*/
import React from 'react';
import { useCurrentFrame } from '../player/useCurrentFrame';
import { SequenceFrameContext } from '../player/PlayerContext';
interface SequenceProps {
from: number;
durationInFrames: number;
layout?: 'absolute-fill' | 'none';
name?: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
export const Sequence: React.FC<SequenceProps> = ({
from,
durationInFrames,
layout = 'absolute-fill',
children,
style,
}) => {
const parentFrame = useCurrentFrame();
// Not yet visible — unmount
if (parentFrame < from) return null;
// Past end — unmount
if (parentFrame >= from + durationInFrames) return null;
// Shift frame so children see 0 at sequence start
const shiftedFrame = parentFrame - from;
const content = (
<SequenceFrameContext.Provider value={shiftedFrame}>
{children}
</SequenceFrameContext.Provider>
);
if (layout === 'none') {
return content;
}
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
...style,
}}
>
{content}
</div>
);
};
+8
View File
@@ -0,0 +1,8 @@
/**
* Bradly Engine Components barrel export.
*/
export { AbsoluteFill } from './AbsoluteFill';
export { Sequence } from './Sequence';
export { Img } from './MediaImg';
export { Video } from './MediaVideo';
export { Audio } from './MediaAudio';
+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;
+45
View File
@@ -0,0 +1,45 @@
/**
* PlayerContext React contexts for the Bradly Player.
*
* Provides frame number, video config, and play/pause state
* to all descendant components (Sequence, Video, Audio, useCurrentFrame, etc.)
*/
import { createContext, useContext } from 'react';
import type { VideoConfig } from './types';
// ═══ Player State Context ═══
interface PlayerStateContextValue {
frame: number;
playing: boolean;
}
export const PlayerStateContext = createContext<PlayerStateContextValue>({
frame: 0,
playing: false,
});
/**
* Hook to access the player's play/pause state.
* Used internally by Video/Audio components to sync playback.
*/
export function usePlayerState(): PlayerStateContextValue {
return useContext(PlayerStateContext);
}
// ═══ Video Config Context ═══
export const VideoConfigContext = createContext<VideoConfig>({
fps: 30,
width: 1080,
height: 1920,
durationInFrames: 150,
});
// ═══ Sequence Frame Context ═══
/**
* Used by <Sequence> to provide shifted frame numbers to children.
* When null, the parent PlayerStateContext frame is used directly.
*/
export const SequenceFrameContext = createContext<number | null>(null);
+175
View File
@@ -0,0 +1,175 @@
/**
* PlayerControls Minimal playback UI for BradlyPlayer.
*
* Provides play/pause, scrub bar, and time display.
* Rendered at the bottom of the player when `controls` prop is true.
*/
import React, { useRef, useCallback } from 'react';
interface PlayerControlsProps {
frame: number;
durationInFrames: number;
fps: number;
playing: boolean;
onPlay: () => void;
onPause: () => void;
onSeek: (frame: number) => void;
}
function formatTime(frames: number, fps: number): string {
const totalSeconds = Math.floor(frames / fps);
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${String(secs).padStart(2, '0')}`;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
frame,
durationInFrames,
fps,
playing,
onPlay,
onPause,
onSeek,
}) => {
const scrubRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const progress = durationInFrames > 0 ? (frame / (durationInFrames - 1)) * 100 : 0;
const scrubToPosition = useCallback(
(clientX: number) => {
if (!scrubRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
onSeek(Math.round(pct * (durationInFrames - 1)));
},
[onSeek, durationInFrames]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isDragging.current = true;
scrubToPosition(e.clientX);
},
[scrubToPosition]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
scrubToPosition(e.clientX);
},
[scrubToPosition]
);
const handlePointerUp = useCallback(() => {
isDragging.current = false;
}, []);
return (
<div
style={{
height: 32,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
userSelect: 'none',
}}
>
{/* Play/Pause */}
<button
onClick={playing ? onPause : onPlay}
title={playing ? 'Pause' : 'Play'}
style={{
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
color: '#fff',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
>
{playing ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
{/* Scrub Bar */}
<div
ref={scrubRef}
style={{
flex: 1,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
borderRadius: 3,
cursor: 'pointer',
position: 'relative',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Progress fill */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${progress}%`,
backgroundColor: '#8b5cf6',
borderRadius: 3,
transition: isDragging.current ? 'none' : 'width 50ms linear',
}}
/>
{/* Thumb */}
<div
style={{
position: 'absolute',
top: '50%',
left: `${progress}%`,
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: '#fff',
transform: 'translate(-50%, -50%)',
boxShadow: '0 1px 4px rgba(0,0,0,0.5)',
}}
/>
</div>
{/* Time display */}
<span
style={{
fontSize: 10,
fontFamily: 'monospace',
color: 'rgba(255, 255, 255, 0.6)',
flexShrink: 0,
minWidth: 60,
textAlign: 'right',
}}
>
{formatTime(frame, fps)} / {formatTime(durationInFrames, fps)}
</span>
</div>
);
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Bradly Player barrel export.
*/
export { BradlyPlayer } from './BradlyPlayer';
export type { BradlyPlayerRef, VideoConfig } from './types';
export { useCurrentFrame } from './useCurrentFrame';
export { useVideoConfig } from './useVideoConfig';
export { PlayerControls } from './PlayerControls';
export { PlayerStateContext, VideoConfigContext, SequenceFrameContext, usePlayerState } from './PlayerContext';
+39
View File
@@ -0,0 +1,39 @@
/**
* BradlyPlayerRef Type definitions for the Player's imperative API.
*
* Mirrors Remotion's PlayerRef interface for drop-in compatibility.
*/
export interface BradlyPlayerRef {
/** Start playback */
play: () => void;
/** Pause playback */
pause: () => void;
/** Toggle play/pause */
toggle: () => void;
/** Jump to a specific frame */
seekTo: (frame: number) => void;
/** Get the current frame number */
getCurrentFrame: () => number;
/** Check if currently playing */
isPlaying: () => boolean;
/** Get total duration in frames */
getDuration: () => number;
/** Add an event listener */
addEventListener: (
event: 'frameupdate' | 'play' | 'pause' | 'ended',
handler: (e: { detail: { frame: number } }) => void
) => void;
/** Remove an event listener */
removeEventListener: (
event: 'frameupdate' | 'play' | 'pause' | 'ended',
handler: (e: { detail: { frame: number } }) => void
) => void;
}
export interface VideoConfig {
fps: number;
width: number;
height: number;
durationInFrames: number;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* useCurrentFrame Hook to read the current frame from the player context.
*
* Respects Sequence frame shifting: if inside a <Sequence>, returns
* the shifted frame (relative to Sequence start).
*
* Drop-in replacement for `import { useCurrentFrame } from 'remotion'`.
*/
import { useContext } from 'react';
import { PlayerStateContext, SequenceFrameContext } from './PlayerContext';
export function useCurrentFrame(): number {
const sequenceFrame = useContext(SequenceFrameContext);
const { frame } = useContext(PlayerStateContext);
// If inside a <Sequence>, use the shifted frame
if (sequenceFrame !== null) {
return sequenceFrame;
}
return frame;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* useVideoConfig Hook to read fps, width, height, durationInFrames.
*
* Drop-in replacement for `import { useVideoConfig } from 'remotion'`.
*/
import { useContext } from 'react';
import { VideoConfigContext } from './PlayerContext';
import type { VideoConfig } from './types';
export function useVideoConfig(): VideoConfig {
return useContext(VideoConfigContext);
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef, RefObject } from 'react'; import { useEffect, useRef, RefObject } from 'react';
import { PlayerRef } from '@remotion/player'; import type { BradlyPlayerRef } from '../engine/player';
import { TimelineElement } from '../types'; import { TimelineElement } from '../types';
interface UseKeyboardShortcutsOptions { interface UseKeyboardShortcutsOptions {
enabled: boolean; enabled: boolean;
playerRef: RefObject<PlayerRef | null>; playerRef: RefObject<BradlyPlayerRef | null>;
durationInFrames: number; durationInFrames: number;
selectedElementId: string | null; selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void; setSelectedElementId: (id: string | null) => void;