
Remotion
- 97 installs
- 17.2k repo stars
- Updated August 1, 2026
- danielmiessler/personal_ai_infrastructure
Creates videos programmatically with React via Remotion, building compositions, animations, and motion graphics rendered to MP4.
About
Creates professional videos programmatically with React using Remotion, producing compositions, animations, and motion graphics rendered to MP4. A developer uses it to generate intros, social videos, and animated content from content.
- React-based compositions rendered to MP4
- ContentToAnimation workflow for turning content into video
Remotion by the numbers
- 97 all-time installs (skills.sh)
- Ranked #792 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/danielmiessler/personal_ai_infrastructure --skill remotionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 97 |
|---|---|
| repo stars | ★ 17.2k |
| Last updated | August 1, 2026 |
| Repository | danielmiessler/personal_ai_infrastructure ↗ |
What it does
Creates videos programmatically with React via Remotion, building compositions, animations, and motion graphics rendered to MP4.
Files
🚨 MANDATORY: Voice Notification (REQUIRED BEFORE ANY ACTION)
You MUST send this notification BEFORE doing anything else when this skill is invoked.
1. Send voice notification:
curl -s -X POST http://localhost:8888/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the WORKFLOWNAME workflow in the Remotion skill to ACTION"}' \
> /dev/null 2>&1 &2. Output text notification:
Running the **WorkflowName** workflow in the **Remotion** skill to ACTION...This is not optional. Execute this curl command immediately upon skill invocation.
Remotion
Create professional videos programmatically with React.
Customization
Before executing, check for user customizations at: ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Remotion/
Workflow Routing
| Trigger | Workflow |
|---|---|
| "animate this", "create animations for", "video overlay" | Workflows/ContentToAnimation.md |
Quick Reference
- Theme: Always use PAI_THEME from
Tools/Theme.ts - Art Integration: Load Art preferences before creating content
- Critical: NO CSS animations - use
useCurrentFrame()only - Output: Always to
~/Downloads/first
Render command:
npx remotion render {composition-id} ~/Downloads/{name}.mp4Full Documentation
- Art integration:
ArtIntegration.md- theme constants, color mapping - Common patterns:
Patterns.md- code examples, presets - Critical rules:
CriticalRules.md- what NOT to do - Detailed reference:
Tools/Ref-*.md- 28 pattern files from Remotion
Tools
| Tool | Purpose |
|---|---|
Tools/Render.ts | Render, list compositions, create projects |
Tools/Theme.ts | PAI theme constants derived from Art |
Links
- Remotion Docs: https://remotion.dev/docs
- GitHub: https://github.com/remotion-dev/remotion
Art Skill Integration
MANDATORY: This skill inherits visual theming from the Art skill.
Before Creating Any Video Content
1. Load Art preferences:
~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/PREFERENCES.md2. Apply the PAI Theme derived from Art preferences:
| Art Preference | Remotion Application |
|---|---|
| Core aesthetic (charcoal architectural) | Dark backgrounds, sketch-like feel |
| Primary accent (purple/violet) | Accent colors, highlights, CTAs |
| Cool atmospheric washes | Background gradients, overlays |
| Paper ground (#F5F5F0) | Light text, subtle backgrounds |
| Human-scale in vast spaces | Typography hierarchy, spacing |
3. Use Theme Constants:
~/.claude/skills/Media/Remotion/Tools/Theme.ts4. Reference images (when visual style reference needed):
~/.claude/skills/Media/Art/Examples/PAI Theme Quick Reference
import { PAI_THEME } from '~/.claude/skills/Media/Remotion/Tools/Theme'
// Colors
PAI_THEME.colors.background // #0f172a - Deep slate
PAI_THEME.colors.accent // #8b5cf6 - Purple/violet
PAI_THEME.colors.text // #f1f5f9 - Light text
PAI_THEME.colors.textMuted // #94a3b8 - Muted text
// Typography
PAI_THEME.typography.title // { fontSize: 72, fontWeight: 'bold' }
PAI_THEME.typography.subtitle // { fontSize: 36 }
PAI_THEME.typography.body // { fontSize: 24 }
// Animation
PAI_THEME.animation.springDefault // { damping: 12, stiffness: 100 }
PAI_THEME.animation.fadeFrames // 30 frames (~1 second)
PAI_THEME.animation.staggerDelay // 10 frames
// Spacing
PAI_THEME.spacing.page // 100px edge padding
PAI_THEME.spacing.section // 60px between sections
PAI_THEME.spacing.element // 30px between elementsUsing the Theme in Components
import { PAI_THEME, titleScreenStyle, fadeInterpolation } from '~/.claude/skills/Media/Remotion/Tools/Theme'
export const MyScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const opacity = interpolate(
frame,
fadeInterpolation().inputRange,
fadeInterpolation().outputRange,
{ extrapolateRight: 'clamp' }
)
const scale = spring({
frame, fps,
config: PAI_THEME.animation.springDefault
})
return (
<AbsoluteFill style={titleScreenStyle}>
<h1 style={{
...PAI_THEME.typography.title,
color: PAI_THEME.colors.text,
opacity,
transform: `scale(${scale})`
}}>
Title Here
</h1>
</AbsoluteFill>
)
}All videos MUST use this theme unless explicitly overridden.
When to use
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
How to use
Read individual rule files for detailed explanations and code examples:
- rules/3d.md - 3D content in Remotion using Three.js and React Three Fiber
- rules/animations.md - Fundamental animation skills for Remotion
- rules/assets.md - Importing images, videos, audio, and fonts into Remotion
- rules/audio.md - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
- rules/calculate-metadata.md - Dynamically set composition duration, dimensions, and props
- rules/can-decode.md - Check if a video can be decoded by the browser using Mediabunny
- rules/charts.md - Chart and data visualization patterns for Remotion
- rules/compositions.md - Defining compositions, stills, folders, default props and dynamic metadata
- rules/display-captions.md - Displaying captions in Remotion with TikTok-style pages and word highlighting
- rules/extract-frames.md - Extract frames from videos at specific timestamps using Mediabunny
- rules/fonts.md - Loading Google Fonts and local fonts in Remotion
- rules/get-audio-duration.md - Getting the duration of an audio file in seconds with Mediabunny
- rules/get-video-dimensions.md - Getting the width and height of a video file with Mediabunny
- rules/get-video-duration.md - Getting the duration of a video file in seconds with Mediabunny
- rules/gifs.md - Displaying GIFs synchronized with Remotion's timeline
- rules/images.md - Embedding images in Remotion using the Img component
- rules/import-srt-captions.md - Importing .srt subtitle files into Remotion using @remotion/captions
- rules/lottie.md - Embedding Lottie animations in Remotion
- rules/measuring-dom-nodes.md - Measuring DOM element dimensions in Remotion
- rules/measuring-text.md - Measuring text dimensions, fitting text to containers, and checking overflow
- rules/sequencing.md - Sequencing patterns for Remotion - delay, trim, limit duration of items
- rules/tailwind.md - Using TailwindCSS in Remotion
- rules/text-animations.md - Typography and text animation patterns for Remotion
- rules/timing.md - Interpolation curves in Remotion - linear, easing, spring animations
- rules/transcribe-captions.md - Transcribing audio to generate captions in Remotion
- rules/transitions.md - Scene transition patterns for Remotion
- rules/trimming.md - Trimming patterns for Remotion - cut the beginning or end of animations
- rules/videos.md - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
Remotion Patterns
Common patterns and examples for Remotion video creation.
Basic Component Structure
import { useCurrentFrame, useVideoConfig, AbsoluteFill, interpolate } from 'remotion'
export const MyVideo: React.FC = () => {
const frame = useCurrentFrame()
const { fps, durationInFrames, width, height } = useVideoConfig()
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: 'clamp'
})
return (
<AbsoluteFill style={{ backgroundColor: 'white' }}>
<h1 style={{ opacity }}>Hello World</h1>
</AbsoluteFill>
)
}Register Composition
// src/Root.tsx
import { Composition } from 'remotion'
import { MyVideo } from './MyVideo'
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="my-video"
component={MyVideo}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
/>
)
}Fade In Text
const frame = useCurrentFrame()
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: 'clamp' })
<h1 style={{ opacity }}>Fade In</h1>Spring Animation
import { spring, useCurrentFrame, useVideoConfig } from 'remotion'
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const scale = spring({
frame,
fps,
from: 0,
to: 1,
config: { damping: 10, stiffness: 100 }
})
<div style={{ transform: `scale(${scale})` }}>Bounce In</div>Sequence Multiple Elements
import { Sequence } from 'remotion'
<Sequence from={0} durationInFrames={60}>
<Title />
</Sequence>
<Sequence from={60} durationInFrames={90}>
<Content />
</Sequence>
<Sequence from={150}>
<Outro />
</Sequence>Audio with Video
import { Audio, Video, staticFile } from 'remotion'
<Video src={staticFile('video.mp4')} volume={0.5} />
<Audio src={staticFile('music.mp3')} volume={0.3} startFrom={30} />Video Size Presets
// YouTube
{ width: 1920, height: 1080 } // 16:9 landscape
{ width: 1080, height: 1920 } // 9:16 Shorts
// TikTok/Reels
{ width: 1080, height: 1920 } // 9:16 portrait
// Instagram
{ width: 1080, height: 1080 } // 1:1 square
{ width: 1080, height: 1350 } // 4:5 portrait
// Twitter/X
{ width: 1280, height: 720 } // 16:9 landscapeCritical Rules
1. NO CSS animations - They won't render. Use useCurrentFrame() for all animations. 2. NO third-party animation libraries - They cause flickering. Drive animations from frame. 3. Use `staticFile()` - For assets in /public directory. 4. Extrapolate carefully - Use extrapolateRight: 'clamp' to prevent overflow. 5. Props with Zod - Define schemas for type-safe, configurable compositions.
Reference Documentation
For detailed patterns on specific topics, see:
~/.claude/skills/Media/Remotion/Tools/Reference/Topics include: animations, audio, 3d, charts, captions, fonts, transitions, and more.
{
"name": "@pai/remotion",
"version": "1.0.0",
"description": "PAI Remotion skill - programmatic video creation with React",
"main": "index.ts",
"type": "module",
"scripts": {
"render": "bun run index.ts render",
"list": "bun run index.ts list",
"create": "bun run index.ts create"
},
"keywords": [
"remotion",
"video",
"react",
"animation",
"pai",
"skill"
],
"dependencies": {},
"peerDependencies": {
"remotion": ">=4.0.0"
},
"devDependencies": {
"bun-types": "latest",
"typescript": "^5.0.0"
}
}
Using Three.js and React Three Fiber in Remotion
Follow React Three Fiber and Three.js best practices. Only the following Remotion-specific rules need to be followed:
Prerequisites
First, the @remotion/three package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/three # If project uses npm
bunx remotion add @remotion/three # If project uses bun
yarn remotion add @remotion/three # If project uses yarn
pnpm exec remotion add @remotion/three # If project uses pnpmUsing ThreeCanvas
You MUST wrap 3D content in <ThreeCanvas> and include proper lighting. <ThreeCanvas> MUST have a width and height prop.
import { ThreeCanvas } from "@remotion/three";
import { useVideoConfig } from "remotion";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={0.8} />
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="red" />
</mesh>
</ThreeCanvas>No animations not driven by useCurrentFrame()
Shaders, models etc MUST NOT animate by themselves. No animations are allowed unless they are driven by useCurrentFrame(). Otherwise, it will cause flickering during rendering.
Using useFrame() from @react-three/fiber is forbidden.
Animate using useCurrentFrame()
Use useCurrentFrame() to perform animations.
const frame = useCurrentFrame();
const rotationY = frame * 0.02;
<mesh rotation={[0, rotationY, 0]}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>Using <Sequence> inside <ThreeCanvas>
The layout prop of any <Sequence> inside a <ThreeCanvas> must be set to none.
import { Sequence } from "remotion";
import { ThreeCanvas } from "@remotion/three";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<Sequence layout="none">
<mesh>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>
</Sequence>
</ThreeCanvas>All animations MUST be driven by the useCurrentFrame() hook. Write animations in seconds and multiply them by the fps value from useVideoConfig().
import { useCurrentFrame } from "remotion";
export const FadeIn = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<div style={{ opacity }}>Hello World!</div>
);
};CSS transitions or animations are FORBIDDEN - they will not render correctly. Tailwind animation class names are FORBIDDEN - they will not render correctly.
Importing assets in Remotion
The public folder
Place assets in the public/ folder at your project root.
Using staticFile()
You MUST use staticFile() to reference files from the public/ folder:
import {Img, staticFile} from 'remotion';
export const MyComposition = () => {
return <Img src={staticFile('logo.png')} />;
};The function returns an encoded URL that works correctly when deploying to subdirectories.
Using with components
Images:
import {Img, staticFile} from 'remotion';
<Img src={staticFile('photo.png')} />;Videos:
import {Video} from '@remotion/media';
import {staticFile} from 'remotion';
<Video src={staticFile('clip.mp4')} />;Audio:
import {Audio} from '@remotion/media';
import {staticFile} from 'remotion';
<Audio src={staticFile('music.mp3')} />;Fonts:
import {staticFile} from 'remotion';
const fontFamily = new FontFace('MyFont', `url(${staticFile('font.woff2')})`);
await fontFamily.load();
document.fonts.add(fontFamily);Remote URLs
Remote URLs can be used directly without staticFile():
<Img src="https://example.com/image.png" />
<Video src="https://remotion.media/video.mp4" />Important notes
- Remotion components (
<Img>,<Video>,<Audio>) ensure assets are fully loaded before rendering - Special characters in filenames (
#,?,&) are automatically encoded
Using audio in Remotion
Prerequisites
First, the @remotion/media package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/media # If project uses npm
bunx remotion add @remotion/media # If project uses bun
yarn remotion add @remotion/media # If project uses yarn
pnpm exec remotion add @remotion/media # If project uses pnpmImporting Audio
Use <Audio> from @remotion/media to add audio to your composition.
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Audio src={staticFile("audio.mp3")} />;
};Remote URLs are also supported:
<Audio src="https://remotion.media/audio.mp3" />By default, audio plays from the start, at full volume and full length. Multiple audio tracks can be layered by adding multiple <Audio> components.
Trimming
Use trimBefore and trimAfter to remove portions of the audio. Values are in frames.
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
trimBefore={2 * fps} // Skip the first 2 seconds
trimAfter={10 * fps} // End at the 10 second mark
/>
);The audio still starts playing at the beginning of the composition - only the specified portion is played.
Delaying
Wrap the audio in a <Sequence> to delay when it starts:
import { Sequence, staticFile } from "remotion";
import { Audio } from "@remotion/media";
const { fps } = useVideoConfig();
return (
<Sequence from={1 * fps}>
<Audio src={staticFile("audio.mp3")} />
</Sequence>
);The audio will start playing after 1 second.
Volume
Set a static volume (0 to 1):
<Audio src={staticFile("audio.mp3")} volume={0.5} />Or use a callback for dynamic volume based on the current frame:
import { interpolate } from "remotion";
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
volume={(f) =>
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
}
/>
);The value of f starts at 0 when the audio begins to play, not the composition frame.
Muting
Use muted to silence the audio. It can be set dynamically:
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
/>
);Speed
Use playbackRate to change the playback speed:
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}Reverse playback is not supported.
Looping
Use loop to loop the audio indefinitely:
<Audio src={staticFile("audio.mp3")} loop />Use loopVolumeCurveBehavior to control how the frame count behaves when looping:
"repeat": Frame count resets to 0 each loop (default)"extend": Frame count continues incrementing
<Audio
src={staticFile("audio.mp3")}
loop
loopVolumeCurveBehavior="extend"
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
/>Pitch
Use toneFrequency to adjust the pitch without affecting speed. Values range from 0.01 to 2:
<Audio
src={staticFile("audio.mp3")}
toneFrequency={1.5} // Higher pitch
/>
<Audio
src={staticFile("audio.mp3")}
toneFrequency={0.8} // Lower pitch
/>Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the <Player />.
Using calculateMetadata
Use calculateMetadata on a <Composition> to dynamically set duration, dimensions, and transform props before rendering.
<Composition id="MyComp" component={MyComponent} durationInFrames={300} fps={30} width={1920} height={1080} defaultProps={{videoSrc: 'https://remotion.media/video.mp4'}} calculateMetadata={calculateMetadata} />Setting duration based on a video
Use the getMediaMetadata() function from the mediabunny/metadata skill to get the video duration:
import {CalculateMetadataFunction} from 'remotion';
import {getMediaMetadata} from '../get-media-metadata';
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
const {durationInSeconds} = await getMediaMetadata(props.videoSrc);
return {
durationInFrames: Math.ceil(durationInSeconds * 30),
};
};Matching dimensions of a video
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
const {durationInSeconds, dimensions} = await getMediaMetadata(props.videoSrc);
return {
durationInFrames: Math.ceil(durationInSeconds * 30),
width: dimensions?.width ?? 1920,
height: dimensions?.height ?? 1080,
};
};Setting duration based on multiple videos
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
const metadataPromises = props.videos.map((video) => getMediaMetadata(video.src));
const allMetadata = await Promise.all(metadataPromises);
const totalDuration = allMetadata.reduce((sum, meta) => sum + meta.durationInSeconds, 0);
return {
durationInFrames: Math.ceil(totalDuration * 30),
};
};Setting a default outName
Set the default output filename based on props:
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
return {
defaultOutName: `video-${props.id}.mp4`,
};
};Transforming props
Fetch data or transform props before rendering:
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props, abortSignal}) => {
const response = await fetch(props.dataUrl, {signal: abortSignal});
const data = await response.json();
return {
props: {
...props,
fetchedData: data,
},
};
};The abortSignal cancels stale requests when props change in the Studio.
Return value
All fields are optional. Returned values override the <Composition> props:
durationInFrames: Number of frameswidth: Composition width in pixelsheight: Composition height in pixelsfps: Frames per secondprops: Transformed props passed to the componentdefaultOutName: Default output filenamedefaultCodec: Default codec for rendering
Checking if a video can be decoded
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
The canDecode() function
This function can be copy-pasted into any project.
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const canDecode = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
try {
await input.getFormat();
} catch {
return false;
}
const videoTrack = await input.getPrimaryVideoTrack();
if (videoTrack && !(await videoTrack.canDecode())) {
return false;
}
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack && !(await audioTrack.canDecode())) {
return false;
}
return true;
};Usage
const src = "https://remotion.media/video.mp4";
const isDecodable = await canDecode(src);
if (isDecodable) {
console.log("Video can be decoded");
} else {
console.log("Video cannot be decoded by this browser");
}Using with Blob
For file uploads or drag-and-drop, use BlobSource:
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
export const canDecodeBlob = async (blob: Blob) => {
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(blob),
});
// Same validation logic as above
};Charts in Remotion
You can create bar charts in Remotion by using regular React code - HTML and SVG is allowed, as well as D3.js.
No animations not powered by useCurrentFrame()
Disable all animations by third party libraries. They will cause flickering during rendering. Instead, drive all animations from useCurrentFrame().
Bar Chart Animations
See Bar Chart Example for a basic example implmentation.
Staggered Bars
You can animate the height of the bars and stagger them like this:
const STAGGER_DELAY = 5;
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const bars = data.map((item, i) => {
const delay = i * STAGGER_DELAY;
const height = spring({
frame,
fps,
delay,
config: {damping: 200},
});
return <div style={{height: height * item.value}} />;
});Pie Chart Animation
Animate segments using stroke-dashoffset, starting from 12 o'clock.
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const progress = interpolate(frame, [0, 100], [0, 1]);
const circumference = 2 * Math.PI * radius;
const segmentLength = (value / total) * circumference;
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
<circle r={radius} cx={center} cy={center} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={`${segmentLength} ${circumference}`} strokeDashoffset={offset} transform={`rotate(-90 ${center} ${center})`} />;A <Composition> defines the component, width, height, fps and duration of a renderable video.
It normally is placed in the src/Root.tsx file.
import { Composition } from "remotion";
import { MyComposition } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
/>
);
};Default Props
Pass defaultProps to provide initial values for your component. Values must be JSON-serializable (Date, Map, Set, and staticFile() are supported).
import { Composition } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
defaultProps={{
title: "Hello World",
color: "#ff0000",
} satisfies MyCompositionProps}
/>
);
};Use type declarations for props rather than interface to ensure defaultProps type safety.
Folders
Use <Folder> to organize compositions in the sidebar. Folder names can only contain letters, numbers, and hyphens.
import { Composition, Folder } from "remotion";
export const RemotionRoot = () => {
return (
<>
<Folder name="Marketing">
<Composition id="Promo" /* ... */ />
<Composition id="Ad" /* ... */ />
</Folder>
<Folder name="Social">
<Folder name="Instagram">
<Composition id="Story" /* ... */ />
<Composition id="Reel" /* ... */ />
</Folder>
</Folder>
</>
);
};Stills
Use <Still> for single-frame images. It does not require durationInFrames or fps.
import { Still } from "remotion";
import { Thumbnail } from "./Thumbnail";
export const RemotionRoot = () => {
return (
<Still
id="Thumbnail"
component={Thumbnail}
width={1280}
height={720}
/>
);
};Calculate Metadata
Use calculateMetadata to make dimensions, duration, or props dynamic based on data.
import { Composition, CalculateMetadataFunction } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
const calculateMetadata: CalculateMetadataFunction<MyCompositionProps> = async ({
props,
abortSignal,
}) => {
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
signal: abortSignal,
}).then((res) => res.json());
return {
durationInFrames: Math.ceil(data.duration * 30),
props: {
...props,
videoUrl: data.url,
},
};
};
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100} // Placeholder, will be overridden
fps={30}
width={1080}
height={1080}
defaultProps={{ videoId: "abc123" }}
calculateMetadata={calculateMetadata}
/>
);
};The function can return props, durationInFrames, width, height, fps, and codec-related defaults. It runs once before rendering begins.
Displaying captions in Remotion
This guide explains how to display captions in Remotion, assuming you already have captions in the Caption format.
Prerequisites
First, the @remotion/captions package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/captions # If project uses npm
bunx remotion add @remotion/captions # If project uses bun
yarn remotion add @remotion/captions # If project uses yarn
pnpm exec remotion add @remotion/captions # If project uses pnpmCreating pages
Use createTikTokStyleCaptions() to group captions into pages. The combineTokensWithinMilliseconds option controls how many words appear at once:
import {useMemo} from 'react';
import {createTikTokStyleCaptions} from '@remotion/captions';
import type {Caption} from '@remotion/captions';
// How often captions should switch (in milliseconds)
// Higher values = more words per page
// Lower values = fewer words (more word-by-word)
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const {pages} = useMemo(() => {
return createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);Rendering with Sequences
Map over the pages and render each one in a <Sequence>. Calculate the start frame and duration from the page timing:
import {Sequence, useVideoConfig, AbsoluteFill} from 'remotion';
import type {TikTokPage} from '@remotion/captions';
const CaptionedContent: React.FC = () => {
const {fps} = useVideoConfig();
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};Word highlighting
A caption page contains tokens which you can use to highlight the currently spoken word:
import {AbsoluteFill, useCurrentFrame, useVideoConfig} from 'remotion';
import type {TikTokPage} from '@remotion/captions';
const HIGHLIGHT_COLOR = '#39E508';
const CaptionPage: React.FC<{page: TikTokPage}> = ({page}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
// Current time relative to the start of the sequence
const currentTimeMs = (frame / fps) * 1000;
// Convert to absolute time by adding the page start
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill style={{justifyContent: 'center', alignItems: 'center'}}>
<div style={{fontSize: 80, fontWeight: 'bold', whiteSpace: 'pre'}}>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
return (
<span
key={token.fromMs}
style={{color: isActive ? HIGHLIGHT_COLOR : 'white'}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};Extracting frames from videos
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
The extractFrames() function
This function can be copy-pasted into any project.
import {
ALL_FORMATS,
Input,
UrlSource,
VideoSample,
VideoSampleSink,
} from "mediabunny";
type Options = {
track: { width: number; height: number };
container: string;
durationInSeconds: number | null;
};
export type ExtractFramesTimestampsInSecondsFn = (
options: Options
) => Promise<number[]> | number[];
export type ExtractFramesProps = {
src: string;
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
onVideoSample: (sample: VideoSample) => void;
signal?: AbortSignal;
};
export async function extractFrames({
src,
timestampsInSeconds,
onVideoSample,
signal,
}: ExtractFramesProps): Promise<void> {
using input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src),
});
const [durationInSeconds, format, videoTrack] = await Promise.all([
input.computeDuration(),
input.getFormat(),
input.getPrimaryVideoTrack(),
]);
if (!videoTrack) {
throw new Error("No video track found in the input");
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const timestamps =
typeof timestampsInSeconds === "function"
? await timestampsInSeconds({
track: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
container: format.name,
durationInSeconds,
})
: timestampsInSeconds;
if (timestamps.length === 0) {
return;
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const sink = new VideoSampleSink(videoTrack);
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
if (signal?.aborted) {
break;
}
if (!videoSample) {
continue;
}
onVideoSample(videoSample);
}
}Basic usage
Extract frames at specific timestamps:
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});Creating a filmstrip
Use a callback function to dynamically calculate timestamps based on video metadata:
const canvasWidth = 500;
const canvasHeight = 80;
const fromSeconds = 0;
const toSeconds = 10;
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: async ({ track, durationInSeconds }) => {
const aspectRatio = track.width / track.height;
const amountOfFramesFit = Math.ceil(
canvasWidth / (canvasHeight * aspectRatio)
);
const segmentDuration = toSeconds - fromSeconds;
const timestamps: number[] = [];
for (let i = 0; i < amountOfFramesFit; i++) {
timestamps.push(
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5)
);
}
return timestamps;
},
onVideoSample: (sample) => {
console.log(`Frame at ${sample.timestamp}s`);
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});Cancellation with AbortSignal
Cancel frame extraction after a timeout:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
});
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}Timeout with Promise.race
const controller = new AbortController();
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort();
reject(new Error("Frame extraction timed out after 10 seconds"));
}, 10000);
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
once: true,
});
});
try {
await Promise.race([
extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
}),
timeoutPromise,
]);
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}Using fonts in Remotion
Google Fonts with @remotion/google-fonts
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
Prerequisites
First, the @remotion/google-fonts package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/google-fonts # If project uses npm
bunx remotion add @remotion/google-fonts # If project uses bun
yarn remotion add @remotion/google-fonts # If project uses yarn
pnpm exec remotion add @remotion/google-fonts # If project uses pnpmimport { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily } = loadFont();
export const MyComposition = () => {
return <div style={{ fontFamily }}>Hello World</div>;
};Preferrably, specify only needed weights and subsets to reduce file size:
import { loadFont } from "@remotion/google-fonts/Roboto";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});Waiting for font to load
Use waitUntilDone() if you need to know when the font is ready:
import { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily, waitUntilDone } = loadFont();
await waitUntilDone();Local fonts with @remotion/fonts
For local font files, use the @remotion/fonts package.
Prerequisites
First, install @remotion/fonts:
npx remotion add @remotion/fonts # If project uses npm
bunx remotion add @remotion/fonts # If project uses bun
yarn remotion add @remotion/fonts # If project uses yarn
pnpm exec remotion add @remotion/fonts # If project uses pnpmLoading a local font
Place your font file in the public/ folder and use loadFont():
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await loadFont({
family: "MyFont",
url: staticFile("MyFont-Regular.woff2"),
});
export const MyComposition = () => {
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
};Loading multiple weights
Load each weight separately with the same family name:
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await Promise.all([
loadFont({
family: "Inter",
url: staticFile("Inter-Regular.woff2"),
weight: "400",
}),
loadFont({
family: "Inter",
url: staticFile("Inter-Bold.woff2"),
weight: "700",
}),
]);Available options
loadFont({
family: "MyFont", // Required: name to use in CSS
url: staticFile("font.woff2"), // Required: font file URL
format: "woff2", // Optional: auto-detected from extension
weight: "400", // Optional: font weight
style: "normal", // Optional: normal or italic
display: "block", // Optional: font-display behavior
});Using in components
Call loadFont() at the top level of your component or in a separate file that's imported early:
import { loadFont } from "@remotion/google-fonts/Montserrat";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
export const Title: React.FC<{ text: string }> = ({ text }) => {
return (
<h1
style={{
fontFamily,
fontSize: 80,
fontWeight: "bold",
}}
>
{text}
</h1>
);
};Getting audio duration with Mediabunny
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
Getting audio duration
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getAudioDuration = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const durationInSeconds = await input.computeDuration();
return durationInSeconds;
};Usage
const duration = await getAudioDuration("https://remotion.media/audio.mp3");
console.log(duration); // e.g. 180.5 (seconds)Using with local files
For local files, use FileSource instead of UrlSource:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const durationInSeconds = await input.computeDuration();Using with staticFile in Remotion
import { staticFile } from "remotion";
const duration = await getAudioDuration(staticFile("audio.mp3"));Getting video dimensions with Mediabunny
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
Getting video dimensions
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDimensions = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found");
}
return {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
};
};Usage
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
console.log(dimensions.width); // e.g. 1920
console.log(dimensions.height); // e.g. 1080Using with local files
For local files, use FileSource instead of UrlSource:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const videoTrack = await input.getPrimaryVideoTrack();
const width = videoTrack.displayWidth;
const height = videoTrack.displayHeight;Using with staticFile in Remotion
import { staticFile } from "remotion";
const dimensions = await getVideoDimensions(staticFile("video.mp4"));Getting video duration with Mediabunny
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
Getting video duration
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDuration = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const durationInSeconds = await input.computeDuration();
return durationInSeconds;
};Usage
const duration = await getVideoDuration("https://remotion.media/video.mp4");
console.log(duration); // e.g. 10.5 (seconds)Using with local files
For local files, use FileSource instead of UrlSource:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const durationInSeconds = await input.computeDuration();Using with staticFile in Remotion
import { staticFile } from "remotion";
const duration = await getVideoDuration(staticFile("video.mp4"));Using Animated images in Remotion
Basic usage
Use <AnimatedImage> to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
import {AnimatedImage, staticFile} from 'remotion';
export const MyComposition = () => {
return <AnimatedImage src={staticFile('animation.gif')} width={500} height={500} />;
};Remote URLs are also supported (must have CORS enabled):
<AnimatedImage src="https://example.com/animation.gif" width={500} height={500} />Sizing and fit
Control how the image fills its container with the fit prop:
// Stretch to fill (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
// Maintain aspect ratio, fit inside container
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
// Fill container, crop if needed
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />Playback speed
Use playbackRate to control the animation speed:
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}Looping behavior
Control what happens when the animation finishes:
// Loop indefinitely (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
// Play once, show final frame
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
// Play once, then clear canvas
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />Styling
Use the style prop for additional CSS (use width and height props for sizing):
<AnimatedImage
src={staticFile('animation.gif')}
width={500}
height={500}
style={{
borderRadius: 20,
position: 'absolute',
top: 100,
left: 50,
}}
/>Getting GIF duration
Use getGifDurationInSeconds() from @remotion/gif to get the duration of a GIF.
npx remotion add @remotion/gif # If project uses npm
bunx remotion add @remotion/gif # If project uses bun
yarn remotion add @remotion/gif # If project uses yarn
pnpm exec remotion add @remotion/gif # If project uses pnpmimport {getGifDurationInSeconds} from '@remotion/gif';
import {staticFile} from 'remotion';
const duration = await getGifDurationInSeconds(staticFile('animation.gif'));
console.log(duration); // e.g. 2.5This is useful for setting the composition duration to match the GIF:
import {getGifDurationInSeconds} from '@remotion/gif';
import {staticFile, CalculateMetadataFunction} from 'remotion';
const calculateMetadata: CalculateMetadataFunction = async () => {
const duration = await getGifDurationInSeconds(staticFile('animation.gif'));
return {
durationInFrames: Math.ceil(duration * 30),
};
};Alternative
If <AnimatedImage> does not work (only supported in Chrome and Firefox), you can use <Gif> from @remotion/gif instead.
npx remotion add @remotion/gif # If project uses npm
bunx remotion add @remotion/gif # If project uses bun
yarn remotion add @remotion/gif # If project uses yarn
pnpm exec remotion add @remotion/gif # If project uses pnpmimport {Gif} from '@remotion/gif';
import {staticFile} from 'remotion';
export const MyComposition = () => {
return <Gif src={staticFile('animation.gif')} width={500} height={500} />;
};The <Gif> component has the same props as <AnimatedImage> but only supports GIF files.
Using images in Remotion
The <Img> component
Always use the <Img> component from remotion to display images:
import { Img, staticFile } from "remotion";
export const MyComposition = () => {
return <Img src={staticFile("photo.png")} />;
};Important restrictions
You MUST use the `<Img>` component from `remotion`. Do not use:
- Native HTML
<img>elements - Next.js
<Image>component - CSS
background-image
The <Img> component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
Local images with staticFile()
Place images in the public/ folder and use staticFile() to reference them:
my-video/
├─ public/
│ ├─ logo.png
│ ├─ avatar.jpg
│ └─ icon.svg
├─ src/
├─ package.jsonimport { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} />Remote images
Remote URLs can be used directly without staticFile():
<Img src="https://example.com/image.png" />Ensure remote images have CORS enabled.
For animated GIFs, use the <Gif> component from @remotion/gif instead.
Sizing and positioning
Use the style prop to control size and position:
<Img
src={staticFile("photo.png")}
style={{
width: 500,
height: 300,
position: "absolute",
top: 100,
left: 50,
objectFit: "cover",
}}
/>Dynamic image paths
Use template literals for dynamic file references:
import { Img, staticFile, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// Image sequence
<Img src={staticFile(`frames/frame${frame}.png`)} />
// Selecting based on props
<Img src={staticFile(`avatars/${props.userId}.png`)} />
// Conditional images
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />This pattern is useful for:
- Image sequences (frame-by-frame animations)
- User-specific avatars or profile images
- Theme-based icons
- State-dependent graphics
Getting image dimensions
Use getImageDimensions() to get the dimensions of an image:
import { getImageDimensions, staticFile } from "remotion";
const { width, height } = await getImageDimensions(staticFile("photo.png"));This is useful for calculating aspect ratios or sizing compositions:
import { getImageDimensions, staticFile, CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const { width, height } = await getImageDimensions(staticFile("photo.png"));
return {
width,
height,
};
};Importing .srt subtitles into Remotion
If you have an existing .srt subtitle file, you can import it into Remotion using parseSrt() from @remotion/captions.
Prerequisites
First, the @remotion/captions package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/captions # If project uses npm
bunx remotion add @remotion/captions # If project uses bun
yarn remotion add @remotion/captions # If project uses yarn
pnpm exec remotion add @remotion/captions # If project uses pnpmReading an .srt file
Use staticFile() to reference an .srt file in your public folder, then fetch and parse it:
import {useState, useEffect, useCallback} from 'react';
import {AbsoluteFill, staticFile, useDelayRender} from 'remotion';
import {parseSrt} from '@remotion/captions';
import type {Caption} from '@remotion/captions';
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const {delayRender, continueRender, cancelRender} = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile('subtitles.srt'));
const text = await response.text();
const {captions: parsed} = parseSrt({input: text});
setCaptions(parsed);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
};Remote URLs are also supported - you can fetch() a remote file via URL instead of using staticFile().
Using imported captions
Once parsed, the captions are in the Caption format and can be used with all @remotion/captions utilities.
Using Lottie Animations in Remotion
Prerequisites
First, the @remotion/lottie package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/lottie # If project uses npm
bunx remotion add @remotion/lottie # If project uses bun
yarn remotion add @remotion/lottie # If project uses yarn
pnpm exec remotion add @remotion/lottie # If project uses pnpmDisplaying a Lottie file
To import a Lottie animation:
- Fetch the Lottie asset
- Wrap the loading process in
delayRender()andcontinueRender() - Save the animation data in a state
- Render the Lottie animation using the
Lottiecomponent from the@remotion/lottiepackage
import {Lottie, LottieAnimationData} from '@remotion/lottie';
import {useEffect, useState} from 'react';
import {cancelRender, continueRender, delayRender} from 'remotion';
export const MyAnimation = () => {
const [handle] = useState(() => delayRender('Loading Lottie animation'));
const [animationData, setAnimationData] = useState<LottieAnimationData | null>(null);
useEffect(() => {
fetch('https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json')
.then((data) => data.json())
.then((json) => {
setAnimationData(json);
continueRender(handle);
})
.catch((err) => {
cancelRender(err);
});
}, [handle]);
if (!animationData) {
return null;
}
return <Lottie animationData={animationData} />;
};Styling and animating
Lottie supports the style prop to allow styles and animations:
return <Lottie animationData={animationData} style={{width: 400, height: 400}} />;Measuring DOM nodes in Remotion
Remotion applies a scale() transform to the video container, which affects values from getBoundingClientRect(). Use useCurrentScale() to get correct measurements.
Measuring element dimensions
import { useCurrentScale } from "remotion";
import { useRef, useEffect, useState } from "react";
export const MyComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const scale = useCurrentScale();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setDimensions({
width: rect.width / scale,
height: rect.height / scale,
});
}, [scale]);
return <div ref={ref}>Content to measure</div>;
};Measuring text in Remotion
Prerequisites
Install @remotion/layout-utils if it is not already installed:
npx remotion add @remotion/layout-utils # If project uses npm
bunx remotion add @remotion/layout-utils # If project uses bun
yarn remotion add @remotion/layout-utils # If project uses yarn
pnpm exec remotion add @remotion/layout-utils # If project uses pnpmMeasuring text dimensions
Use measureText() to calculate the width and height of text:
import { measureText } from "@remotion/layout-utils";
const { width, height } = measureText({
text: "Hello World",
fontFamily: "Arial",
fontSize: 32,
fontWeight: "bold",
});Results are cached - duplicate calls return the cached result.
Fitting text to a width
Use fitText() to find the optimal font size for a container:
import { fitText } from "@remotion/layout-utils";
const { fontSize } = fitText({
text: "Hello World",
withinWidth: 600,
fontFamily: "Inter",
fontWeight: "bold",
});
return (
<div
style={{
fontSize: Math.min(fontSize, 80), // Cap at 80px
fontFamily: "Inter",
fontWeight: "bold",
}}
>
Hello World
</div>
);Checking text overflow
Use fillTextBox() to check if text exceeds a box:
import { fillTextBox } from "@remotion/layout-utils";
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
const words = ["Hello", "World", "This", "is", "a", "test"];
for (const word of words) {
const { exceedsBox } = box.add({
text: word + " ",
fontFamily: "Arial",
fontSize: 24,
});
if (exceedsBox) {
// Text would overflow, handle accordingly
break;
}
}Best practices
Load fonts first: Only call measurement functions after fonts are loaded.
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily, waitUntilDone } = loadFont("normal", {
weights: ["400"],
subsets: ["latin"],
});
waitUntilDone().then(() => {
// Now safe to measure
const { width } = measureText({
text: "Hello",
fontFamily,
fontSize: 32,
});
})Use validateFontIsLoaded: Catch font loading issues early:
measureText({
text: "Hello",
fontFamily: "MyCustomFont",
fontSize: 32,
validateFontIsLoaded: true, // Throws if font not loaded
});Match font properties: Use the same properties for measurement and rendering:
const fontStyle = {
fontFamily: "Inter",
fontSize: 32,
fontWeight: "bold" as const,
letterSpacing: "0.5px",
};
const { width } = measureText({
text: "Hello",
...fontStyle,
});
return <div style={fontStyle}>Hello</div>;Avoid padding and border: Use outline instead of border to prevent layout differences:
<div style={{ outline: "2px solid red" }}>Text</div>Use <Sequence> to delay when an element appears in the timeline.
import { Sequence } from "remotion";
const {fps} = useVideoConfig();
<Sequence from={1 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
<Title />
</Sequence>
<Sequence from={2 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
<Subtitle />
</Sequence>This will by default wrap the component in an absolute fill element. If the items should not be wrapped, use the layout prop:
<Sequence layout="none">
<Title />
</Sequence>Premounting
This loads the component in the timeline before it is actually played. Always premount any <Sequence>!
<Sequence premountFor={1 * fps}>
<Title />
</Sequence>Series
Use <Series> when elements should play one after another without overlap.
import {Series} from 'remotion';
<Series>
<Series.Sequence durationInFrames={45}>
<Intro />
</Series.Sequence>
<Series.Sequence durationInFrames={60}>
<MainContent />
</Series.Sequence>
<Series.Sequence durationInFrames={30}>
<Outro />
</Series.Sequence>
</Series>;Same as with <Sequence>, the items will be wrapped in an absolute fill element by default when using <Series.Sequence>, unless the layout prop is set to none.
Series with overlaps
Use negative offset for overlapping sequences:
<Series>
<Series.Sequence durationInFrames={60}>
<SceneA />
</Series.Sequence>
<Series.Sequence offset={-15} durationInFrames={60}>
{/* Starts 15 frames before SceneA ends */}
<SceneB />
</Series.Sequence>
</Series>Frame References Inside Sequences
Inside a Sequence, useCurrentFrame() returns the local frame (starting from 0):
<Sequence from={60} durationInFrames={30}>
<MyComponent />
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
</Sequence>Nested Sequences
Sequences can be nested for complex timing:
<Sequence from={0} durationInFrames={120}>
<Background />
<Sequence from={15} durationInFrames={90} layout="none">
<Title />
</Sequence>
<Sequence from={45} durationInFrames={60} layout="none">
<Subtitle />
</Sequence>
</Sequence>You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
Don't use transition-* or animate-* classes - always animate using the useCurrentFrame() hook.
Tailwind must be installed and enabled first in a Remotion project - fetch https://www.remotion.dev/docs/tailwind using WebFetch for instructions.
Text animations
Based on useCurrentFrame(), reduce the string character by character to create a typewriter effect.
Typewriter Effect
See Typewriter for an advanced example with a blinking cursor and a pause after the first sentence.
Always use string slicing for typewriter effects. Never use per-character opacity.
Word Highlighting
See Word Highlight for an example for how a word highlight is animated, like with a highlighter pen.
A simple linear interpolation is done using the interpolate function.
```ts title="Going from 0 to 1 over 100 frames" import {interpolate} from 'remotion';
const opacity = interpolate(frame, [0, 100], [0, 1]);
By default, the values are not clamped, so the value can go outside the range [0, 1].
Here is how they can be clamped:
const opacity = interpolate(frame, [0, 100], [0, 1], { extrapolateRight: 'clamp', extrapolateLeft: 'clamp', });
## Spring animations
Spring animations have a more natural motion.
They go from 0 to 1 over time.
import {spring, useCurrentFrame, useVideoConfig} from 'remotion';
const frame = useCurrentFrame(); const {fps} = useVideoConfig();
const scale = spring({ frame, fps, });
### Physical properties
The default configuration is: `mass: 1, damping: 10, stiffness: 100`.
This leads to the animation having a bit of bounce before it settles.
The config can be overwritten like this:
const scale = spring({ frame, fps, config: {damping: 200}, });
The recommended configuration for a natural motion without a bounce is: `{ damping: 200 }`.
Here are some common configurations:
const smooth = {damping: 200}; // Smooth, no bounce (subtle reveals) const snappy = {damping: 20, stiffness: 200}; // Snappy, minimal bounce (UI elements) const bouncy = {damping: 8}; // Bouncy entrance (playful animations) const heavy = {damping: 15, stiffness: 80, mass: 2}; // Heavy, slow, small bounce
### Delay
The animation starts immediately by default.
Use the `delay` parameter to delay the animation by a number of frames.
const entrance = spring({ frame: frame - ENTRANCE_DELAY, fps, delay: 20, });
### Duration
A `spring()` has a natural duration based on the physical properties.
To stretch the animation to a specific duration, use the `durationInFrames` parameter.
const spring = spring({ frame, fps, durationInFrames: 40, });
### Combining spring() with interpolate()
Map spring output (0-1) to custom ranges:
const springProgress = spring({ frame, fps, });
// Map to rotation const rotation = interpolate(springProgress, [0, 1], [0, 360]);
<div style={{rotate: rotation + 'deg'}} />;
### Adding springs
Springs return just numbers, so math can be performed:
const frame = useCurrentFrame(); const {fps, durationInFrames} = useVideoConfig();
const inAnimation = spring({ frame, fps, }); const outAnimation = spring({ frame, fps, durationInFrames: 1 fps, delay: durationInFrames - 1 fps, });
const scale = inAnimation - outAnimation;
## Easing
Easing can be added to the `interpolate` function:
import {interpolate, Easing} from 'remotion';
const value1 = interpolate(frame, [0, 100], [0, 1], { easing: Easing.inOut(Easing.quad), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', });
The default easing is `Easing.linear`.
There are various other convexities:
- `Easing.in` for starting slow and accelerating
- `Easing.out` for starting fast and slowing down
- `Easing.inOut`
and curves (sorted from most linear to most curved):
- `Easing.quad`
- `Easing.sin`
- `Easing.exp`
- `Easing.circle`
Convexities and curves need be combined for an easing function:
const value1 = interpolate(frame, [0, 100], [0, 1], { easing: Easing.inOut(Easing.quad), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', });
Cubic bezier curves are also supported:
const value1 = interpolate(frame, [0, 100], [0, 1], { easing: Easing.bezier(0.8, 0.22, 0.96, 0.65), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', });
Transcribing audio
Remotion provides several built-in options for transcribing audio to generate captions:
@remotion/install-whisper-cpp- Transcribe locally on a server using Whisper.cpp. Fast and free, but requires server infrastructure.
https://remotion.dev/docs/install-whisper-cpp
@remotion/whisper-web- Transcribe in the browser using WebAssembly. No server needed and free, but slower due to WASM overhead.
https://remotion.dev/docs/whisper-web
@remotion/openai-whisper- Use OpenAI Whisper API for cloud-based transcription. Fast and no server needed, but requires payment.
https://remotion.dev/docs/openai-whisper/openai-whisper-api-to-captions
Fullscreen transitions
Using <TransitionSeries> to animate between multiple scenes or clips. This will absolutely position the children.
Prerequisites
First, the @remotion/transitions package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/transitions # If project uses npm
bunx remotion add @remotion/transitions # If project uses bun
yarn remotion add @remotion/transitions # If project uses yarn
pnpm exec remotion add @remotion/transitions # If project uses pnpmExample usage
import {TransitionSeries, linearTiming} from '@remotion/transitions';
import {fade} from '@remotion/transitions/fade';
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Transition presentation={fade()} timing={linearTiming({durationInFrames: 15})} />
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>;Available Transition Types
Import transitions from their respective modules:
import {fade} from '@remotion/transitions/fade';
import {slide} from '@remotion/transitions/slide';
import {wipe} from '@remotion/transitions/wipe';
import {flip} from '@remotion/transitions/flip';
import {clockWipe} from '@remotion/transitions/clock-wipe';Slide Transition with Direction
Specify slide direction for enter/exit animations.
import {slide} from '@remotion/transitions/slide';
<TransitionSeries.Transition presentation={slide({direction: 'from-left'})} timing={linearTiming({durationInFrames: 20})} />;Directions: "from-left", "from-right", "from-top", "from-bottom"
Timing Options
import {linearTiming, springTiming} from '@remotion/transitions';
// Linear timing - constant speed
linearTiming({durationInFrames: 20});
// Spring timing - organic motion
springTiming({config: {damping: 200}, durationInFrames: 25});Duration calculation
Transitions overlap adjacent scenes, so the total composition length is shorter than the sum of all sequence durations.
For example, with two 60-frame sequences and a 15-frame transition:
- Without transitions:
60 + 60 = 120frames - With transition:
60 + 60 - 15 = 105frames
The transition duration is subtracted because both scenes play simultaneously during the transition.
Getting the duration of a transition
Use the getDurationInFrames() method on the timing object:
import {linearTiming, springTiming} from '@remotion/transitions';
const linearDuration = linearTiming({durationInFrames: 20}).getDurationInFrames({fps: 30});
// Returns 20
const springDuration = springTiming({config: {damping: 200}}).getDurationInFrames({fps: 30});
// Returns calculated duration based on spring physicsFor springTiming without an explicit durationInFrames, the duration depends on fps because it calculates when the spring animation settles.
Calculating total composition duration
import {linearTiming} from '@remotion/transitions';
const scene1Duration = 60;
const scene2Duration = 60;
const scene3Duration = 60;
const timing1 = linearTiming({durationInFrames: 15});
const timing2 = linearTiming({durationInFrames: 20});
const transition1Duration = timing1.getDurationInFrames({fps: 30});
const transition2Duration = timing2.getDurationInFrames({fps: 30});
const totalDuration = scene1Duration + scene2Duration + scene3Duration - transition1Duration - transition2Duration;
// 60 + 60 + 60 - 15 - 20 = 145 framesUse <Sequence> with a negative from value to trim the start of an animation.
Trim the Beginning
A negative from value shifts time backwards, making the animation start partway through:
import { Sequence, useVideoConfig } from "remotion";
const fps = useVideoConfig();
<Sequence from={-0.5 * fps}>
<MyAnimation />
</Sequence>The animation appears 15 frames into its progress - the first 15 frames are trimmed off. Inside <MyAnimation>, useCurrentFrame() starts at 15 instead of 0.
Trim the End
Use durationInFrames to unmount content after a specified duration:
<Sequence durationInFrames={1.5 * fps}>
<MyAnimation />
</Sequence>The animation plays for 45 frames, then the component unmounts.
Trim and Delay
Nest sequences to both trim the beginning and delay when it appears:
<Sequence from={30}>
<Sequence from={-15}>
<MyAnimation />
</Sequence>
</Sequence>The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
Using videos in Remotion
Prerequisites
First, the @remotion/media package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/media # If project uses npm
bunx remotion add @remotion/media # If project uses bun
yarn remotion add @remotion/media # If project uses yarn
pnpm exec remotion add @remotion/media # If project uses pnpmUse <Video> from @remotion/media to embed videos into your composition.
import { Video } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Video src={staticFile("video.mp4")} />;
};Remote URLs are also supported:
<Video src="https://remotion.media/video.mp4" />Trimming
Use trimBefore and trimAfter to remove portions of the video. Values are in seconds.
const { fps } = useVideoConfig();
return (
<Video
src={staticFile("video.mp4")}
trimBefore={2 * fps} // Skip the first 2 seconds
trimAfter={10 * fps} // End at the 10 second mark
/>
);Delaying
Wrap the video in a <Sequence> to delay when it appears:
import { Sequence, staticFile } from "remotion";
import { Video } from "@remotion/media";
const { fps } = useVideoConfig();
return (
<Sequence from={1 * fps}>
<Video src={staticFile("video.mp4")} />
</Sequence>
);The video will appear after 1 second.
Sizing and Position
Use the style prop to control size and position:
<Video
src={staticFile("video.mp4")}
style={{
width: 500,
height: 300,
position: "absolute",
top: 100,
left: 50,
objectFit: "cover",
}}
/>Volume
Set a static volume (0 to 1):
<Video src={staticFile("video.mp4")} volume={0.5} />Or use a callback for dynamic volume based on the current frame:
import { interpolate } from "remotion";
const { fps } = useVideoConfig();
return (
<Video
src={staticFile("video.mp4")}
volume={(f) =>
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
}
/>
);Use muted to silence the video entirely:
<Video src={staticFile("video.mp4")} muted />Speed
Use playbackRate to change the playback speed:
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}Reverse playback is not supported.
Looping
Use loop to loop the video indefinitely:
<Video src={staticFile("video.mp4")} loop />Use loopVolumeCurveBehavior to control how the frame count behaves when looping:
"repeat": Frame count resets to 0 each loop (forvolumecallback)"extend": Frame count continues incrementing
<Video
src={staticFile("video.mp4")}
loop
loopVolumeCurveBehavior="extend"
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
/>Pitch
Use toneFrequency to adjust the pitch without affecting speed. Values range from 0.01 to 2:
<Video
src={staticFile("video.mp4")}
toneFrequency={1.5} // Higher pitch
/>
<Video
src={staticFile("video.mp4")}
toneFrequency={0.8} // Lower pitch
/>Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the <Player />.
/**
* Remotion Code-First Interface
*
* TypeScript wrappers for Remotion CLI operations.
* Enables programmatic video rendering with full control.
*/
import { $ } from 'bun'
export interface RenderOptions {
/** Composition ID to render */
compositionId: string
/** Output file path */
outputPath: string
/** Video codec */
codec?: 'h264' | 'h265' | 'vp8' | 'vp9' | 'prores' | 'gif'
/** Constant Rate Factor (quality, lower = better, 0-51) */
crf?: number
/** Frames per second */
fps?: number
/** Video width */
width?: number
/** Video height */
height?: number
/** Props to pass to composition */
inputProps?: Record<string, any>
/** Project directory (defaults to cwd) */
projectDir?: string
/** Specific frames to render (e.g., "0-100") */
frames?: string
/** Image sequence output format */
imageFormat?: 'png' | 'jpeg'
/** JPEG quality (0-100) */
jpegQuality?: number
/** Scale factor */
scale?: number
/** Mute audio */
muted?: boolean
/** Audio codec */
audioCodec?: 'aac' | 'mp3' | 'opus' | 'wav' | 'pcm'
/** Number of render threads */
concurrency?: number
/** Verbose output */
verbose?: boolean
}
export interface Composition {
id: string
width: number
height: number
fps: number
durationInFrames: number
defaultProps?: Record<string, any>
}
export interface RenderResult {
success: boolean
outputPath: string
duration?: number
error?: string
}
/**
* Render a Remotion composition to video file
*
* @param options - Render configuration
* @returns Render result
*/
export async function render(options: RenderOptions): Promise<RenderResult> {
const args: string[] = ['npx', 'remotion', 'render', options.compositionId, options.outputPath]
if (options.codec) args.push('--codec', options.codec)
if (options.crf !== undefined) args.push('--crf', String(options.crf))
if (options.fps) args.push('--fps', String(options.fps))
if (options.width) args.push('--width', String(options.width))
if (options.height) args.push('--height', String(options.height))
if (options.frames) args.push('--frames', options.frames)
if (options.imageFormat) args.push('--image-format', options.imageFormat)
if (options.jpegQuality) args.push('--jpeg-quality', String(options.jpegQuality))
if (options.scale) args.push('--scale', String(options.scale))
if (options.muted) args.push('--muted')
if (options.audioCodec) args.push('--audio-codec', options.audioCodec)
if (options.concurrency) args.push('--concurrency', String(options.concurrency))
if (options.inputProps) {
args.push('--props', JSON.stringify(options.inputProps))
}
const startTime = Date.now()
const cwd = options.projectDir || process.cwd()
try {
const result = await $`${args}`.cwd(cwd).text()
const duration = (Date.now() - startTime) / 1000
return {
success: true,
outputPath: options.outputPath,
duration
}
} catch (error: any) {
return {
success: false,
outputPath: options.outputPath,
error: error.message || String(error)
}
}
}
/**
* Render a still image from a composition
*
* @param options - Still render configuration
* @returns Render result
*/
export async function renderStill(options: {
compositionId: string
outputPath: string
frame?: number
inputProps?: Record<string, any>
projectDir?: string
imageFormat?: 'png' | 'jpeg'
jpegQuality?: number
scale?: number
}): Promise<RenderResult> {
const args: string[] = ['npx', 'remotion', 'still', options.compositionId, options.outputPath]
if (options.frame !== undefined) args.push('--frame', String(options.frame))
if (options.imageFormat) args.push('--image-format', options.imageFormat)
if (options.jpegQuality) args.push('--jpeg-quality', String(options.jpegQuality))
if (options.scale) args.push('--scale', String(options.scale))
if (options.inputProps) {
args.push('--props', JSON.stringify(options.inputProps))
}
const cwd = options.projectDir || process.cwd()
try {
await $`${args}`.cwd(cwd).text()
return {
success: true,
outputPath: options.outputPath
}
} catch (error: any) {
return {
success: false,
outputPath: options.outputPath,
error: error.message || String(error)
}
}
}
/**
* List all compositions in a Remotion project
*
* @param projectDir - Project directory (defaults to cwd)
* @returns Array of compositions
*/
export async function listCompositions(projectDir?: string): Promise<Composition[]> {
const cwd = projectDir || process.cwd()
try {
const result = await $`npx remotion compositions --json`.cwd(cwd).text()
return JSON.parse(result)
} catch (error: any) {
console.error('Failed to list compositions:', error.message)
return []
}
}
/**
* Start the Remotion studio preview server
*
* @param options - Studio options
*/
export async function startStudio(options?: {
projectDir?: string
port?: number
browserArgs?: string[]
}): Promise<void> {
const args: string[] = ['npx', 'remotion', 'studio']
if (options?.port) args.push('--port', String(options.port))
const cwd = options?.projectDir || process.cwd()
// Run in background - studio stays open
$`${args}`.cwd(cwd).nothrow()
console.log(`Remotion Studio starting at http://localhost:${options?.port || 3000}`)
}
/**
* Create a new Remotion project
*
* @param options - Project creation options
*/
export async function createProject(options: {
name: string
template?: 'blank' | 'hello-world' | 'three' | 'audiogram' | 'tts'
outputDir?: string
}): Promise<{ success: boolean; path: string; error?: string }> {
const args: string[] = ['npx', 'create-video@latest', options.name]
if (options.template) {
args.push('--template', options.template)
}
const cwd = options.outputDir || process.cwd()
try {
await $`${args}`.cwd(cwd).text()
return {
success: true,
path: `${cwd}/${options.name}`
}
} catch (error: any) {
return {
success: false,
path: `${cwd}/${options.name}`,
error: error.message || String(error)
}
}
}
/**
* Upgrade Remotion packages in a project
*
* @param projectDir - Project directory
*/
export async function upgrade(projectDir?: string): Promise<{ success: boolean; error?: string }> {
const cwd = projectDir || process.cwd()
try {
await $`npx remotion upgrade`.cwd(cwd).text()
return { success: true }
} catch (error: any) {
return {
success: false,
error: error.message || String(error)
}
}
}
/**
* Get video metadata using Mediabunny
*/
export async function getVideoMetadata(videoPath: string): Promise<{
width: number
height: number
durationInSeconds: number
fps: number
} | null> {
try {
// This requires @remotion/media-utils in the project
const result = await $`npx remotion parse-video ${videoPath} --json`.text()
return JSON.parse(result)
} catch {
return null
}
}
/**
* Get audio duration using Mediabunny
*/
export async function getAudioDuration(audioPath: string): Promise<number | null> {
try {
const result = await $`npx remotion parse-audio ${audioPath} --json`.text()
const data = JSON.parse(result)
return data.durationInSeconds
} catch {
return null
}
}
// CLI entry point
if (import.meta.main) {
const args = process.argv.slice(2)
const command = args[0]
switch (command) {
case 'render': {
const [_, compositionId, outputPath, ...rest] = args
if (!compositionId || !outputPath) {
console.error('Usage: bun run index.ts render <compositionId> <outputPath> [--crf N] [--fps N]')
process.exit(1)
}
const options: RenderOptions = { compositionId, outputPath }
// Parse optional args
for (let i = 0; i < rest.length; i++) {
if (rest[i] === '--crf' && rest[i + 1]) options.crf = parseInt(rest[++i])
if (rest[i] === '--fps' && rest[i + 1]) options.fps = parseInt(rest[++i])
if (rest[i] === '--codec' && rest[i + 1]) options.codec = rest[++i] as any
if (rest[i] === '--width' && rest[i + 1]) options.width = parseInt(rest[++i])
if (rest[i] === '--height' && rest[i + 1]) options.height = parseInt(rest[++i])
}
const result = await render(options)
console.log(JSON.stringify(result, null, 2))
break
}
case 'list': {
const compositions = await listCompositions(args[1])
console.log(JSON.stringify(compositions, null, 2))
break
}
case 'create': {
const name = args[1]
const template = args[2] as any
if (!name) {
console.error('Usage: bun run index.ts create <name> [template]')
process.exit(1)
}
const result = await createProject({ name, template })
console.log(JSON.stringify(result, null, 2))
break
}
default:
console.log(`
Remotion CLI Wrapper
Commands:
render <compositionId> <outputPath> [--crf N] [--fps N] [--codec TYPE]
list [projectDir]
create <name> [template]
Examples:
bun run index.ts render my-video out/video.mp4 --crf 18
bun run index.ts list
bun run index.ts create new-project hello-world
`)
}
}
/**
* PAI Theme for Remotion
*
* Derived from Art skill preferences at:
* ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/PREFERENCES.md
*
* Core aesthetic: Charcoal architectural sketch with purple accents
* Visual feel: Monumental emotional spaces, gestural linework, cool washes
*/
export const PAI_THEME = {
// Colors (from Art color palette)
colors: {
// Backgrounds (vast architectural space feel)
background: '#0f172a', // Deep slate
backgroundAlt: '#1e293b', // Slightly lighter slate
backgroundDark: '#020617', // Near black
// Accents (purple/violet from Art prefs)
accent: '#8b5cf6', // Primary purple
accentLight: '#a78bfa', // Lighter purple
accentDark: '#7c3aed', // Darker purple
accentMuted: '#6366f1', // Indigo variant
// Text (paper ground inspired)
text: '#f1f5f9', // Light text
textMuted: '#94a3b8', // Muted/secondary text
textDark: '#64748b', // De-emphasized text
// Special
paperGround: '#F5F5F0', // Cream/off-white from Art prefs
coolWash: 'rgba(139, 92, 246, 0.1)', // Purple atmospheric wash
warmWash: 'rgba(251, 191, 36, 0.1)', // Amber contrast wash
// Utility
success: '#10b981', // Green
warning: '#f59e0b', // Amber
error: '#ef4444', // Red
info: '#3b82f6', // Blue
},
// Typography (production design quality)
typography: {
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontFamilyMono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
// Size scale
title: { fontSize: 72, fontWeight: 'bold' as const, lineHeight: 1.1 },
subtitle: { fontSize: 48, fontWeight: '600' as const, lineHeight: 1.2 },
heading: { fontSize: 36, fontWeight: '600' as const, lineHeight: 1.3 },
body: { fontSize: 24, fontWeight: 'normal' as const, lineHeight: 1.5 },
caption: { fontSize: 18, fontWeight: 'normal' as const, lineHeight: 1.4 },
small: { fontSize: 14, fontWeight: 'normal' as const, lineHeight: 1.4 },
},
// Animation feel (gestural, organic - not mechanical)
animation: {
// Spring configs (organic feel like gestural linework)
springFast: { damping: 15, stiffness: 150 },
springDefault: { damping: 12, stiffness: 100 },
springSlow: { damping: 10, stiffness: 80 },
springBouncy: { damping: 8, stiffness: 120 },
// Frame durations at 30fps
fadeFrames: 30, // ~1 second fade
quickFade: 15, // ~0.5 second
slowFade: 45, // ~1.5 seconds
// Stagger delays
staggerDelay: 10, // Frames between sequential elements
staggerFast: 5, // Quick succession
staggerSlow: 15, // Dramatic reveal
},
// Spacing (human-scale in vast spaces)
spacing: {
page: 100, // Edge padding for full-screen
section: 60, // Between major sections
element: 30, // Between related elements
tight: 15, // Compact spacing
// For text blocks
paragraphGap: 24,
listItemGap: 16,
},
// Shadows and effects
effects: {
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)',
boxShadowLarge: '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)',
glow: '0 0 20px rgba(139, 92, 246, 0.5)',
},
// Border radius
borderRadius: {
small: 8,
medium: 16,
large: 24,
full: 9999,
},
} as const
// Type exports
export type PAITheme = typeof PAI_THEME
export type PAIColors = typeof PAI_THEME.colors
export type PAITypography = typeof PAI_THEME.typography
export type PAIAnimation = typeof PAI_THEME.animation
// Utility: Get interpolate input/output for fade
export const fadeInterpolation = (startFrame = 0) => ({
inputRange: [startFrame, startFrame + PAI_THEME.animation.fadeFrames],
outputRange: [0, 1] as [number, number],
})
// Utility: Style preset for centered title screen
export const titleScreenStyle = {
backgroundColor: PAI_THEME.colors.background,
display: 'flex' as const,
justifyContent: 'center' as const,
alignItems: 'center' as const,
fontFamily: PAI_THEME.typography.fontFamily,
}
// Utility: Style preset for content screen
export const contentScreenStyle = {
backgroundColor: PAI_THEME.colors.background,
padding: PAI_THEME.spacing.page,
fontFamily: PAI_THEME.typography.fontFamily,
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./",
"types": ["bun-types"]
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
ContentToAnimation Workflow
Transform any content into professional PAI-themed animations.
Triggers
- "animate this content"
- "create animations for"
- "video overlay for"
- "animate my blog post"
- "animate this YouTube video"
Input Types
This workflow handles ANY input via the Parser skill:
| Input Type | Detection | Extraction Method |
|---|---|---|
| YouTube URL | youtube.com, youtu.be | Parser: ExtractYoutube → transcript |
| Article URL | HTTP(S) URL | Parser: ExtractArticle → text |
| Blog file | .md file path | Direct read → markdown content |
| PDF file | .pdf file path | Parser: ExtractPdf → text |
| Tweet/Thread | twitter.com, x.com | Parser: ExtractTwitter → thread |
| Raw text | No URL/path detected | Use directly |
Execution Steps
1. Extract Content
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 1: CONTENT EXTRACTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. Detect input type (URL, file path, or raw text) │
│ 2. Route to appropriate Parser workflow OR read directly │
│ 3. Extract: title, sections, key points, quotes, data │
└─────────────────────────────────────────────────────────────────────────────┘For YouTube:
# Get transcript via Parser skill
# Load: ~/.claude/skills/Utilities/Parser/Workflows/ExtractYoutube.mdFor articles/blogs:
# Read file directly for .md
# Or use Parser: ExtractArticle for URLs2. Analyze Structure
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 2: STRUCTURE ANALYSIS │
├─────────────────────────────────────────────────────────────────────────────┤
│ Extract these elements for animation: │
│ │
│ • Title & subtitle │
│ • Section headers (H2, H3) │
│ • Key points (3-7 main takeaways) │
│ • Quotes or callouts │
│ • Data/statistics (numbers, percentages) │
│ • Lists or steps │
│ • Conclusion/summary │
└─────────────────────────────────────────────────────────────────────────────┘Output structure:
interface ContentStructure {
title: string
subtitle?: string
sections: {
heading: string
keyPoints: string[]
quotes?: string[]
data?: { label: string; value: string }[]
}[]
conclusion?: string
duration: number // Calculated based on content length
}3. Generate Animation Plan
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 3: ANIMATION PLANNING │
├─────────────────────────────────────────────────────────────────────────────┤
│ Map content to animation scenes: │
│ │
│ Scene 1: Title Card (3 seconds) │
│ → Title fade in with spring scale │
│ → Subtitle fade in with delay │
│ │
│ Scene 2-N: Content Sections (4-6 seconds each) │
│ → Section header slide in │
│ → Key points stagger in │
│ → Data visualizations animate │
│ │
│ Scene N+1: Conclusion (3 seconds) │
│ → Summary points │
│ → Call to action │
└─────────────────────────────────────────────────────────────────────────────┘Timing formula:
- Title: 90 frames (3 seconds at 30fps)
- Per section: 120-180 frames (4-6 seconds)
- Conclusion: 90 frames (3 seconds)
- Total = 90 + (sections × 150) + 90
3.5 Verify Logical Coherence ⚠️ CRITICAL GATE
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 3.5: LOGICAL COHERENCE VERIFICATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ BEFORE generating React components, verify the animation plan makes sense. │
│ │
│ This checks LOGICAL coherence, not just functional capability. │
│ │
│ If these checks FAIL, the video would render but be confusing/wrong. │
│ Block early to save compute and prevent bad outputs. │
└─────────────────────────────────────────────────────────────────────────────┘1. NARRATIVE COHERENCE CHECKS
Verify the story flows logically:
| Check | What It Tests | Failure Example |
|---|---|---|
| Section connectivity | Adjacent sections share ≥15% concepts | Section 2 "Authentication" → Section 3 "Database Schema" with 0% overlap |
| Context completeness | No forward references to undefined concepts | Scene 2 uses "ISC" acronym before defining it in Scene 4 |
| Transition bridges | Last point of section N relates to first point of section N+1 | Jarring topic jump with no conceptual bridge |
| Story arc validity | Sections follow recognizable narrative pattern | Random sequence with no setup→development→resolution |
| Title-content alignment | Content delivers what title promises | Title: "5 Ways to..." but only 3 covered |
| Conclusion validity | Conclusion only references introduced concepts | Conclusion mentions "OWASP" never discussed in content |
Test method:
// Pseudo-code for verification
const narrativeChecks = {
sectionConnectivity: verifySectionOverlap(sections) >= 0.15,
contextCompleteness: noForwardReferences(sections),
transitionBridges: hasConceptualBridges(sections),
storyArc: matchesValidPattern(sections),
titleAlignment: contentMatchesTitle(title, sections),
conclusionValidity: conclusionReferencesContent(conclusion, sections)
}
if (Object.values(narrativeChecks).some(check => !check)) {
throw new Error('Narrative coherence check failed - see details above')
}2. TIMING VERIFICATION CHECKS
Verify timing adapts to content density:
| Check | What It Tests | Failure Example |
|---|---|---|
| Reading speed validation | Text duration allows comfortable reading (≤4 words/second) | 47-word paragraph shown for 2 seconds (23.5 wps) |
| Content-density adaptation | Duration scales with word count, key points, data items | Simple 2-word title gets same 3s as complex 15-word title |
| Data comprehension time | Statistics get 1-2 seconds per item for mental processing | 5 data points crammed into 3 seconds |
| Content-type multipliers | Quotes get 1.5x, data gets 1.3x base duration | Reflective quote rushed at same pace as simple list |
| Duration bounds | Timing stays within 2-10 seconds per point | Critical concept: 1s, Minor detail: 12s |
Test method:
// Calculate adaptive timing based on content density
function calculateSectionDuration(section: Section): number {
const WORDS_PER_SECOND = 3.5 // Research: 200-250 WPM
const SECONDS_PER_POINT = 2
const SECONDS_PER_DATA = 1.5
const wordCount = countWords(section.keyPoints)
const baseDuration = (
wordCount / WORDS_PER_SECOND +
section.keyPoints.length * SECONDS_PER_POINT +
(section.data?.length || 0) * SECONDS_PER_DATA
)
// Apply content-type multiplier
const typeMultiplier = section.quotes ? 1.5 : 1.0
const duration = baseDuration * typeMultiplier
// Enforce bounds
const minDuration = section.keyPoints.length * 2
const maxDuration = section.keyPoints.length * 10
return Math.max(minDuration, Math.min(maxDuration, duration))
}3. SCENE TYPE SELECTION VALIDATION
Verify correct scene template chosen for content:
| Check | What It Tests | Failure Example |
|---|---|---|
| Data scene validation | DataScene only used when data array exists with items | DataScene receives empty data array → blank screen |
| Numeric content detection | Statistics in text trigger DataScene, not KeyPointsScene | "10M users, 95% accuracy" shown as bullet points |
| KeyPoints scene validation | KeyPointsScene used for 2+ text items without numeric data | Single quote forced into KeyPointsScene template |
| Quote handling | Quotes get appropriate visual treatment | Quote buried in bullet list with no emphasis |
Selection logic:
function selectSceneType(section: Section): SceneType {
// Priority 1: Has structured data? → DataScene
if (section.data && section.data.length > 0) {
return 'DataScene'
}
// Priority 2: Detect numeric patterns in text → extract to DataScene
if (hasNumericPatterns(section.keyPoints)) {
section.data = extractDataFromText(section.keyPoints)
return 'DataScene'
}
// Priority 3: Has quote and few/no key points? → QuoteScene
if (section.quotes && section.keyPoints.length < 2) {
return 'QuoteScene'
}
// Default: Key points list
if (section.keyPoints.length >= 2) {
return 'KeyPointsScene'
}
// Fallback: Simple text
return 'TitleScene'
}
// Validation guards
function validateSceneSelection(scene: SceneType, section: Section): void {
if (scene === 'DataScene') {
assert(section.data && section.data.length > 0,
'DataScene requires data array with at least 1 item')
}
if (scene === 'KeyPointsScene') {
assert(section.keyPoints.length >= 2,
'KeyPointsScene requires at least 2 key points')
assert(!hasNumericPatterns(section.keyPoints),
'Numeric data should use DataScene, not KeyPointsScene')
}
}4. DECISION LOGIC: FAIL FAST OR WARN
interface VerificationResult {
passed: boolean
errors: string[] // Block rendering
warnings: string[] // Show but allow proceeding
}
function verifyAnimationPlan(
structure: ContentStructure,
plan: AnimationPlan
): VerificationResult {
const errors: string[] = []
const warnings: string[] = []
// Run all verification checks
const narrativeResult = verifyNarrativeCoherence(structure)
const timingResult = verifyTimingLogic(plan)
const sceneResult = verifySceneSelection(plan)
errors.push(...narrativeResult.errors, ...timingResult.errors, ...sceneResult.errors)
warnings.push(...narrativeResult.warnings, ...timingResult.warnings, ...sceneResult.warnings)
return { passed: errors.length === 0, errors, warnings }
}
// In workflow execution:
const verification = verifyAnimationPlan(structure, plan)
if (!verification.passed) {
console.error('❌ LOGICAL COHERENCE CHECK FAILED:')
verification.errors.forEach(err => console.error(` - ${err}`))
throw new Error('Cannot proceed - fix logical issues before rendering')
}
if (verification.warnings.length > 0) {
console.warn('⚠️ COHERENCE WARNINGS (review recommended):')
verification.warnings.forEach(warn => console.warn(` - ${warn}`))
}
console.log('✅ Logical coherence verified - proceeding to component generation')Example output:
PASS:
✅ Logical coherence verified - proceeding to component generation
Checks passed:
✓ Narrative flow: All sections connect logically
✓ Timing: Adapted to content density (avg 3.8 words/sec)
✓ Scene selection: All templates match content typesFAIL:
❌ LOGICAL COHERENCE CHECK FAILED:
- Narrative: Section 2 → 3 weak connection (5% overlap, need ≥15%)
- Timing: Scene 3 text too fast to read (6.2 words/sec, max 4.0)
- Scene selection: DataScene assigned but section.data is empty
- Conclusion: References "ISC methodology" never introduced in content
Cannot proceed - fix logical issues before renderingWARN:
⚠️ COHERENCE WARNINGS (review recommended):
- Narrative: Section 3 → 4 transition lacks bridge concept
- Timing: Scene 2 duration near minimum bound (2.1s per point)
✅ Logical coherence verified - proceeding to component generationWhy this matters:
| Without Verification | With Verification |
|---|---|
| Video renders successfully | Video renders successfully |
| 47-word text shown for 2s → unreadable | Timing adapted to 13s → readable |
| Conclusion references undefined "ISC" → confusing | Blocked: "ISC mentioned but never defined" |
| Statistics shown as bullet points → wrong format | Converted to DataScene → proper visualization |
| Section jump from auth to database → jarring | Blocked: "5% overlap, need transitional content" |
Bottom line: Verification prevents technically-correct but logically-broken videos from being generated.
4. Generate Remotion Components
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 4: COMPONENT GENERATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ Create project at: /tmp/remotion-{timestamp}/ │
│ │
│ Files to generate: │
│ • package.json │
│ • src/Root.tsx (composition registration) │
│ • src/Video.tsx (main composition) │
│ • src/scenes/TitleScene.tsx │
│ • src/scenes/SectionScene.tsx │
│ • src/scenes/ConclusionScene.tsx │
│ • src/theme.ts (copy from skill) │
└─────────────────────────────────────────────────────────────────────────────┘MANDATORY: Apply PAI Theme
import { PAI_THEME } from '~/.claude/skills/Media/Remotion/theme'
// All components MUST use:
// - PAI_THEME.colors for all colors
// - PAI_THEME.typography for text styles
// - PAI_THEME.animation for spring configs
// - PAI_THEME.spacing for layout5. Render Output
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 5: RENDER │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. Install dependencies: npm install │
│ 2. Render: npx remotion render {composition-id} ~/Downloads/{name}.mp4 │
│ 3. Open for preview: open ~/Downloads/{name}.mp4 │
└─────────────────────────────────────────────────────────────────────────────┘Scene Templates
TitleScene
const TitleScene: React.FC<{ title: string; subtitle?: string }> = ({ title, subtitle }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const titleOpacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: 'clamp' })
const titleScale = spring({ frame, fps, config: PAI_THEME.animation.springDefault })
const subtitleOpacity = interpolate(frame, [20, 50], [0, 1], { extrapolateRight: 'clamp' })
return (
<AbsoluteFill style={{
backgroundColor: PAI_THEME.colors.background,
justifyContent: 'center',
alignItems: 'center',
}}>
<h1 style={{
...PAI_THEME.typography.title,
color: PAI_THEME.colors.accent,
opacity: titleOpacity,
transform: `scale(${titleScale})`,
textAlign: 'center',
maxWidth: '80%',
}}>
{title}
</h1>
{subtitle && (
<p style={{
...PAI_THEME.typography.subtitle,
color: PAI_THEME.colors.textMuted,
opacity: subtitleOpacity,
marginTop: 20,
}}>
{subtitle}
</p>
)}
</AbsoluteFill>
)
}KeyPointsScene
const KeyPointsScene: React.FC<{ heading: string; points: string[] }> = ({ heading, points }) => {
const frame = useCurrentFrame()
return (
<AbsoluteFill style={{
backgroundColor: PAI_THEME.colors.background,
padding: PAI_THEME.spacing.page,
}}>
<h2 style={{
...PAI_THEME.typography.heading,
color: PAI_THEME.colors.text,
opacity: interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' }),
marginBottom: PAI_THEME.spacing.section,
}}>
{heading}
</h2>
{points.map((point, i) => {
const delay = 20 + (i * PAI_THEME.animation.staggerDelay)
const opacity = interpolate(frame, [delay, delay + 20], [0, 1], { extrapolateRight: 'clamp' })
const x = interpolate(frame, [delay, delay + 20], [-30, 0], { extrapolateRight: 'clamp' })
return (
<div key={i} style={{
...PAI_THEME.typography.body,
color: PAI_THEME.colors.text,
opacity,
transform: `translateX(${x}px)`,
marginBottom: PAI_THEME.spacing.element,
display: 'flex',
alignItems: 'flex-start',
}}>
<span style={{ color: PAI_THEME.colors.accent, marginRight: 16 }}>✓</span>
{point}
</div>
)
})}
</AbsoluteFill>
)
}DataScene
const DataScene: React.FC<{ data: { label: string; value: string }[] }> = ({ data }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
return (
<AbsoluteFill style={{
backgroundColor: PAI_THEME.colors.background,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
gap: PAI_THEME.spacing.section,
}}>
{data.map((item, i) => {
const delay = i * 15
const scale = spring({ frame: Math.max(0, frame - delay), fps, config: PAI_THEME.animation.springBouncy })
return (
<div key={i} style={{
textAlign: 'center',
transform: `scale(${scale})`,
}}>
<div style={{
fontSize: 96,
fontWeight: 'bold',
color: PAI_THEME.colors.accent,
}}>
{item.value}
</div>
<div style={{
...PAI_THEME.typography.body,
color: PAI_THEME.colors.textMuted,
}}>
{item.label}
</div>
</div>
)
})}
</AbsoluteFill>
)
}Output Formats
| Format | Dimensions | Use Case |
|---|---|---|
| YouTube landscape | 1920x1080 | Default, blog content |
| YouTube Shorts | 1080x1920 | Vertical clips |
| Square | 1080x1080 | Instagram, social |
Example Usage
Blog post:
User: animate my blog post at ${PROJECTS_DIR}/YourWebsite/cms/blog/skills-vs-agents.mdYouTube video:
User: create animations for https://youtube.com/watch?v=xyz123Raw text:
User: animate this content: "The three pillars of AI safety are..."Integration with Art Skill
This workflow inherits visual theming from Art preferences:
- Load:
~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/PREFERENCES.md - Apply: Charcoal aesthetic, purple accents, organic animations
- Reference:
~/.claude/skills/Media/Remotion/theme.ts