Skip to content

DOMAIN:VISUAL_PRODUCTION:REMOTION_PATTERNS

OWNER: felice
UPDATED: 2026-03-24
SCOPE: Remotion deep dive — project structure, core concepts, patterns, rendering, CI/CD
AGENTS: felice (primary), floris/floor (frontend integration)
PARENT: Visual Production


REMOTION:PROJECT_SETUP

INITIALIZATION

TOOL: Remotion (React-based programmatic video framework)
VERSION: 4.x (latest stable)
RUN: npx create-video@latest my-video --template blank

DIRECTORY_STRUCTURE

my-video/
├── src/
│   ├── Root.tsx              -- registers all compositions
│   ├── compositions/
│   │   ├── MyVideo.tsx       -- main video entry component
│   │   ├── Intro.tsx         -- intro sequence
│   │   ├── Scene1.tsx        -- content scene
│   │   ├── Scene2.tsx        -- content scene
│   │   └── Outro.tsx         -- outro sequence
│   ├── components/
│   │   ├── AnimatedText.tsx  -- reusable animated text
│   │   ├── BarChart.tsx      -- data visualization
│   │   ├── FadeIn.tsx        -- fade-in wrapper
│   │   ├── ProgressBar.tsx   -- progress indicator
│   │   └── Countdown.tsx     -- countdown timer
│   ├── lib/
│   │   ├── constants.ts      -- fps, dimensions, durations
│   │   ├── springs.ts        -- reusable spring configs
│   │   ├── colors.ts         -- brand color palette
│   │   └── fonts.ts          -- font loading
│   └── assets/
│       ├── fonts/
│       ├── images/
│       └── audio/
├── public/                   -- static files (accessed via staticFile())
│   ├── audio/
│   ├── video/
│   └── images/
├── remotion.config.ts        -- Remotion configuration
├── package.json
└── tsconfig.json

RULE: separate compositions (full videos) from components (reusable building blocks)
RULE: all duration/dimension constants in lib/constants.ts — never hardcode in components
RULE: all spring configurations in lib/springs.ts — reuse for visual consistency

CONSTANTS_FILE

// src/lib/constants.ts
export const FPS = 30;
export const WIDTH = 1920;
export const HEIGHT = 1080;

// Durations in frames
export const INTRO_DURATION = 90;   // 3 seconds
export const SCENE_DURATION = 150;  // 5 seconds
export const OUTRO_DURATION = 90;   // 3 seconds
export const FADE_FRAMES = 15;      // 0.5 seconds

// Colors
export const BRAND_PRIMARY = '#2563EB';
export const BRAND_DARK = '#1E293B';
export const BRAND_LIGHT = '#F8FAFC';

REMOTION:CORE_CONCEPTS

COMPOSITION

Defines a renderable video with metadata. Registered in Root.tsx.

// src/Root.tsx
import { Composition } from 'remotion';
import { MyVideo } from './compositions/MyVideo';
import { FPS, WIDTH, HEIGHT } from './lib/constants';

export const RemotionRoot = () => (
  <>
    <Composition
      id="MyVideo"
      component={MyVideo}
      durationInFrames={300}
      fps={FPS}
      width={WIDTH}
      height={HEIGHT}
      defaultProps={{
        title: 'Default Title',
        data: [],
      }}
    />
    <Composition
      id="Thumbnail"
      component={MyVideo}
      durationInFrames={1}
      fps={FPS}
      width={1280}
      height={720}
      defaultProps={{
        title: 'Thumbnail',
        data: [],
      }}
    />
  </>
);

RULE: register a separate "Thumbnail" composition for poster frame generation
RULE: use defaultProps for data-driven videos (injected at render time)

USE_CURRENT_FRAME

Returns the current frame number (0-indexed). This is the fundamental animation primitive.

const frame = useCurrentFrame();
// frame 0 = first frame
// frame 29 = 1 second mark at 30fps
// frame 299 = last frame of a 10-second video

RULE: never use useState, useEffect with timers, setTimeout, or setInterval
RULE: every visual state must be derivable from frame alone — Remotion renders frame-by-frame

USE_VIDEO_CONFIG

Returns composition metadata:

const { fps, width, height, durationInFrames } = useVideoConfig();

RULE: use fps to convert between seconds and frames: seconds * fps = frames

INTERPOLATE

Maps a frame range to an output range. The primary animation function.

import { interpolate } from 'remotion';

