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 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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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,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,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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user