diff --git a/src/components/BrandComposition.tsx b/src/components/BrandComposition.tsx index 71aac14..1874b67 100644 --- a/src/components/BrandComposition.tsx +++ b/src/components/BrandComposition.tsx @@ -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'; diff --git a/src/components/StudioProperties.tsx b/src/components/StudioProperties.tsx index 3789c7c..0b8e0a7 100644 --- a/src/components/StudioProperties.tsx +++ b/src/components/StudioProperties.tsx @@ -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: RefObject; activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media'; designMD: DesignMD; outputFormat?: 'video' | 'image'; diff --git a/src/components/StudioTimeline.tsx b/src/components/StudioTimeline.tsx index 11c1a3d..9752ac6 100644 --- a/src/components/StudioTimeline.tsx +++ b/src/components/StudioTimeline.tsx @@ -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: RefObject; activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions'; outputFormat?: 'video' | 'image'; designMD?: DesignMD; diff --git a/src/components/StudioWorkspace.tsx b/src/components/StudioWorkspace.tsx index 3e1dc58..ce5b541 100644 --- a/src/components/StudioWorkspace.tsx +++ b/src/components/StudioWorkspace.tsx @@ -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: RefObject; compositionProps: RenderProps; durationInFrames: number; timelineElements?: TimelineElement[]; @@ -560,7 +560,7 @@ export const StudioWorkspace: React.FC = ({ ) : undefined} > - = ({ designMD, comp ) : undefined} > - { diff --git a/src/components/composition/BrandOverlay.tsx b/src/components/composition/BrandOverlay.tsx index 2c39b8a..b121327 100644 --- a/src/components/composition/BrandOverlay.tsx +++ b/src/components/composition/BrandOverlay.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AbsoluteFill } from 'remotion'; +import { AbsoluteFill } from '../../engine/components'; import { DesignMD } from '../../types'; interface BrandOverlayProps { diff --git a/src/components/composition/ChromaKeyVideo.tsx b/src/components/composition/ChromaKeyVideo.tsx index faae459..f679138 100644 --- a/src/components/composition/ChromaKeyVideo.tsx +++ b/src/components/composition/ChromaKeyVideo.tsx @@ -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, diff --git a/src/components/composition/CompositionElement.tsx b/src/components/composition/CompositionElement.tsx index c3d371d..601d8dc 100644 --- a/src/components/composition/CompositionElement.tsx +++ b/src/components/composition/CompositionElement.tsx @@ -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'; diff --git a/src/components/composition/keyframeEngine.ts b/src/components/composition/keyframeEngine.ts index 8ef742f..4093cbc 100644 --- a/src/components/composition/keyframeEngine.ts +++ b/src/components/composition/keyframeEngine.ts @@ -1,4 +1,4 @@ -import { interpolate, Easing } from 'remotion'; +import { interpolate, Easing } from '../../engine/animation'; import { AnimationKeyframe, EasingType } from '../../types'; interface KeyframeDefaults { diff --git a/src/components/composition/useTransitions.ts b/src/components/composition/useTransitions.ts index 79d08ea..ffdd8ab 100644 --- a/src/components/composition/useTransitions.ts +++ b/src/components/composition/useTransitions.ts @@ -1,4 +1,4 @@ -import { interpolate, spring } from 'remotion'; +import { interpolate, spring } from '../../engine/animation'; import { TimelineElement } from '../../types'; import { resolveKeyframes } from './keyframeEngine'; diff --git a/src/components/dashboard/BatchPreviewGrid.tsx b/src/components/dashboard/BatchPreviewGrid.tsx index 6d5ceab..8465a86 100644 --- a/src/components/dashboard/BatchPreviewGrid.tsx +++ b/src/components/dashboard/BatchPreviewGrid.tsx @@ -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 ? ( - = ({ onActivePieceChange, }) => { const [carouselIndex, setCarouselIndex] = useState(null); - const carouselPlayerRef = useRef(null); + const carouselBradlyPlayerRef = useRef(null); const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]); const totalDuration = useMemo(() => getTemplateDuration(template), [template]); @@ -364,7 +364,7 @@ export const BatchPreviewGrid: React.FC = ({ maxHeight: 'calc(100% - 120px)', }} > - = ({ maxHeight: 'calc(100vh - 100px)', }} > - = ({ const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0); const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]); - const playerRef = useRef(null); + const playerRef = useRef(null); const designMD = brand.design; const fps = 30; diff --git a/src/components/express/ExpressEditor.tsx b/src/components/express/ExpressEditor.tsx index 152558d..9cafde5 100644 --- a/src/components/express/ExpressEditor.tsx +++ b/src/components/express/ExpressEditor.tsx @@ -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 = ({ const [showLogo, setShowLogo] = useState(true); const [overlayOpacity, setOverlayOpacity] = useState(0); - const playerRef = useRef(null); + const playerRef = useRef(null); const handleSelectTemplate = useCallback((template: ExpressTemplate) => { setSelectedTemplate(template); @@ -208,7 +208,7 @@ export const ExpressEditor: React.FC = ({ maxHeight: 'calc(100% - 80px)', }} > - ; + playerRef: RefObject; elements: TimelineElement[]; durationInFrames: number; selectedSlotId: string | null; diff --git a/src/components/properties/AudioLayerPanel.tsx b/src/components/properties/AudioLayerPanel.tsx index 8098b3b..0152c09 100644 --- a/src/components/properties/AudioLayerPanel.tsx +++ b/src/components/properties/AudioLayerPanel.tsx @@ -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>; timelineElements: TimelineElement[]; - playerRef: RefObject; + playerRef: RefObject; endFrameLimit?: number; } diff --git a/src/components/properties/GlobalSettingsPanel.tsx b/src/components/properties/GlobalSettingsPanel.tsx index 56c998b..d95ee1f 100644 --- a/src/components/properties/GlobalSettingsPanel.tsx +++ b/src/components/properties/GlobalSettingsPanel.tsx @@ -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?: RefObject; aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3'; outputFormat?: 'video' | 'image'; onExportClick?: () => void; diff --git a/src/components/shared/LivePreviewCanvas.tsx b/src/components/shared/LivePreviewCanvas.tsx index c8e5e84..6eb6404 100644 --- a/src/components/shared/LivePreviewCanvas.tsx +++ b/src/components/shared/LivePreviewCanvas.tsx @@ -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?: React.RefObject; /** Status label (e.g. "Listo" / "Faltan campos") */ statusLabel?: string; /** Whether all required fields are complete */ @@ -67,7 +67,7 @@ export const LivePreviewCanvas: React.FC = ({ statusLabel, isComplete = false, }) => { - const internalRef = useRef(null); + const internalRef = useRef(null); const playerRef = externalRef || internalRef; const [isPlaying, setIsPlaying] = useState(false); @@ -246,7 +246,7 @@ export const LivePreviewCanvas: React.FC = ({ maxHeight: 'calc(100% - 160px)', }} > - ; + playerRef: RefObject; durationInFrames: number; onPointerDown: (e: React.PointerEvent) => void; onPointerMove?: (e: React.PointerEvent) => void; diff --git a/src/components/ui/PlaybackInfo.tsx b/src/components/ui/PlaybackInfo.tsx index 6e25239..671bcfe 100644 --- a/src/components/ui/PlaybackInfo.tsx +++ b/src/components/ui/PlaybackInfo.tsx @@ -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: React.RefObject; durationInFrames: number; fps?: number; elementCount?: number; diff --git a/src/context/EditorContext.tsx b/src/context/EditorContext.tsx index 993c9c4..3759945 100644 --- a/src/context/EditorContext.tsx +++ b/src/context/EditorContext.tsx @@ -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: RefObject; // Format outputFormat: 'video' | 'image'; @@ -142,7 +142,7 @@ export const EditorProvider: React.FC = ({ 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(null); + const playerRef = useRef(null); const durationInFrames = timelineElements.reduce((max, el) => Math.max(max, el.endFrame), 300); diff --git a/src/engine/animation/easing.ts b/src/engine/animation/easing.ts new file mode 100644 index 0000000..6833406 --- /dev/null +++ b/src/engine/animation/easing.ts @@ -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, +}; diff --git a/src/engine/animation/index.ts b/src/engine/animation/index.ts new file mode 100644 index 0000000..6fb1c18 --- /dev/null +++ b/src/engine/animation/index.ts @@ -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'; diff --git a/src/engine/animation/interpolate.ts b/src/engine/animation/interpolate.ts new file mode 100644 index 0000000..99014da --- /dev/null +++ b/src/engine/animation/interpolate.ts @@ -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); +} diff --git a/src/engine/animation/spring.ts b/src/engine/animation/spring.ts new file mode 100644 index 0000000..f1fe2b3 --- /dev/null +++ b/src/engine/animation/spring.ts @@ -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); +} diff --git a/src/engine/components/AbsoluteFill.tsx b/src/engine/components/AbsoluteFill.tsx new file mode 100644 index 0000000..7bed323 --- /dev/null +++ b/src/engine/components/AbsoluteFill.tsx @@ -0,0 +1,33 @@ +/** + * AbsoluteFill — Drop-in replacement for Remotion's . + * + * A
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 +>(({ style, children, ...props }, ref) => ( +
+ {children} +
+)); + +AbsoluteFill.displayName = 'AbsoluteFill'; diff --git a/src/engine/components/MediaAudio.tsx b/src/engine/components/MediaAudio.tsx new file mode 100644 index 0000000..867af03 --- /dev/null +++ b/src/engine/components/MediaAudio.tsx @@ -0,0 +1,81 @@ +/** + * Audio — Drop-in replacement for Remotion's