const frame = useCurrentFrame();

// Fade in over 30 frames (1 second at 30fps)
const opacity = interpolate(frame, [0, 30], [0, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

// Slide up by 50px over frames 10-40
const translateY = interpolate(frame, [10, 40], [50, 0], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

// Scale up then hold
const scale = interpolate(frame, [0, 20], [0.5, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

EXTRAPOLATION:
- 'clamp' — value stays at boundary. USE THIS ALWAYS.
- 'extend' — value continues linearly beyond range. AVOID — causes unexpected values.
- 'identity' — returns the input value beyond range. RARELY useful.

RULE: always set extrapolateLeft: 'clamp' AND extrapolateRight: 'clamp'
RULE: forgetting clamp is the #1 Remotion bug — values go negative or > 1

EASING:

import { Easing } from 'remotion';

const opacity = interpolate(frame, [0, 30], [0, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
  easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),  // ease-out
});

Common easings:
- Easing.linear — constant speed
- Easing.ease — CSS ease equivalent
- Easing.bezier(0.25, 0.1, 0.25, 1.0) — custom cubic bezier
- Easing.inOut(Easing.ease) — symmetric ease

SPRING

Physics-based animation — natural motion without manual easing.

import { spring } from 'remotion';

const frame = useCurrentFrame();
const { fps } = useVideoConfig();

const scale = spring({
  frame,
  fps,
  config: {
    damping: 10,      // resistance (higher = less bounce)
    stiffness: 100,   // force (higher = faster)
    mass: 0.5,        // weight (higher = slower, more momentum)
  },
});

SPRING_PRESETS:

// src/lib/springs.ts
export const SPRING_BOUNCE = { damping: 8, stiffness: 200, mass: 0.5 };  // playful bounce
export const SPRING_SMOOTH = { damping: 15, stiffness: 100, mass: 1 };   // smooth arrival
export const SPRING_SNAPPY = { damping: 20, stiffness: 300, mass: 0.3 }; // quick, minimal overshoot
export const SPRING_GENTLE = { damping: 12, stiffness: 50, mass: 1.5 };  // slow, organic

RULE: use spring() for entrance animations — more natural than linear interpolate
RULE: use interpolate() for progress bars, timers, and linear transitions

DELAYED_SPRING:

const scale = spring({
  frame: frame - 30,  // delay by 30 frames (1 second)
  fps,
  config: SPRING_BOUNCE,
});

SEQUENCE

Offsets child rendering by N frames. Creates a local frame context.

import { Sequence } from 'remotion';

<>
  <Sequence from={0} durationInFrames={90}>
    <Intro />  {/* Intro sees frames 0-89 */}
  </Sequence>
  <Sequence from={90} durationInFrames={150}>
    <Scene1 />  {/* Scene1 sees frames 0-149 (local) */}
  </Sequence>
  <Sequence from={240} durationInFrames={60}>
    <Outro />  {/* Outro sees frames 0-59 (local) */}
  </Sequence>
</>

RULE: useCurrentFrame() inside a Sequence returns LOCAL frame (reset to 0)
RULE: this means components are reusable — they don't need to know their absolute position
RULE: set durationInFrames to prevent overlap and aid debugging

ABSOLUTE_FILL

Full-frame container — positions content to fill the entire video frame.

import { AbsoluteFill } from 'remotion';

<AbsoluteFill style={{ backgroundColor: '#1E293B' }}>
  {/* content fills entire frame */}
</AbsoluteFill>

RULE: use AbsoluteFill as the root of every composition and scene
RULE: layer AbsoluteFill components for z-index stacking

AUDIO_AND_VIDEO

import { Audio, Video, staticFile } from 'remotion';

// Audio
<Audio src={staticFile("narration.mp3")} volume={0.8} startFrom={0} />

// Dynamic volume (callback receives LOCAL frame)
<Audio
  src={staticFile("bg-music.mp3")}
  volume={(f) => interpolate(f, [0, 30], [0, 0.15], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  })}
/>

// Video embed
<Video src={staticFile("screen-recording.mp4")} style={{ objectFit: 'contain' }} />

RULE: audio files must be MP3 or WAV — other formats may fail during render
RULE: use staticFile() for all assets in /public — never dynamic import()
RULE: background music volume: 0.10-0.20 when narration is active
RULE: use volume callback for fades and ducking — not separate audio processing


REMOTION:COMMON_PATTERNS

ANIMATED_TEXT

interface AnimatedTextProps {
  text: string;
  delay?: number;
  fontSize?: number;
  color?: string;
}

export const AnimatedText = ({
  text,
  delay = 0,
  fontSize = 48,
  color = '#FFFFFF',
}: AnimatedTextProps) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const opacity = interpolate(frame - delay, [0, 15], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const y = spring({
    frame: frame - delay,
    fps,
    config: { damping: 12, stiffness: 100, mass: 0.8 },
  });

  const translateY = interpolate(y, [0, 1], [30, 0]);

  return (
    <div
      style={{
        opacity,
        transform: `translateY(${translateY}px)`,
        fontSize,
        color,
        fontWeight: 700,
        fontFamily: 'Inter, sans-serif',
      }}
    >
      {text}
    </div>
  );
};

WORD_BY_WORD_REVEAL

export const WordByWord = ({ text, startFrame = 0 }: { text: string; startFrame?: number }) => {
  const frame = useCurrentFrame();
  const words = text.split(' ');
  const framesPerWord = 4;

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
      {words.map((word, i) => {
        const wordStart = startFrame + i * framesPerWord;
        const opacity = interpolate(frame, [wordStart, wordStart + 6], [0, 1], {
          extrapolateLeft: 'clamp',
          extrapolateRight: 'clamp',
        });
        return (
          <span key={i} style={{ opacity, fontSize: 48, fontWeight: 700, color: '#FFF' }}>
            {word}
          </span>
        );
      })}
    </div>
  );
};

DATA_VISUALIZATION

ANIMATED_BAR_CHART:

interface BarData {
  label: string;
  value: number;
  color: string;
}

export const BarChart = ({ data, maxValue }: { data: BarData[]; maxValue: number }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 400 }}>
      {data.map((d, i) => {
        const progress = spring({
          frame: frame - i * 5,
          fps,
          config: { damping: 12, stiffness: 80, mass: 1 },
        });
        const height = (d.value / maxValue) * 360 * progress;

        return (
          <div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
            <div
              style={{
                width: 60,
                height,
                background: d.color,
                borderRadius: '8px 8px 0 0',
              }}
            />
            <span style={{ marginTop: 8, fontSize: 14, color: '#94A3B8' }}>{d.label}</span>
          </div>
        );
      })}
    </div>
  );
};

