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:
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame } from 'remotion';
|
||||
import { AbsoluteFill } from '../engine/components';
|
||||
import { useCurrentFrame } from '../engine/player';
|
||||
import { RenderProps } from '../types';
|
||||
import { useCanvasDrag } from './composition/useCanvasDrag';
|
||||
import { BackgroundLayer } from './composition/BackgroundLayer';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
|
||||
import { AudioLayerPanel } from './properties/AudioLayerPanel';
|
||||
@@ -21,7 +21,7 @@ interface StudioPropertiesProps {
|
||||
textOverlay: string;
|
||||
setTextOverlay: (text: string) => void;
|
||||
activeLayerId: string;
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media';
|
||||
designMD: DesignMD;
|
||||
outputFormat?: 'video' | 'image';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, RefObject } from 'react';
|
||||
import { Layers, GripVertical } from 'lucide-react';
|
||||
import { TimelineElement, TimelineLayer, DesignMD } from '../types';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../engine/player';
|
||||
import { DragState, getTrackBgClass } from './timeline/timelineUtils';
|
||||
import { TimelineControls } from './timeline/TimelineControls';
|
||||
import { TimelineRuler } from './timeline/TimelineRuler';
|
||||
@@ -27,7 +27,7 @@ interface StudioTimelineProps {
|
||||
setActiveLayerId: (id: string) => void;
|
||||
selectedElementId: string | null;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
|
||||
outputFormat?: 'video' | 'image';
|
||||
designMD?: DesignMD;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { RenderProps, TimelineElement } from '../types';
|
||||
import { PlaySquare } from 'lucide-react';
|
||||
@@ -13,7 +13,7 @@ interface StudioWorkspaceProps {
|
||||
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
selectedElementId?: string | null;
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
compositionProps: RenderProps;
|
||||
durationInFrames: number;
|
||||
timelineElements?: TimelineElement[];
|
||||
@@ -560,7 +560,7 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
ref={playerRef}
|
||||
component={BrandComposition}
|
||||
inputProps={{
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Player } from '@remotion/player';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Sequence,
|
||||
Video,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
spring,
|
||||
} from 'remotion';
|
||||
import { BradlyPlayer } from '../../../engine/player';
|
||||
import { AbsoluteFill, Sequence, Video } from '../../../engine/components';
|
||||
import { useCurrentFrame, useVideoConfig } from '../../../engine/player';
|
||||
import { interpolate, spring } from '../../../engine/animation';
|
||||
import { DesignMD, CompanyProfile } from '../../../types';
|
||||
import { CanvasWorkspace } from '../../ui/CanvasWorkspace';
|
||||
|
||||
@@ -329,7 +323,7 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
ref={playerRef}
|
||||
component={SampleComposition}
|
||||
inputProps={inputProps}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const getFilterStyle = (filter?: MediaFilter): React.CSSProperties => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { AbsoluteFill } from '../../engine/components';
|
||||
import { DesignMD } from '../../types';
|
||||
|
||||
interface BrandOverlayProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { useCurrentFrame, useVideoConfig } from '../../engine/player';
|
||||
import {
|
||||
applyChromaKey,
|
||||
hexToRgb,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { calculateElementTransitions } from './useTransitions';
|
||||
import { resolveKeyframes } from './keyframeEngine';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { interpolate, Easing } from 'remotion';
|
||||
import { interpolate, Easing } from '../../engine/animation';
|
||||
import { AnimationKeyframe, EasingType } from '../../types';
|
||||
|
||||
interface KeyframeDefaults {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { interpolate, spring } from 'remotion';
|
||||
import { interpolate, spring } from '../../engine/animation';
|
||||
import { TimelineElement } from '../../types';
|
||||
import { resolveKeyframes } from './keyframeEngine';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
X, ChevronLeft, ChevronRight, AlertTriangle, Eye,
|
||||
} from 'lucide-react';
|
||||
import { Player, PlayerRef } from '@remotion/player';
|
||||
import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player';
|
||||
import { BrandComposition } from '../BrandComposition';
|
||||
import {
|
||||
compileExpressToTimeline, getAspectDimensions, getTemplateDuration,
|
||||
@@ -132,7 +132,7 @@ const PieceThumbnail: React.FC<{
|
||||
>
|
||||
{/* Render the piece */}
|
||||
{!isVideo ? (
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
key={playerKey}
|
||||
component={BrandComposition}
|
||||
inputProps={inputProps}
|
||||
@@ -217,7 +217,7 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
|
||||
onActivePieceChange,
|
||||
}) => {
|
||||
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 totalDuration = useMemo(() => getTemplateDuration(template), [template]);
|
||||
@@ -364,7 +364,7 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
|
||||
maxHeight: 'calc(100% - 120px)',
|
||||
}}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
key={videoPlayerKey}
|
||||
component={BrandComposition}
|
||||
inputProps={videoInputProps}
|
||||
@@ -499,9 +499,9 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
}}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
key={`carousel-${carouselIndex}`}
|
||||
ref={carouselPlayerRef}
|
||||
ref={carouselBradlyPlayerRef}
|
||||
component={BrandComposition}
|
||||
inputProps={carouselInputProps}
|
||||
durationInFrames={totalFrames}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowLeft, Sparkles, Zap, Play, ChevronRight, ChevronLeft, FileText, Download, Film,
|
||||
Layers, Package,
|
||||
} from 'lucide-react';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../../engine/player';
|
||||
import {
|
||||
ExpressTemplate, CompanyProfile, DesignMD,
|
||||
TemplateField, BrandSource,
|
||||
@@ -95,7 +95,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
||||
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
|
||||
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
|
||||
|
||||
const playerRef = useRef<PlayerRef>(null);
|
||||
const playerRef = useRef<BradlyPlayerRef>(null);
|
||||
|
||||
const designMD = brand.design;
|
||||
const fps = 30;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useCallback, useRef } from '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 { BrandComposition } from '../BrandComposition';
|
||||
import { ExpressTemplateGallery } from './ExpressTemplateGallery';
|
||||
@@ -42,7 +42,7 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [overlayOpacity, setOverlayOpacity] = useState(0);
|
||||
|
||||
const playerRef = useRef<PlayerRef>(null);
|
||||
const playerRef = useRef<BradlyPlayerRef>(null);
|
||||
|
||||
const handleSelectTemplate = useCallback((template: ExpressTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
@@ -208,7 +208,7 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
|
||||
maxHeight: 'calc(100% - 80px)',
|
||||
}}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
ref={playerRef}
|
||||
component={BrandComposition}
|
||||
inputProps={{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useRef, useState, useCallback, RefObject } from '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 { TimelineRuler } from '../timeline/TimelineRuler';
|
||||
import { TimelinePlayhead } from '../timeline/TimelinePlayhead';
|
||||
|
||||
interface ExpressTimelineProps {
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
elements: TimelineElement[];
|
||||
durationInFrames: number;
|
||||
selectedSlotId: string | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { RefObject } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { TimelineElement } from '../../types';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../../engine/player';
|
||||
import { uploadMedia } from '../../utils/mediaUploader';
|
||||
import { FileDropZone } from '../ui/FileDropZone';
|
||||
import { getAudioDuration, durationToFrames } from '../../utils/audioMetadata';
|
||||
@@ -10,7 +10,7 @@ interface AudioLayerPanelProps {
|
||||
activeLayerId: string;
|
||||
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
|
||||
timelineElements: TimelineElement[];
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
endFrameLimit?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, RefObject } from 'react';
|
||||
import { Film, Play, Camera, Download, Grid3x3, Palette, Maximize } from 'lucide-react';
|
||||
import { CollapsibleSection } from '../ui/CollapsibleSection';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../../engine/player';
|
||||
import { EXPORT_PRESETS } from '../../config/constants';
|
||||
import { TimelineElement, TimelineLayer } from '../../types';
|
||||
import { ProjectStats } from '../ui/ProjectStats';
|
||||
@@ -11,7 +11,7 @@ import { BulkActionsBar } from '../ui/BulkActionsBar';
|
||||
interface GlobalSettingsPanelProps {
|
||||
textOverlay: string;
|
||||
setTextOverlay: (text: string) => void;
|
||||
playerRef?: RefObject<PlayerRef | null>;
|
||||
playerRef?: RefObject<BradlyPlayerRef | null>;
|
||||
aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
|
||||
outputFormat?: 'video' | 'image';
|
||||
onExportClick?: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useState, useCallback, useRef, useEffect } from '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 { BrandComposition } from '../BrandComposition';
|
||||
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
|
||||
@@ -31,7 +31,7 @@ export interface LivePreviewCanvasProps {
|
||||
/** Callback when user navigates to a scene */
|
||||
onSceneChange?: (sceneId: string) => void;
|
||||
/** External player ref */
|
||||
playerRef?: React.RefObject<PlayerRef>;
|
||||
playerRef?: React.RefObject<BradlyPlayerRef>;
|
||||
/** Status label (e.g. "Listo" / "Faltan campos") */
|
||||
statusLabel?: string;
|
||||
/** Whether all required fields are complete */
|
||||
@@ -67,7 +67,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
||||
statusLabel,
|
||||
isComplete = false,
|
||||
}) => {
|
||||
const internalRef = useRef<PlayerRef>(null);
|
||||
const internalRef = useRef<BradlyPlayerRef>(null);
|
||||
const playerRef = externalRef || internalRef;
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -246,7 +246,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
||||
maxHeight: 'calc(100% - 160px)',
|
||||
}}
|
||||
>
|
||||
<Player
|
||||
<BradlyPlayer
|
||||
key={playerKey}
|
||||
ref={playerRef}
|
||||
component={BrandComposition}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useRef, RefObject } from 'react';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../../engine/player';
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
durationInFrames: number;
|
||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerMove?: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../../engine/player';
|
||||
|
||||
interface PlaybackInfoProps {
|
||||
playerRef: React.RefObject<PlayerRef | null>;
|
||||
playerRef: React.RefObject<BradlyPlayerRef | null>;
|
||||
durationInFrames: number;
|
||||
fps?: number;
|
||||
elementCount?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useHistory } from '../hooks/useHistory';
|
||||
import { DEFAULT_DESIGN_MD } from '../data/defaults';
|
||||
@@ -34,7 +34,7 @@ interface EditorState {
|
||||
setTextOverlay: (text: string) => void;
|
||||
|
||||
// Player
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
|
||||
// Format
|
||||
outputFormat: 'video' | 'image';
|
||||
@@ -142,7 +142,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
|
||||
const [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames');
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, RefObject } from 'react';
|
||||
import { PlayerRef } from '@remotion/player';
|
||||
import type { BradlyPlayerRef } from '../engine/player';
|
||||
import { TimelineElement } from '../types';
|
||||
|
||||
interface UseKeyboardShortcutsOptions {
|
||||
enabled: boolean;
|
||||
playerRef: RefObject<PlayerRef | null>;
|
||||
playerRef: RefObject<BradlyPlayerRef | null>;
|
||||
durationInFrames: number;
|
||||
selectedElementId: string | null;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
|
||||
Reference in New Issue
Block a user