ANIMATED_COUNTER:

export const Counter = ({ target, duration = 60 }: { target: number; duration?: number }) => {
  const frame = useCurrentFrame();
  const value = Math.round(
    interpolate(frame, [0, duration], [0, target], {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
    })
  );

  return (
    <span style={{ fontSize: 72, fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}>
      {value.toLocaleString()}
    </span>
  );
};

SCREEN_RECORDING_OVERLAY

export const ScreenRecordingWithCallouts = () => (
  <AbsoluteFill>
    <Video src={staticFile("screen-recording.mp4")} style={{ objectFit: 'contain' }} />

    {/* Cursor highlight */}
    <Sequence from={90} durationInFrames={60}>
      <CursorHighlight x={400} y={300} />
    </Sequence>

    {/* Callout annotation */}
    <Sequence from={120} durationInFrames={90}>
      <div style={{ position: 'absolute', bottom: 60, left: 40, right: 40 }}>
        <AnimatedText text="Click the settings icon to configure your workspace" fontSize={32} />
      </div>
    </Sequence>

    {/* Zoom into area */}
    <Sequence from={180} durationInFrames={120}>
      <ZoomArea x={350} y={250} width={400} height={300} />
    </Sequence>
  </AbsoluteFill>
);

COUNTDOWN_TIMER

export const Countdown = ({ from, fontSize = 120 }: { from: number; fontSize?: number }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const secondsRemaining = Math.ceil(from - frame / fps);

  if (secondsRemaining <= 0) return null;

  const progress = (frame % fps) / fps;
  const scale = interpolate(progress, [0, 0.5, 1], [1, 1.1, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
      <span
        style={{
          fontSize,
          fontWeight: 900,
          color: '#FFF',
          transform: `scale(${scale})`,
          fontVariantNumeric: 'tabular-nums',
        }}
      >
        {secondsRemaining}
      </span>
    </AbsoluteFill>
  );
};

PROGRESS_BAR

export const ProgressBar = ({
  totalFrames,
  color = '#2563EB',
  height = 6,
}: {
  totalFrames: number;
  color?: string;
  height?: number;
}) => {
  const frame = useCurrentFrame();
  const progress = interpolate(frame, [0, totalFrames], [0, 100], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div
      style={{
        position: 'absolute',
        bottom: 0,
        left: 0,
        right: 0,
        height,
        background: 'rgba(255,255,255,0.2)',
      }}
    >
      <div style={{ width: `${progress}%`, height: '100%', background: color }} />
    </div>
  );
};

TRANSITIONS

CROSSFADE:

export const Crossfade = ({
  children,
  durationInFrames,
}: {
  children: [React.ReactNode, React.ReactNode];
  durationInFrames: number;
}) => {
  const frame = useCurrentFrame();
  const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill>
      <AbsoluteFill style={{ opacity: 1 - progress }}>{children[0]}</AbsoluteFill>
      <AbsoluteFill style={{ opacity: progress }}>{children[1]}</AbsoluteFill>
    </AbsoluteFill>
  );
};

SLIDE_TRANSITION:

export const SlideIn = ({
  children,
  direction = 'left',
}: {
  children: React.ReactNode;
  direction?: 'left' | 'right' | 'up' | 'down';
}) => {
  const frame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();

  const progress = spring({ frame, fps, config: { damping: 15, stiffness: 100, mass: 1 } });

  const transforms = {
    left: `translateX(${interpolate(progress, [0, 1], [-width, 0])}px)`,
    right: `translateX(${interpolate(progress, [0, 1], [width, 0])}px)`,
    up: `translateY(${interpolate(progress, [0, 1], [-height, 0])}px)`,
    down: `translateY(${interpolate(progress, [0, 1], [height, 0])}px)`,
  };

  return (
    <AbsoluteFill style={{ transform: transforms[direction] }}>
      {children}
    </AbsoluteFill>
  );
};


REMOTION:AUDIO_INTEGRATION

PATTERNS

LAYERED_AUDIO:

export const VideoWithAudio = () => {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();

  // Fade out music at the end
  const musicVolume = interpolate(
    frame,
    [0, 30, durationInFrames - 30, durationInFrames],
    [0, 0.15, 0.15, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  return (
    <>
      {/* Background music - full duration, fades in and out */}
      <Audio src={staticFile("audio/bg-music.mp3")} volume={musicVolume} />

      {/* Narration segments */}
      <Sequence from={30}>
        <Audio src={staticFile("audio/narration-intro.mp3")} volume={0.9} />
      </Sequence>

      <Sequence from={150}>
        <Audio src={staticFile("audio/narration-main.mp3")} volume={0.9} />
      </Sequence>

      {/* Sound effects */}
      <Sequence from={60}>
        <Audio src={staticFile("audio/whoosh.mp3")} volume={0.5} />
      </Sequence>

      {/* Visual content */}
      <Sequence from={0} durationInFrames={90}>
        <Intro />
      </Sequence>
      <Sequence from={90}>
        <MainContent />
      </Sequence>
    </>
  );
};

AUDIO_DUCKING (lower music when narration plays):

const narrationActive = frame >= 30 && frame <= 180;
const musicVolume = narrationActive ? 0.08 : 0.20;

RULE: music 0.10-0.20 without narration, 0.05-0.10 with narration
RULE: audio files in MP3 or WAV only — other formats may fail during render
RULE: normalize all audio to -16 LUFS before importing


REMOTION:RENDERING

LOCAL_CLI

BASIC:

npx remotion render src/Root.tsx MyVideo out/video.mp4

WITH_OPTIONS:

npx remotion render src/Root.tsx MyVideo out/video.mp4 \
  --codec h264 \
  --crf 18 \
  --image-format jpeg \
  --concurrency 4 \
  --gl angle

FLAGS:
- --codec: h264 (default, wide compat), h265 (smaller), vp8/vp9 (WebM), prores (Apple)
- --crf: 0-51. Lower = better quality, larger file. 18 = visually lossless for h264.
- --image-format: jpeg (faster) or png (better for graphics/text)
- --concurrency: parallel frame count. Default: CPU / 2. Increase for faster renders.
- --every-nth-frame: render every Nth frame (for quick previews)
- --gl: "angle" | "egl" | "swangle" — GPU backend
- --props: JSON string of props to inject into composition

RULE: always specify --gl angle on headless Linux (GitHub Actions, CI servers)
RULE: CRF 18-23 for web delivery, CRF 15-18 for archival
RULE: use --image-format png when video contains text or sharp edges

REMOTION_LAMBDA

Cloud rendering on AWS Lambda — 200+ concurrent workers.

SETUP:

npx remotion lambda policies role    # create IAM role
npx remotion lambda policies user    # create IAM user
npx remotion lambda sites create src/Root.tsx --site-name my-video
npx remotion lambda functions deploy

RENDER:

npx remotion lambda render \
  --function-name remotion-render-func \
  --composition MyVideo \
  --props '{"title": "Custom Title"}'

PERFORMANCE:
- 5-minute video renders in ~30 seconds
- cost: ~$0.01-0.05 per render
- requires AWS account with Lambda + S3 permissions

RULE: use Lambda for CI/CD — local rendering blocks the pipeline
RULE: use Lambda for long videos (> 30 seconds)
RULE: local rendering is fine for development and short videos

STILL_RENDERING

Generate a single frame as an image (for thumbnails, poster frames, social cards):

npx remotion still src/Root.tsx Thumbnail out/thumbnail.png --frame 0

RULE: register a dedicated Thumbnail composition for poster frames
RULE: social media cards should be rendered as stills, not extracted from video


REMOTION:CI_CD

GITHUB_ACTIONS

name: Render Video
on:
  push:
    paths: ['video/**']

jobs:
  render:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
          cache-dependency-path: video/package-lock.json
      - run: cd video && npm ci
      - run: cd video && npx remotion render src/Root.tsx MyVideo out/video.mp4 --gl angle
      - uses: actions/upload-artifact@v4
        with:
          name: rendered-video
          path: video/out/video.mp4

WITH_LAMBDA:

  render-lambda:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: cd video && npm ci
      - run: cd video && npx remotion lambda render --function-name remotion-render-func --composition MyVideo
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

RULE: --gl angle is required on GitHub Actions / headless Linux
RULE: cache node_modules to speed up CI
RULE: for long videos, use Lambda to avoid CI timeout (default 6 hours, but wasteful)


REMOTION:PERFORMANCE_OPTIMIZATION

RENDERING_SPEED

FAST_RENDER:
- increase --concurrency (but watch RAM usage — each worker needs ~500MB)
- use --image-format jpeg for photographic content (faster than png)
- reduce durationInFrames where possible (fewer frames = faster)
- simplify React component tree — fewer DOM nodes per frame

SLOW_RENDER_DIAGNOSIS:
- check if fonts are loading on every frame (load once in Root)
- check for expensive computations in render path (memoize with useMemo)
- check for large images being processed every frame (preload and cache)

MEMORY_MANAGEMENT

RULE: avoid creating new objects in the render path — reuse references
RULE: large datasets should be imported once at module level, not per-frame
RULE: for data-driven videos, pass data as defaultProps — not fetched during render

FONT_LOADING

// src/lib/fonts.ts
import { staticFile } from 'remotion';

const fontFamily = 'Inter';
const fontUrl = staticFile('fonts/Inter-Variable.woff2');

export const loadFont = () => {
  const font = new FontFace(fontFamily, `url(${fontUrl})`);
  return font.load().then((loaded) => {
    document.fonts.add(loaded);
  });
};
// In Root.tsx
import { continueRender, delayRender } from 'remotion';
import { loadFont } from './lib/fonts';

const handle = delayRender();
loadFont().then(() => continueRender(handle));

RULE: always use delayRender / continueRender for async operations
RULE: font loading MUST complete before rendering begins — otherwise text renders as fallback


REMOTION:ANTI_PATTERNS

ANTI_PATTERN: using setTimeout or setInterval
FIX: use useCurrentFrame() — Remotion renders frame-by-frame, not in real time

ANTI_PATTERN: using CSS transition or animation
FIX: all animation via interpolate() / spring() driven by frame number

ANTI_PATTERN: using useState for animation state
FIX: derive all state from useCurrentFrame() — no stateful animations

ANTI_PATTERN: fetching data during render
FIX: pass data as defaultProps or import statically

ANTI_PATTERN: dynamic import() for assets
FIX: use staticFile() for assets in /public

ANTI_PATTERN: forgetting extrapolateLeft/Right: 'clamp'
FIX: always set both clamp options on every interpolate() call

ANTI_PATTERN: hardcoding frame numbers in components
FIX: use Sequence for timing, constants for durations


CROSS_REFERENCES