feat: add chunked skills.md generation for large collections

Implements batched generation to avoid Vercel 120s timeout for collections
with 60+ tools. Uses per-tool caching and recursive serverless invocations.

- Add ToolSkillsCache and SkillsGenerationJob models to schema
- Create tool-skills-generator.ts for per-tool markdown generation
- Create skills-summary-generator.ts for final pass summary/intro
- Update route handler with chunked generation logic
- Small collections (<20 tools) use original monolithic approach
- Large collections use 10-tool batches with progress tracking
This commit is contained in:
Ajax Davis 2026-01-21 16:26:06 +10:00
parent 514ea3c0db
commit 5d00f2a711
39 changed files with 6068 additions and 286 deletions

View file

@ -0,0 +1,43 @@
---
name: remotion-best-practices
description: Best practices for Remotion - Video creation in React
metadata:
tags: remotion, video, react, animation, composition
---
## 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](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber
- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion
- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion
- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props
- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny
- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion
- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata
- [rules/display-captions.md](rules/display-captions.md) - Displaying captions in Remotion with TikTok-style pages and word highlighting
- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny
- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion
- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny
- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny
- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny
- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline
- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component
- [rules/import-srt-captions.md](rules/import-srt-captions.md) - Importing .srt subtitle files into Remotion using @remotion/captions
- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion
- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion
- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow
- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items
- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion
- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion
- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations
- [rules/transcribe-captions.md](rules/transcribe-captions.md) - Transcribing audio to generate captions in Remotion
- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion
- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations
- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch

View file

@ -0,0 +1,86 @@
---
name: 3d
description: 3D content in Remotion using Three.js and React Three Fiber.
metadata:
tags: 3d, three, threejs
---
# 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:
```bash
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 pnpm
```
## Using ThreeCanvas
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
`<ThreeCanvas>` MUST have a `width` and `height` prop.
```tsx
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.
```tsx
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`.
```tsx
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>
```

View file

@ -0,0 +1,29 @@
---
name: animations
description: Fundamental animation skills for Remotion
metadata:
tags: animations, transitions, frames, useCurrentFrame
---
All animations MUST be driven by the `useCurrentFrame()` hook.
Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`.
```tsx
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.

View file

@ -0,0 +1,78 @@
---
name: assets
description: Importing images, videos, audio, and fonts into Remotion
metadata:
tags: assets, staticFile, images, fonts, public
---
# 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:
```tsx
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:**
```tsx
import {Img, staticFile} from 'remotion';
<Img src={staticFile('photo.png')} />;
```
**Videos:**
```tsx
import {Video} from '@remotion/media';
import {staticFile} from 'remotion';
<Video src={staticFile('clip.mp4')} />;
```
**Audio:**
```tsx
import {Audio} from '@remotion/media';
import {staticFile} from 'remotion';
<Audio src={staticFile('music.mp3')} />;
```
**Fonts:**
```tsx
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()`:
```tsx
<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

View file

@ -0,0 +1,165 @@
import { loadFont } from '@remotion/google-fonts/Inter';
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from 'remotion';
const { fontFamily } = loadFont();
const COLOR_BAR = '#D4AF37';
const COLOR_TEXT = '#ffffff';
const COLOR_MUTED = '#888888';
const COLOR_BG = '#0a0a0a';
const COLOR_AXIS = '#333333';
// Ideal composition size: 1280x720
const Title: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<div style={{ color: COLOR_TEXT, fontSize: 48, fontWeight: 600 }}>{children}</div>
</div>
);
const YAxis: React.FC<{ steps: number[]; height: number }> = ({ steps, height }) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height,
paddingRight: 16,
}}
>
{steps
.slice()
.reverse()
.map((step) => (
<div
key={step}
style={{
color: COLOR_MUTED,
fontSize: 20,
textAlign: 'right',
}}
>
{step.toLocaleString()}
</div>
))}
</div>
);
const Bar: React.FC<{
height: number;
progress: number;
}> = ({ height, progress }) => (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<div
style={{
width: '100%',
height,
backgroundColor: COLOR_BAR,
borderRadius: '8px 8px 0 0',
opacity: progress,
}}
/>
</div>
);
const XAxis: React.FC<{
children: React.ReactNode;
labels: string[];
height: number;
}> = ({ children, labels, height }) => (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 16,
height,
borderLeft: `2px solid ${COLOR_AXIS}`,
borderBottom: `2px solid ${COLOR_AXIS}`,
paddingLeft: 16,
}}
>
{children}
</div>
<div
style={{
display: 'flex',
gap: 16,
paddingLeft: 16,
marginTop: 12,
}}
>
{labels.map((label) => (
<div
key={label}
style={{
flex: 1,
textAlign: 'center',
color: COLOR_MUTED,
fontSize: 20,
}}
>
{label}
</div>
))}
</div>
</div>
);
export const MyAnimation = () => {
const frame = useCurrentFrame();
const { fps, height } = useVideoConfig();
const data = [
{ month: 'Jan', price: 2039 },
{ month: 'Mar', price: 2160 },
{ month: 'May', price: 2327 },
{ month: 'Jul', price: 2426 },
{ month: 'Sep', price: 2634 },
{ month: 'Nov', price: 2672 },
];
const minPrice = 2000;
const maxPrice = 2800;
const priceRange = maxPrice - minPrice;
const chartHeight = height - 280;
const yAxisSteps = [2000, 2400, 2800];
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
padding: 60,
display: 'flex',
flexDirection: 'column',
fontFamily,
}}
>
<Title>Gold Price 2024</Title>
<div style={{ display: 'flex', flex: 1 }}>
<YAxis steps={yAxisSteps} height={chartHeight} />
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
{data.map((item, i) => {
const progress = spring({
frame: frame - i * 5 - 10,
fps,
config: { damping: 18, stiffness: 80 },
});
const barHeight = ((item.price - minPrice) / priceRange) * chartHeight * progress;
return <Bar key={item.month} height={barHeight} progress={progress} />;
})}
</XAxis>
</div>
</AbsoluteFill>
);
};

View file

@ -0,0 +1,89 @@
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
const PAUSE_AFTER = 'From prompt to motion graphics.';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const CHAR_FRAMES = 2;
const CURSOR_BLINK_FRAMES = 16;
const PAUSE_SECONDS = 1;
// Ideal composition size: 1280x720
const getTypedText = ({
frame,
fullText,
pauseAfter,
charFrames,
pauseFrames,
}: {
frame: number;
fullText: string;
pauseAfter: string;
charFrames: number;
pauseFrames: number;
}): string => {
const pauseIndex = fullText.indexOf(pauseAfter);
const preLen = pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
let typedChars = 0;
if (frame < preLen * charFrames) {
typedChars = Math.floor(frame / charFrames);
} else if (frame < preLen * charFrames + pauseFrames) {
typedChars = preLen;
} else {
const postPhase = frame - preLen * charFrames - pauseFrames;
typedChars = Math.min(fullText.length, preLen + Math.floor(postPhase / charFrames));
}
return fullText.slice(0, typedChars);
};
const Cursor: React.FC<{
frame: number;
blinkFrames: number;
symbol?: string;
}> = ({ frame, blinkFrames, symbol = '\u258C' }) => {
const opacity = interpolate(frame % blinkFrames, [0, blinkFrames / 2, blinkFrames], [1, 0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return <span style={{ opacity }}>{symbol}</span>;
};
export const MyAnimation = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
const typedText = getTypedText({
frame,
fullText: FULL_TEXT,
pauseAfter: PAUSE_AFTER,
charFrames: CHAR_FRAMES,
pauseFrames,
});
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
fontFamily: 'sans-serif',
}}
>
<span>{typedText}</span>
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
</div>
</AbsoluteFill>
);
};

View file

@ -0,0 +1,101 @@
import { loadFont } from '@remotion/google-fonts/Inter';
import type React from 'react';
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from 'remotion';
/*
* Highlight a word in a sentence with a spring-animated wipe effect.
*/
// Ideal composition size: 1280x720
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const COLOR_HIGHLIGHT = '#A7C7E7';
const FULL_TEXT = 'This is Remotion.';
const HIGHLIGHT_WORD = 'Remotion';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const HIGHLIGHT_START_FRAME = 30;
const HIGHLIGHT_WIPE_DURATION = 18;
const { fontFamily } = loadFont();
const Highlight: React.FC<{
word: string;
color: string;
delay: number;
durationInFrames: number;
}> = ({ word, color, delay, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const highlightProgress = spring({
fps,
frame,
config: { damping: 200 },
delay,
durationInFrames,
});
const scaleX = Math.max(0, Math.min(1, highlightProgress));
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<span
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: '1.05em',
transform: `translateY(-50%) scaleX(${scaleX})`,
transformOrigin: 'left center',
backgroundColor: color,
borderRadius: '0.18em',
zIndex: 0,
}}
/>
<span style={{ position: 'relative', zIndex: 1 }}>{word}</span>
</span>
);
};
export const MyAnimation = () => {
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
const hasHighlight = highlightIndex >= 0;
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
const postText = hasHighlight ? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length) : '';
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
alignItems: 'center',
justifyContent: 'center',
fontFamily,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
}}
>
{hasHighlight ? (
<>
<span>{preText}</span>
<Highlight
word={HIGHLIGHT_WORD}
color={COLOR_HIGHLIGHT}
delay={HIGHLIGHT_START_FRAME}
durationInFrames={HIGHLIGHT_WIPE_DURATION}
/>
<span>{postText}</span>
</>
) : (
<span>{FULL_TEXT}</span>
)}
</div>
</AbsoluteFill>
);
};

View file

@ -0,0 +1,172 @@
---
name: audio
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
metadata:
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
---
# Using audio in Remotion
## Prerequisites
First, the @remotion/media package needs to be installed.
If it is not installed, use the following command:
```bash
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 pnpm
```
## Importing Audio
Use `<Audio>` from `@remotion/media` to add audio to your composition.
```tsx
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Audio src={staticFile("audio.mp3")} />;
};
```
Remote URLs are also supported:
```tsx
<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.
```tsx
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:
```tsx
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):
```tsx
<Audio src={staticFile("audio.mp3")} volume={0.5} />
```
Or use a callback for dynamic volume based on the current frame:
```tsx
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:
```tsx
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:
```tsx
<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:
```tsx
<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
```tsx
<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:
```tsx
<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 />`.

View file

@ -0,0 +1,104 @@
---
name: calculate-metadata
description: Dynamically set composition duration, dimensions, and props
metadata:
tags: calculateMetadata, duration, dimensions, props, dynamic
---
# Using calculateMetadata
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
```tsx
<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:
```tsx
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
```tsx
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
```tsx
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:
```tsx
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
return {
defaultOutName: `video-${props.id}.mp4`,
};
};
```
## Transforming props
Fetch data or transform props before rendering:
```tsx
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 frames
- `width`: Composition width in pixels
- `height`: Composition height in pixels
- `fps`: Frames per second
- `props`: Transformed props passed to the component
- `defaultOutName`: Default output filename
- `defaultCodec`: Default codec for rendering

View file

@ -0,0 +1,75 @@
---
name: can-decode
description: Check if a video can be decoded by the browser using Mediabunny
metadata:
tags: decode, validation, video, audio, compatibility, browser
---
# 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.
```tsx
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
```tsx
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`:
```tsx
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
};
```

View file

@ -0,0 +1,58 @@
---
name: charts
description: Chart and data visualization patterns for Remotion. Use when creating bar charts, pie charts, histograms, progress bars, or any data-driven animations.
metadata:
tags: charts, data, visualization, bar-chart, pie-chart, graphs
---
# 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](assets/charts/bar-chart.tsx) for a basic example implmentation.
### Staggered Bars
You can animate the height of the bars and stagger them like this:
```tsx
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.
```tsx
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})`} />;
```

View file

@ -0,0 +1,146 @@
---
name: compositions
description: Defining compositions, stills, folders, default props and dynamic metadata
metadata:
tags: composition, still, folder, props, metadata
---
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
It normally is placed in the `src/Root.tsx` file.
```tsx
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).
```tsx
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.
```tsx
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`.
```tsx
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.
```tsx
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.

View file

@ -0,0 +1,126 @@
---
name: display-captions
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
metadata:
tags: captions, subtitles, display, tiktok, highlight
---
# 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:
```bash
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 pnpm
```
## Creating pages
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
```tsx
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:
```tsx
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:
```tsx
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>
);
};
```

View file

@ -0,0 +1,229 @@
---
name: extract-frames
description: Extract frames from videos at specific timestamps using Mediabunny
metadata:
tags: frames, extract, video, thumbnail, filmstrip, canvas
---
# 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.
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
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
```tsx
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);
}
```

View file

@ -0,0 +1,152 @@
---
name: fonts
description: Loading Google Fonts and local fonts in Remotion
metadata:
tags: fonts, google-fonts, typography, text
---
# 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:
```bash
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 pnpm
```
```tsx
import { 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:
```tsx
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:
```tsx
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:
```bash
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 pnpm
```
### Loading a local font
Place your font file in the `public/` folder and use `loadFont()`:
```tsx
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:
```tsx
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
```tsx
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:
```tsx
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>
);
};
```

View file

@ -0,0 +1,58 @@
---
name: get-audio-duration
description: Getting the duration of an audio file in seconds with Mediabunny
metadata:
tags: duration, audio, length, time, seconds, mp3, wav
---
# 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
```tsx
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
```tsx
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`:
```tsx
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
```tsx
import { staticFile } from "remotion";
const duration = await getAudioDuration(staticFile("audio.mp3"));
```

View file

@ -0,0 +1,68 @@
---
name: get-video-dimensions
description: Getting the width and height of a video file with Mediabunny
metadata:
tags: dimensions, width, height, resolution, size, video
---
# 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
```tsx
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
```tsx
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
console.log(dimensions.width); // e.g. 1920
console.log(dimensions.height); // e.g. 1080
```
## Using with local files
For local files, use `FileSource` instead of `UrlSource`:
```tsx
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
```tsx
import { staticFile } from "remotion";
const dimensions = await getVideoDimensions(staticFile("video.mp4"));
```

View file

@ -0,0 +1,58 @@
---
name: get-video-duration
description: Getting the duration of a video file in seconds with Mediabunny
metadata:
tags: duration, video, length, time, seconds
---
# 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
```tsx
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
```tsx
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`:
```tsx
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
```tsx
import { staticFile } from "remotion";
const duration = await getVideoDuration(staticFile("video.mp4"));
```

View file

@ -0,0 +1,138 @@
---
name: gif
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
metadata:
tags: gif, animation, images, animated, apng, avif, webp
---
# Using Animated images in Remotion
## Basic usage
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
```tsx
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):
```tsx
<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:
```tsx
// 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:
```tsx
<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:
```tsx
// 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):
```tsx
<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.
```bash
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 pnpm
```
```tsx
import {getGifDurationInSeconds} from '@remotion/gif';
import {staticFile} from 'remotion';
const duration = await getGifDurationInSeconds(staticFile('animation.gif'));
console.log(duration); // e.g. 2.5
```
This is useful for setting the composition duration to match the GIF:
```tsx
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.
```bash
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 pnpm
```
```tsx
import {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.

View file

@ -0,0 +1,130 @@
---
name: images
description: Embedding images in Remotion using the <Img> component
metadata:
tags: images, img, staticFile, png, jpg, svg, webp
---
# Using images in Remotion
## The `<Img>` component
Always use the `<Img>` component from `remotion` to display images:
```tsx
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.json
```
```tsx
import { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} />
```
## Remote images
Remote URLs can be used directly without `staticFile()`:
```tsx
<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:
```tsx
<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:
```tsx
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:
```tsx
import { getImageDimensions, staticFile } from "remotion";
const { width, height } = await getImageDimensions(staticFile("photo.png"));
```
This is useful for calculating aspect ratios or sizing compositions:
```tsx
import { getImageDimensions, staticFile, CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const { width, height } = await getImageDimensions(staticFile("photo.png"));
return {
width,
height,
};
};
```

View file

@ -0,0 +1,67 @@
---
name: import-srt-captions
description: Importing .srt subtitle files into Remotion using @remotion/captions
metadata:
tags: captions, subtitles, srt, import, parse
---
# 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:
```bash
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 pnpm
```
## Reading an .srt file
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
```tsx
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.

View file

@ -0,0 +1,68 @@
---
name: lottie
description: Embedding Lottie animations in Remotion.
metadata:
category: Animation
---
# Using Lottie Animations in Remotion
## Prerequisites
First, the @remotion/lottie package needs to be installed.
If it is not, use the following command:
```bash
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 pnpm
```
## Displaying a Lottie file
To import a Lottie animation:
- Fetch the Lottie asset
- Wrap the loading process in `delayRender()` and `continueRender()`
- Save the animation data in a state
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
```tsx
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:
```tsx
return <Lottie animationData={animationData} style={{width: 400, height: 400}} />;
```

View file

@ -0,0 +1,35 @@
---
name: measuring-dom-nodes
description: Measuring DOM element dimensions in Remotion
metadata:
tags: measure, layout, dimensions, getBoundingClientRect, scale
---
# 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
```tsx
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>;
};
```

View file

@ -0,0 +1,143 @@
---
name: measuring-text
description: Measuring text dimensions, fitting text to containers, and checking overflow
metadata:
tags: measure, text, layout, dimensions, fitText, fillTextBox
---
# Measuring text in Remotion
## Prerequisites
Install @remotion/layout-utils if it is not already installed:
```bash
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 pnpm
```
## Measuring text dimensions
Use `measureText()` to calculate the width and height of text:
```tsx
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:
```tsx
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:
```tsx
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.
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
<div style={{ outline: "2px solid red" }}>Text</div>
```

View file

@ -0,0 +1,106 @@
---
name: sequencing
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
metadata:
tags: sequence, series, timing, delay, trim
---
Use `<Sequence>` to delay when an element appears in the timeline.
```tsx
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:
```tsx
<Sequence layout="none">
<Title />
</Sequence>
```
## Premounting
This loads the component in the timeline before it is actually played.
Always premount any `<Sequence>`!
```tsx
<Sequence premountFor={1 * fps}>
<Title />
</Sequence>
```
## Series
Use `<Series>` when elements should play one after another without overlap.
```tsx
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:
```tsx
<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):
```tsx
<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:
```tsx
<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>
```

View file

@ -0,0 +1,11 @@
---
name: tailwind
description: Using TailwindCSS in Remotion.
metadata:
---
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.

View file

@ -0,0 +1,20 @@
---
name: text-animations
description: Typography and text animation patterns for Remotion.
metadata:
tags: typography, text, typewriter, highlighter ken
---
## Text animations
Based on `useCurrentFrame()`, reduce the string character by character to create a typewriter effect.
## Typewriter Effect
See [Typewriter](assets/text-animations-typewriter.tsx) 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](assets/text-animations-word-highlight.tsx) for an example for how a word highlight is animated, like with a highlighter pen.

View file

@ -0,0 +1,179 @@
---
name: timing
description: Interpolation curves in Remotion - linear, easing, spring animations
metadata:
tags: spring, bounce, easing, interpolation
---
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:
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
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.
```ts title="Spring animation from 0 to 1 over 100 frames"
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:
```ts
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:
```tsx
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.
```tsx
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.
```tsx
const spring = spring({
frame,
fps,
durationInFrames: 40,
});
```
### Combining spring() with interpolate()
Map spring output (0-1) to custom ranges:
```tsx
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:
```tsx
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:
```ts
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:
```ts
const value1 = interpolate(frame, [0, 100], [0, 1], {
easing: Easing.inOut(Easing.quad),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
```
Cubic bezier curves are also supported:
```ts
const value1 = interpolate(frame, [0, 100], [0, 1], {
easing: Easing.bezier(0.8, 0.22, 0.96, 0.65),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
```

View file

@ -0,0 +1,19 @@
---
name: transcribe-captions
description: Transcribing audio to generate captions in Remotion
metadata:
tags: captions, transcribe, whisper, audio, speech-to-text
---
# 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

View file

@ -0,0 +1,122 @@
---
name: transitions
description: Fullscreen scene transitions for Remotion.
metadata:
tags: transitions, fade, slide, wipe, scenes
---
## 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:
```bash
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 pnpm
```
## Example usage
```tsx
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:
```tsx
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.
```tsx
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
```tsx
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 = 120` frames
- With transition: `60 + 60 - 15 = 105` frames
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:
```tsx
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 physics
```
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
### Calculating total composition duration
```tsx
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 frames
```

View file

@ -0,0 +1,53 @@
---
name: trimming
description: Trimming patterns for Remotion - cut the beginning or end of animations
metadata:
tags: sequence, trim, clip, cut, offset
---
Use `<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:
```tsx
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:
```tsx
<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:
```tsx
<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.

View file

@ -0,0 +1,171 @@
---
name: videos
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
metadata:
tags: video, media, trim, volume, speed, loop, pitch
---
# Using videos in Remotion
## Prerequisites
First, the @remotion/media package needs to be installed.
If it is not, use the following command:
```bash
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 pnpm
```
Use `<Video>` from `@remotion/media` to embed videos into your composition.
```tsx
import { Video } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Video src={staticFile("video.mp4")} />;
};
```
Remote URLs are also supported:
```tsx
<Video src="https://remotion.media/video.mp4" />
```
## Trimming
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
```tsx
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:
```tsx
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:
```tsx
<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):
```tsx
<Video src={staticFile("video.mp4")} volume={0.5} />
```
Or use a callback for dynamic volume based on the current frame:
```tsx
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:
```tsx
<Video src={staticFile("video.mp4")} muted />
```
## Speed
Use `playbackRate` to change the playback speed:
```tsx
<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:
```tsx
<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 (for `volume` callback)
- `"extend"`: Frame count continues incrementing
```tsx
<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:
```tsx
<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 />`.

View file

@ -1,26 +1,144 @@
/** /**
* GET /:username/collections/:slug/skills.md * GET /:username/collections/:slug/skills.md
* Generate AI-powered skills documentation for a collection * Generate AI-powered skills documentation for a collection
* Clean URL endpoint for machine-readable skills documentation *
* Supports chunked generation for large collections (60+ tools) to avoid
* Vercel's 120s timeout limit. Uses per-tool caching and recursive batch
* processing.
*/ */
import { prisma } from '@tpmjs/db'; import { prisma } from '@tpmjs/db';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { fetchMultiplePackageSources } from '~/lib/ai/package-source-fetcher'; import {
fetchMultiplePackageSources,
fetchPackageSource,
type PackageSource,
} from '~/lib/ai/package-source-fetcher';
import { generateSkillsMarkdown } from '~/lib/ai/skills-generator'; import { generateSkillsMarkdown } from '~/lib/ai/skills-generator';
import { assembleSkillsDocument, generateSkillsSummary } from '~/lib/ai/skills-summary-generator';
import { generateToolSkillsBatch } from '~/lib/ai/tool-skills-generator';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 120; // Extended for source fetching + AI generation export const maxDuration = 300; // Extended for chunked generation (5 min)
// Cache for 1 week // Cache for 1 week
const CACHE_DURATION_SECONDS = 7 * 24 * 60 * 60; // 604800 seconds const CACHE_DURATION_SECONDS = 7 * 24 * 60 * 60; // 604800 seconds
// Batch size for chunked generation
const BATCH_SIZE = 10;
// Threshold for using chunked generation (tools)
const CHUNKED_THRESHOLD = 20;
type RouteContext = { type RouteContext = {
params: Promise<{ username: string; slug: string }>; params: Promise<{ username: string; slug: string }>;
}; };
interface CollectionWithTools {
id: string;
name: string;
slug: string | null;
description: string | null;
isPublic: boolean;
skillsMarkdown: string | null;
skillsGeneratedAt: Date | null;
tools: Array<{
tool: {
id: string;
name: string;
description: string;
inputSchema: unknown;
package: {
npmPackageName: string;
npmVersion: string;
};
};
}>;
}
/**
* Load collection with tools from database
*/
async function loadCollection(
username: string,
slug: string
): Promise<{ user: { username: string }; collection: CollectionWithTools } | Response> {
const user = await prisma.user.findUnique({
where: { username },
select: { id: true, username: true },
});
if (!user || !user.username) {
return new Response('User not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
const collection = await prisma.collection.findFirst({
where: { slug, userId: user.id },
include: {
tools: {
include: {
tool: {
select: {
id: true,
name: true,
description: true,
inputSchema: true,
package: { select: { npmPackageName: true, npmVersion: true } },
},
},
},
orderBy: { position: 'asc' },
take: 100,
},
},
});
if (!collection) {
return new Response('Collection not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
if (!collection.isPublic) {
return new Response('This collection is not public', {
status: 403,
headers: { 'Content-Type': 'text/plain' },
});
}
return { user: { username: user.username }, collection };
}
/**
* Check cache and return cached response if valid
*/
function checkCache(collection: CollectionWithTools, cachebust: boolean): Response | null {
if (cachebust || !collection.skillsMarkdown || !collection.skillsGeneratedAt) {
return null;
}
const cacheAge = Date.now() - collection.skillsGeneratedAt.getTime();
if (cacheAge >= CACHE_DURATION_SECONDS * 1000) {
return null;
}
return new Response(collection.skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'HIT',
'X-Cache-Age': Math.floor(cacheAge / 1000).toString(),
},
});
}
/** /**
* GET /:username/collections/:slug/skills.md * GET /:username/collections/:slug/skills.md
* Generate skills.md markdown for a collection * Generate skills.md markdown for a collection
@ -30,116 +148,31 @@ export async function GET(request: NextRequest, context: RouteContext) {
const { username: rawUsername, slug } = await context.params; const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
// Check for cachebust query param
const cachebust = request.nextUrl.searchParams.has('cachebust'); const cachebust = request.nextUrl.searchParams.has('cachebust');
const batchIndex = request.nextUrl.searchParams.get('_batch');
const jobId = request.nextUrl.searchParams.get('_jobId');
// Find the user first // Handle batch continuation requests
const user = await prisma.user.findUnique({ if (batchIndex !== null && jobId) {
where: { username }, return handleBatchContinuation(jobId, parseInt(batchIndex, 10), username, slug);
select: { id: true, username: true },
});
if (!user || !user.username) {
return new Response('User not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
} }
// Find the collection by slug belonging to this user // Load collection from database
const collection = await prisma.collection.findFirst({ const result = await loadCollection(username, slug);
where: { if (result instanceof Response) return result;
slug, const { user, collection } = result;
userId: user.id,
},
include: {
tools: {
include: {
tool: {
select: {
id: true,
name: true,
description: true,
inputSchema: true,
package: {
select: {
npmPackageName: true,
npmVersion: true,
},
},
},
},
},
orderBy: { position: 'asc' },
take: 50, // Limit tools for performance
},
},
});
if (!collection) { // Check cache
return new Response('Collection not found', { const cachedResponse = checkCache(collection, cachebust);
status: 404, if (cachedResponse) return cachedResponse;
headers: { 'Content-Type': 'text/plain' },
});
}
// Only return if public if (collection.tools.length === 0) {
if (!collection.isPublic) {
return new Response('This collection is not public', {
status: 403,
headers: { 'Content-Type': 'text/plain' },
});
}
// Check cache if not busting
if (!cachebust && collection.skillsMarkdown && collection.skillsGeneratedAt) {
// Check if cache is still valid (within 1 week)
const cacheAge = Date.now() - collection.skillsGeneratedAt.getTime();
if (cacheAge < CACHE_DURATION_SECONDS * 1000) {
return new Response(collection.skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'HIT',
'X-Cache-Age': Math.floor(cacheAge / 1000).toString(),
},
});
}
}
// Extract tools data
const tools = collection.tools.map((ct) => ({
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
packageName: ct.tool.package.npmPackageName,
inputSchema: ct.tool.inputSchema,
}));
if (tools.length === 0) {
return new Response('Collection has no tools', { return new Response('Collection has no tools', {
status: 400, status: 400,
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
}); });
} }
// Get unique packages to fetch
const uniquePackages = [
...new Map(
collection.tools.map((ct) => [
ct.tool.package.npmPackageName,
{
name: ct.tool.package.npmPackageName,
version: ct.tool.package.npmVersion,
},
])
).values(),
];
// Fetch package sources (limit to 5 packages for performance)
const packageSources = await fetchMultiplePackageSources(uniquePackages.slice(0, 5));
// Build MCP URLs // Build MCP URLs
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com'; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
const mcpUrls = { const mcpUrls = {
@ -147,46 +180,480 @@ export async function GET(request: NextRequest, context: RouteContext) {
sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`, sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`,
}; };
// Generate skills markdown // Route to appropriate handler based on collection size
const skillsMarkdown = await generateSkillsMarkdown( if (collection.tools.length < CHUNKED_THRESHOLD) {
{ return handleSmallCollection(collection, user.username, mcpUrls);
id: collection.id, }
name: collection.name, return handleLargeCollection(collection, user.username, mcpUrls, cachebust);
slug: collection.slug || slug,
description: collection.description,
username: user.username,
},
tools,
packageSources,
mcpUrls
);
// Save to database for caching
await prisma.collection.update({
where: { id: collection.id },
data: {
skillsMarkdown,
skillsGeneratedAt: new Date(),
},
});
return new Response(skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'MISS',
},
});
} catch (error) { } catch (error) {
console.error('[Skills.md Error] GET /:username/collections/:slug/skills.md:', error); console.error('[Skills.md Error]:', error);
const message = const message =
error instanceof Error ? error.message : 'Failed to generate skills documentation'; error instanceof Error ? error.message : 'Failed to generate skills documentation';
return new Response(`Error: ${message}`, { return new Response(`Error: ${message}`, {
status: 500, status: 500,
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
}); });
} }
} }
/**
* Handle small collections with the original monolithic approach
*/
async function handleSmallCollection(
collection: CollectionWithTools,
username: string,
mcpUrls: { http: string; sse: string }
): Promise<Response> {
const tools = collection.tools.map((ct) => ({
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
packageName: ct.tool.package.npmPackageName,
inputSchema: ct.tool.inputSchema,
}));
// Get unique packages to fetch
const uniquePackages = [
...new Map(
collection.tools.map((ct) => [
ct.tool.package.npmPackageName,
{
name: ct.tool.package.npmPackageName,
version: ct.tool.package.npmVersion,
},
])
).values(),
];
// Fetch package sources (limit to 5 packages for performance)
const packageSources = await fetchMultiplePackageSources(uniquePackages.slice(0, 5));
// Generate skills markdown using original function
const skillsMarkdown = await generateSkillsMarkdown(
{
id: collection.id,
name: collection.name,
slug: collection.slug || '',
description: collection.description,
username,
},
tools,
packageSources,
mcpUrls
);
// Save to database for caching
await prisma.collection.update({
where: { id: collection.id },
data: {
skillsMarkdown,
skillsGeneratedAt: new Date(),
},
});
return new Response(skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'MISS',
'X-Generation-Mode': 'monolithic',
},
});
}
/**
* Handle large collections with chunked generation
*/
async function handleLargeCollection(
collection: CollectionWithTools,
username: string,
mcpUrls: { http: string; sse: string },
cachebust: boolean
): Promise<Response> {
const toolIds = collection.tools.map((ct) => ct.tool.id);
// Check for existing in-progress job
const existingJob = await prisma.skillsGenerationJob.findFirst({
where: {
collectionId: collection.id,
status: { in: ['pending', 'processing'] },
// Only consider jobs from the last 10 minutes (prevent stuck jobs)
createdAt: { gte: new Date(Date.now() - 10 * 60 * 1000) },
},
});
if (existingJob) {
// Job in progress - return 202 Accepted with retry hint
return new Response(
JSON.stringify({
status: 'processing',
message: 'Skills generation in progress',
progress: {
currentBatch: existingJob.currentBatch,
totalBatches: existingJob.totalBatches,
completedTools: existingJob.completedToolIds.length,
totalTools: toolIds.length,
},
}),
{
status: 202,
headers: {
'Content-Type': 'application/json',
'Retry-After': '10',
},
}
);
}
// Check which tools already have cached skills (unless cachebust)
let toolsWithCache: string[] = [];
if (!cachebust) {
const cachedTools = await prisma.toolSkillsCache.findMany({
where: { toolId: { in: toolIds } },
select: { toolId: true },
});
toolsWithCache = cachedTools.map((c) => c.toolId);
}
const toolsNeedingGeneration = toolIds.filter((id) => !toolsWithCache.includes(id));
// If all tools are cached, assemble final document
if (toolsNeedingGeneration.length === 0) {
return assembleFinalDocument(collection, username, mcpUrls);
}
// Create a new generation job
const totalBatches = Math.ceil(toolsNeedingGeneration.length / BATCH_SIZE);
const job = await prisma.skillsGenerationJob.create({
data: {
collectionId: collection.id,
status: 'processing',
currentBatch: 0,
totalBatches,
completedToolIds: toolsWithCache, // Include already-cached tools
},
});
// Process the first batch synchronously
try {
await processBatch(job.id, 0, collection, toolsNeedingGeneration);
} catch (error) {
console.error('[Skills.md] Batch 0 failed:', error);
await prisma.skillsGenerationJob.update({
where: { id: job.id },
data: {
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
},
});
throw error;
}
// Check if there are more batches to process
if (totalBatches > 1) {
// Trigger the next batch via recursive fetch
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
const nextBatchUrl = `${baseUrl}/${username}/collections/${collection.slug}/skills.md?_batch=1&_jobId=${job.id}`;
// Fire and forget - don't await
fetch(nextBatchUrl, {
method: 'GET',
headers: { 'x-internal': 'true' },
}).catch((err) => {
console.error('[Skills.md] Failed to trigger batch 1:', err);
});
// Return 202 to indicate processing
return new Response(
JSON.stringify({
status: 'processing',
message: 'Skills generation started',
progress: {
currentBatch: 1,
totalBatches,
completedTools: BATCH_SIZE + toolsWithCache.length,
totalTools: toolIds.length,
},
}),
{
status: 202,
headers: {
'Content-Type': 'application/json',
'Retry-After': '15',
},
}
);
}
// Single batch - assemble and return final document
return assembleFinalDocument(collection, username, mcpUrls);
}
/**
* Handle batch continuation (recursive call)
*/
async function handleBatchContinuation(
jobId: string,
batchIndex: number,
username: string,
slug: string
): Promise<Response> {
// Fetch the job
const job = await prisma.skillsGenerationJob.findUnique({
where: { id: jobId },
include: {
collection: {
include: {
tools: {
include: {
tool: {
select: {
id: true,
name: true,
description: true,
inputSchema: true,
package: {
select: {
npmPackageName: true,
npmVersion: true,
},
},
},
},
},
orderBy: { position: 'asc' },
take: 100,
},
},
},
},
});
if (!job || job.status === 'failed' || job.status === 'completed') {
return new Response('Job not found or already completed', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
const collection = job.collection;
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
const mcpUrls = {
http: `${baseUrl}/api/mcp/${username}/${slug}/http`,
sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`,
};
// Get tools needing generation
const allToolIds = collection.tools.map((ct) => ct.tool.id);
const toolsNeedingGeneration = allToolIds.filter((id) => !job.completedToolIds.includes(id));
try {
await processBatch(job.id, batchIndex, collection, toolsNeedingGeneration);
} catch (error) {
console.error(`[Skills.md] Batch ${batchIndex} failed:`, error);
await prisma.skillsGenerationJob.update({
where: { id: job.id },
data: {
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
},
});
return new Response('Batch processing failed', {
status: 500,
headers: { 'Content-Type': 'text/plain' },
});
}
// Check if there are more batches
if (batchIndex + 1 < job.totalBatches) {
// Trigger next batch
const nextBatchUrl = `${baseUrl}/${username}/collections/${slug}/skills.md?_batch=${batchIndex + 1}&_jobId=${job.id}`;
fetch(nextBatchUrl, {
method: 'GET',
headers: { 'x-internal': 'true' },
}).catch((err) => {
console.error(`[Skills.md] Failed to trigger batch ${batchIndex + 1}:`, err);
});
return new Response(
JSON.stringify({
status: 'processing',
batchCompleted: batchIndex,
nextBatch: batchIndex + 1,
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
}
);
}
// All batches done - run final pass
const result = await assembleFinalDocument(collection, username, mcpUrls);
// Mark job as completed
await prisma.skillsGenerationJob.update({
where: { id: job.id },
data: { status: 'completed' },
});
return result;
}
/**
* Process a single batch of tools
*/
async function processBatch(
jobId: string,
batchIndex: number,
collection: CollectionWithTools,
toolsNeedingGeneration: string[]
): Promise<void> {
const startIdx = batchIndex * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, toolsNeedingGeneration.length);
const batchToolIds = toolsNeedingGeneration.slice(startIdx, endIdx);
if (batchToolIds.length === 0) {
return;
}
// Get tool data for this batch
const batchTools = collection.tools
.filter((ct) => batchToolIds.includes(ct.tool.id))
.map((ct) => ({
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
packageName: ct.tool.package.npmPackageName,
packageVersion: ct.tool.package.npmVersion,
inputSchema: ct.tool.inputSchema,
}));
// Get unique packages for this batch
const uniquePackages = [...new Set(batchTools.map((t) => t.packageName))];
// Fetch package sources for this batch (limit to 5 per batch)
const packageSourceResults = await Promise.allSettled(
uniquePackages
.slice(0, 5)
.map((name) =>
fetchPackageSource(name, batchTools.find((t) => t.packageName === name)?.packageVersion)
)
);
const packageSourcesMap = new Map<string, PackageSource>();
for (const result of packageSourceResults) {
if (result.status === 'fulfilled') {
packageSourcesMap.set(result.value.packageName, result.value);
}
}
// Generate skills for batch
const results = await generateToolSkillsBatch(batchTools, packageSourcesMap);
// Save to per-tool cache
const cacheUpserts = Array.from(results.entries()).map(([toolId, markdown]) =>
prisma.toolSkillsCache.upsert({
where: { toolId },
create: {
toolId,
skillsMarkdown: markdown,
},
update: {
skillsMarkdown: markdown,
generatedAt: new Date(),
},
})
);
await Promise.all(cacheUpserts);
// Update job progress
const completedToolIds = [...batchToolIds];
await prisma.skillsGenerationJob.update({
where: { id: jobId },
data: {
currentBatch: batchIndex + 1,
completedToolIds: { push: completedToolIds },
},
});
}
/**
* Assemble the final document from cached tool sections
*/
async function assembleFinalDocument(
collection: CollectionWithTools,
username: string,
mcpUrls: { http: string; sse: string }
): Promise<Response> {
const toolIds = collection.tools.map((ct) => ct.tool.id);
// Fetch all cached tool sections
const cachedTools = await prisma.toolSkillsCache.findMany({
where: { toolId: { in: toolIds } },
});
// Create a map for ordering
const cacheMap = new Map(cachedTools.map((c) => [c.toolId, c.skillsMarkdown]));
// Order sections by collection tool order
const toolSections = collection.tools
.map((ct) => cacheMap.get(ct.tool.id))
.filter((section): section is string => !!section);
// Get package names for summary
const packageNames = collection.tools.map((ct) => ct.tool.package.npmPackageName);
// Generate summary (second AI pass)
const summary = await generateSkillsSummary(
{
id: collection.id,
name: collection.name,
slug: collection.slug || '',
description: collection.description,
username,
},
toolSections,
mcpUrls,
packageNames
);
// Assemble final document
const skillsMarkdown = assembleSkillsDocument(
{
id: collection.id,
name: collection.name,
slug: collection.slug || '',
description: collection.description,
username,
},
toolSections,
summary,
mcpUrls,
packageNames
);
// Save to database for caching
await prisma.collection.update({
where: { id: collection.id },
data: {
skillsMarkdown,
skillsGeneratedAt: new Date(),
},
});
return new Response(skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'MISS',
'X-Generation-Mode': 'chunked',
'X-Tool-Count': toolSections.length.toString(),
},
});
}

View file

@ -1,6 +1,11 @@
/** /**
* AI-powered skills documentation generator for tool collections * AI-powered skills documentation generator for tool collections
* Uses Vercel AI SDK to analyze package source code and generate comprehensive skills.md * Uses Vercel AI SDK to analyze package source code and generate comprehensive skills.md
*
* This module provides the original monolithic generation function for backward compatibility.
* For chunked generation of large collections, use the new modules:
* - tool-skills-generator.ts: Per-tool skills generation
* - skills-summary-generator.ts: Summary/intro generation
*/ */
import { openai } from '@ai-sdk/openai'; import { openai } from '@ai-sdk/openai';

View file

@ -0,0 +1,271 @@
/**
* Skills Summary Generator (Second Pass)
* Analyzes all tool sections and generates cohesive intro, workflows, and summary
*/
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export interface CollectionData {
id: string;
name: string;
slug: string;
description: string | null;
username: string;
}
export interface McpUrls {
http: string;
sse: string;
}
export interface SkillsSummary {
intro: string;
workflows: string;
summary: string;
}
const SUMMARY_SYSTEM_PROMPT = `You are an expert technical writer creating cohesive documentation from individual tool sections.
Your task is to analyze all tool documentation sections and generate:
1. An introduction that describes the collection's overall capabilities
2. Multi-tool workflow examples showing how tools work together
3. A summary with constraints and safety considerations
Guidelines:
- Be concise and focused on practical usage
- Identify synergies between tools
- Generate realistic workflow examples
- Document limitations based on tool analysis
Output clean, well-organized Markdown sections.`;
function buildSummaryPrompt(
collection: CollectionData,
toolSections: string[],
mcpUrls: McpUrls,
packageNames: string[]
): string {
const baseUrl = mcpUrls.http.replace(/\/api\/mcp\/.*$/, '');
const collectionUrl = `${baseUrl}/${collection.username}/collections/${collection.slug}`;
return `Analyze these tool documentation sections and generate summary content for the skills.md document.
## Collection Info
- **Name:** ${collection.name}
- **Owner:** @${collection.username}
- **Slug:** ${collection.slug}
- **Description:** ${collection.description || 'No description'}
- **Collection URL:** ${collectionUrl}
- **Tool Count:** ${toolSections.length} tools from ${new Set(packageNames).size} packages
## MCP Endpoints
- **HTTP:** ${mcpUrls.http}
- **SSE:** ${mcpUrls.sse}
## Individual Tool Sections
${toolSections.join('\n\n---\n\n')}
---
Generate three sections. Output each section with its exact header:
## INTRO_START
[Generate an introduction paragraph describing the collection's overall capabilities and primary use cases. Keep it to 2-3 paragraphs max.]
## INTRO_END
## WORKFLOWS_START
[Generate 2-3 multi-tool workflow examples showing how tools can work together. Include TypeScript/bash code examples for each workflow.]
## WORKFLOWS_END
## SUMMARY_START
[Generate a summary section with:
- What the collection does NOT support (limitations)
- Rate limits info
- Authentication requirements
- Versioning info with current timestamp]
## SUMMARY_END`;
}
function parseSummaryResponse(text: string): SkillsSummary {
// Extract sections using markers
const introMatch = text.match(/## INTRO_START\n([\s\S]*?)## INTRO_END/);
const workflowsMatch = text.match(/## WORKFLOWS_START\n([\s\S]*?)## WORKFLOWS_END/);
const summaryMatch = text.match(/## SUMMARY_START\n([\s\S]*?)## SUMMARY_END/);
return {
intro: introMatch?.[1]?.trim() || 'Skills documentation for this collection.',
workflows: workflowsMatch?.[1]?.trim() || '',
summary: summaryMatch?.[1]?.trim() || '',
};
}
/**
* Generate skills summary by analyzing all tool sections
*/
export async function generateSkillsSummary(
collection: CollectionData,
toolSections: string[],
mcpUrls: McpUrls,
packageNames: string[]
): Promise<SkillsSummary> {
if (toolSections.length === 0) {
return {
intro: 'This collection has no tools.',
workflows: '',
summary: '',
};
}
const prompt = buildSummaryPrompt(collection, toolSections, mcpUrls, packageNames);
const { text } = await generateText({
model: openai('gpt-4.1-mini'),
system: SUMMARY_SYSTEM_PROMPT,
prompt,
temperature: 0.3,
});
return parseSummaryResponse(text);
}
/**
* Assemble the final skills.md document from individual sections
*/
export function assembleSkillsDocument(
collection: CollectionData,
toolSections: string[],
summary: SkillsSummary,
mcpUrls: McpUrls,
packageNames: string[]
): string {
const baseUrl = mcpUrls.http.replace(/\/api\/mcp\/.*$/, '');
const collectionUrl = `${baseUrl}/${collection.username}/collections/${collection.slug}`;
const uniquePackages = new Set(packageNames).size;
const timestamp = new Date().toISOString();
return `# Agent Skills Declaration: ${collection.name}
> Machine-consumable capability contract for AI agents (Claude Code, MCP clients, tool routers)
## 1. Agent Identity
**Collection:** ${collection.name}
**Owner:** @${collection.username}
**Description:** ${collection.description || 'No description'}
**Tool Count:** ${toolSections.length} tools from ${uniquePackages} packages
${summary.intro}
---
## 2. Core Skills
${toolSections.join('\n\n---\n\n')}
---
## 3. Multi-Tool Workflows
${summary.workflows || '*No workflow examples generated.*'}
---
## 4. Agent Integration Methods
### 4.1 CLI (\`tpm run\`)
Install CLI:
\`\`\`bash
npm install -g @tpmjs/cli
\`\`\`
Run a tool:
\`\`\`bash
tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name] --args '{"key": "value"}'
\`\`\`
### 4.2 REST API (JSON-RPC 2.0)
**Endpoint:** \`POST ${mcpUrls.http}\`
\`\`\`bash
curl -X POST ${mcpUrls.http} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "[tool-name]",
"arguments": { ... }
}
}'
\`\`\`
**List available tools:**
\`\`\`bash
curl -X POST ${mcpUrls.http} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
\`\`\`
### 4.3 MCP Server
**URLs:**
- HTTP: \`${mcpUrls.http}\`
- SSE: \`${mcpUrls.sse}\`
**Claude Desktop Config (\`claude_desktop_config.json\`):**
\`\`\`json
{
"mcpServers": {
"${collection.slug}": {
"url": "${mcpUrls.sse}",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
\`\`\`
**Local MCP Server (via CLI):**
\`\`\`bash
tpm mcp serve ${collection.username}/${collection.slug}
\`\`\`
---
## 5. Constraints & Safety
${
summary.summary ||
`**Rate Limits:**
- Free tier: 100 requests/hour
- Authenticated: 1000 requests/hour
**Authentication:**
- Public collections: No auth required for read
- Tool execution: API key required`
}
---
## 6. Versioning
**Skills Version:** 1.0.0
**Generated:** ${timestamp}
**Collection Page:** ${collectionUrl}
---
## 7. Canonical References
- TPMJS: https://tpmjs.com
- MCP Protocol: https://modelcontextprotocol.io
- Collection Page: ${collectionUrl}
`;
}

View file

@ -0,0 +1,140 @@
/**
* Per-tool skills documentation generator
* Generates markdown section for a single tool, designed for batched processing
*/
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import type { PackageSource } from './package-source-fetcher';
export interface ToolData {
id: string;
name: string;
description: string;
packageName: string;
packageVersion: string;
inputSchema: unknown | null;
}
const TOOL_SKILLS_SYSTEM_PROMPT = `You are an expert technical writer generating skills documentation for a single tool.
Your task is to analyze the source code and generate a comprehensive skills section for this tool that can be used by AI agents.
Guidelines:
1. **Accuracy First**: Only document capabilities you can verify from the source code
2. **Real Examples**: Generate code examples based on actual function signatures
3. **Input/Output Schemas**: Extract exact TypeScript types when available
4. **Source Analysis**: Provide insights about implementation details
5. **Concise**: Focus on what's useful for tool invocation
Output clean, well-organized Markdown.`;
function buildToolPrompt(tool: ToolData, packageSource: PackageSource | null): string {
const sourceContext = packageSource
? packageSource.files
.map((f) => `### ${f.path}\n\`\`\`typescript\n${f.content.slice(0, 4000)}\n\`\`\``)
.join('\n\n')
: 'Source code not available';
return `Generate a skills section for this tool.
## Tool Info
- **Name:** ${tool.name}
- **Package:** ${tool.packageName}@${tool.packageVersion}
- **Description:** ${tool.description}
## Input Schema
${tool.inputSchema ? JSON.stringify(tool.inputSchema, null, 2) : 'Not available'}
## Package Source Code
${sourceContext}
---
Generate a markdown section following this structure:
### Skill: ${tool.name}
**Package:** \`${tool.packageName}\`
**Description:** ${tool.description}
**Input Schema:**
\`\`\`typescript
[Extract from source code or input schema]
\`\`\`
**Output Format:** [Analyze from source]
**Source Analysis:**
[AI-generated insights from reading actual code - what does this tool actually do internally?]
**Example Usage:**
\`\`\`typescript
[Real example based on source code patterns]
\`\`\`
**Constraints:**
- [What this tool can and cannot do]
`;
}
/**
* Generate skills markdown section for a single tool
*/
export async function generateToolSkills(
tool: ToolData,
packageSource: PackageSource | null
): Promise<string> {
const prompt = buildToolPrompt(tool, packageSource);
const { text } = await generateText({
model: openai('gpt-4.1-mini'),
system: TOOL_SKILLS_SYSTEM_PROMPT,
prompt,
temperature: 0.3,
});
return text;
}
/**
* Generate skills markdown for a batch of tools in parallel
* Returns a map of toolId -> markdown
*/
export async function generateToolSkillsBatch(
tools: ToolData[],
packageSources: Map<string, PackageSource>
): Promise<Map<string, string>> {
const results = new Map<string, string>();
// Process tools in parallel (10 at a time is reasonable for API limits)
const promises = tools.map(async (tool) => {
try {
const packageSource = packageSources.get(tool.packageName) || null;
const markdown = await generateToolSkills(tool, packageSource);
return { toolId: tool.id, markdown };
} catch (error) {
console.error(`[ToolSkillsGenerator] Failed to generate skills for ${tool.name}:`, error);
// Return a fallback section on error
return {
toolId: tool.id,
markdown: `### Skill: ${tool.name}
**Package:** \`${tool.packageName}\`
**Description:** ${tool.description}
*Skills documentation generation failed. Please retry.*
`,
};
}
});
const settledResults = await Promise.allSettled(promises);
for (const result of settledResults) {
if (result.status === 'fulfilled') {
results.set(result.value.toolId, result.value.markdown);
}
}
return results;
}

View file

@ -75,6 +75,86 @@
"isESM": true, "isESM": true,
"relativePath": ["dist", "commands", "playground.js"] "relativePath": ["dist", "commands", "playground.js"]
}, },
"run": {
"aliases": [],
"args": {},
"description": "Execute a tool from a collection via MCP",
"examples": [
"<%= config.bin %> run -c ajax/unsandbox -t execute --args '{\"code\":\"print(1)\",\"language\":\"python\"}'",
"<%= config.bin %> run --collection ajax/ajax-collection --tool base64Encode --args '{\"data\":\"hello\"}'",
"OPENAI_API_KEY=xxx <%= config.bin %> run -c ajax/my-collection -t myTool",
"<%= config.bin %> run -c ajax/tools -t search --args '{\"query\":\"test\"}' --json",
"<%= config.bin %> run -c ajax/tools -t search --env API_KEY=xxx --env DEBUG=true"
],
"flags": {
"collection": {
"char": "c",
"description": "Collection identifier (username/slug)",
"name": "collection",
"required": true,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"tool": {
"char": "t",
"description": "Tool name (MCP format: package--toolName)",
"name": "tool",
"required": true,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"args": {
"char": "a",
"description": "Tool arguments as JSON string",
"name": "args",
"default": "{}",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"env": {
"char": "e",
"description": "Environment variables (key=value)",
"name": "env",
"hasDynamicHelp": false,
"multiple": true,
"type": "option"
},
"json": {
"description": "Output result as JSON",
"name": "json",
"allowNo": false,
"type": "boolean"
},
"verbose": {
"char": "v",
"description": "Show verbose output",
"name": "verbose",
"allowNo": false,
"type": "boolean"
},
"timeout": {
"description": "Request timeout in seconds",
"name": "timeout",
"default": 60,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [],
"id": "run",
"pluginAlias": "@tpmjs/cli",
"pluginName": "@tpmjs/cli",
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"isESM": true,
"relativePath": ["dist", "commands", "run.js"]
},
"update": { "update": {
"aliases": [], "aliases": [],
"args": {}, "args": {},
@ -614,6 +694,127 @@
"isESM": true, "isESM": true,
"relativePath": ["dist", "commands", "auth", "whoami.js"] "relativePath": ["dist", "commands", "auth", "whoami.js"]
}, },
"mcp:config": {
"aliases": [],
"args": {
"collection": {
"description": "Collection path (username/slug)",
"name": "collection",
"required": true
}
},
"description": "Generate MCP configuration for AI clients",
"examples": [
"<%= config.bin %> <%= command.id %> ajax/ajax-collection",
"<%= config.bin %> <%= command.id %> ajax/ajax-collection --client cursor",
"<%= config.bin %> <%= command.id %> ajax/ajax-collection --output ~/Library/Application\\ Support/Claude/claude_desktop_config.json"
],
"flags": {
"client": {
"char": "c",
"description": "Target client (claude, cursor, windsurf, generic)",
"name": "client",
"default": "claude",
"hasDynamicHelp": false,
"multiple": false,
"options": ["claude", "cursor", "windsurf", "generic"],
"type": "option"
},
"output": {
"char": "o",
"description": "Output file path (will merge with existing config)",
"name": "output",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"json": {
"description": "Output in JSON format",
"name": "json",
"allowNo": false,
"type": "boolean"
},
"api-key": {
"char": "k",
"description": "API key to include in config (optional)",
"name": "api-key",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [],
"id": "mcp:config",
"pluginAlias": "@tpmjs/cli",
"pluginName": "@tpmjs/cli",
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"isESM": true,
"relativePath": ["dist", "commands", "mcp", "config.js"]
},
"mcp:serve": {
"aliases": [],
"args": {},
"description": "Run as a local MCP server",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --port 8080",
"<%= config.bin %> <%= command.id %> --stdio",
"<%= config.bin %> <%= command.id %> --collection my-collection"
],
"flags": {
"port": {
"char": "p",
"description": "Port to run the server on (HTTP mode)",
"name": "port",
"default": 3333,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"stdio": {
"description": "Use stdio transport instead of HTTP",
"name": "stdio",
"allowNo": false,
"type": "boolean"
},
"collection": {
"char": "c",
"description": "Serve tools from a specific collection",
"name": "collection",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"tool": {
"char": "t",
"description": "Serve specific tools (comma-separated)",
"name": "tool",
"hasDynamicHelp": false,
"multiple": true,
"type": "option"
},
"verbose": {
"char": "v",
"description": "Show verbose output",
"name": "verbose",
"allowNo": false,
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [],
"id": "mcp:serve",
"pluginAlias": "@tpmjs/cli",
"pluginName": "@tpmjs/cli",
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"isESM": true,
"relativePath": ["dist", "commands", "mcp", "serve.js"]
},
"collection:add": { "collection:add": {
"aliases": [], "aliases": [],
"args": { "args": {
@ -963,127 +1164,6 @@
"isESM": true, "isESM": true,
"relativePath": ["dist", "commands", "collection", "update.js"] "relativePath": ["dist", "commands", "collection", "update.js"]
}, },
"mcp:config": {
"aliases": [],
"args": {
"collection": {
"description": "Collection path (username/slug)",
"name": "collection",
"required": true
}
},
"description": "Generate MCP configuration for AI clients",
"examples": [
"<%= config.bin %> <%= command.id %> ajax/ajax-collection",
"<%= config.bin %> <%= command.id %> ajax/ajax-collection --client cursor",
"<%= config.bin %> <%= command.id %> ajax/ajax-collection --output ~/Library/Application\\ Support/Claude/claude_desktop_config.json"
],
"flags": {
"client": {
"char": "c",
"description": "Target client (claude, cursor, windsurf, generic)",
"name": "client",
"default": "claude",
"hasDynamicHelp": false,
"multiple": false,
"options": ["claude", "cursor", "windsurf", "generic"],
"type": "option"
},
"output": {
"char": "o",
"description": "Output file path (will merge with existing config)",
"name": "output",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"json": {
"description": "Output in JSON format",
"name": "json",
"allowNo": false,
"type": "boolean"
},
"api-key": {
"char": "k",
"description": "API key to include in config (optional)",
"name": "api-key",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [],
"id": "mcp:config",
"pluginAlias": "@tpmjs/cli",
"pluginName": "@tpmjs/cli",
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"isESM": true,
"relativePath": ["dist", "commands", "mcp", "config.js"]
},
"mcp:serve": {
"aliases": [],
"args": {},
"description": "Run as a local MCP server",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --port 8080",
"<%= config.bin %> <%= command.id %> --stdio",
"<%= config.bin %> <%= command.id %> --collection my-collection"
],
"flags": {
"port": {
"char": "p",
"description": "Port to run the server on (HTTP mode)",
"name": "port",
"default": 3333,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"stdio": {
"description": "Use stdio transport instead of HTTP",
"name": "stdio",
"allowNo": false,
"type": "boolean"
},
"collection": {
"char": "c",
"description": "Serve tools from a specific collection",
"name": "collection",
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"tool": {
"char": "t",
"description": "Serve specific tools (comma-separated)",
"name": "tool",
"hasDynamicHelp": false,
"multiple": true,
"type": "option"
},
"verbose": {
"char": "v",
"description": "Show verbose output",
"name": "verbose",
"allowNo": false,
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [],
"id": "mcp:serve",
"pluginAlias": "@tpmjs/cli",
"pluginName": "@tpmjs/cli",
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"isESM": true,
"relativePath": ["dist", "commands", "mcp", "serve.js"]
},
"publish:check": { "publish:check": {
"aliases": [], "aliases": [],
"args": { "args": {

View file

@ -108,6 +108,7 @@ model Tool {
likes ToolLike[] likes ToolLike[]
ratings ToolRating[] ratings ToolRating[]
reviews ToolReview[] reviews ToolReview[]
skillsCache ToolSkillsCache?
@@unique([packageId, name]) @@unique([packageId, name])
@@index([qualityScore]) @@index([qualityScore])
@ -467,11 +468,12 @@ model Collection {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
// Relations // Relations
tools CollectionTool[] tools CollectionTool[]
agents AgentCollection[] agents AgentCollection[]
likes CollectionLike[] likes CollectionLike[]
bridgeTools CollectionBridgeTool[] bridgeTools CollectionBridgeTool[]
scenarios Scenario[] scenarios Scenario[]
skillsGenerationJobs SkillsGenerationJob[]
// Unique constraint: user can't have duplicate collection slugs // Unique constraint: user can't have duplicate collection slugs
@@unique([userId, slug]) @@unique([userId, slug])
@ -1418,3 +1420,53 @@ model SocialProof {
@@index([totalRuns]) @@index([totalRuns])
@@map("social_proofs") @@map("social_proofs")
} }
// ============================================================================
// Skills Generation Models (Chunked Generation for Large Collections)
// ============================================================================
/// ToolSkillsCache - per-tool cached skills markdown section
model ToolSkillsCache {
id String @id @default(cuid())
// Tool relationship
toolId String @unique @map("tool_id")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
// Cached markdown section for this tool
skillsMarkdown String @map("skills_markdown") @db.Text
// Timestamps
generatedAt DateTime @default(now()) @map("generated_at")
@@index([toolId])
@@map("tool_skills_cache")
}
/// SkillsGenerationJob - tracks chunked skills.md generation progress
model SkillsGenerationJob {
id String @id @default(cuid())
// Collection relationship
collectionId String @map("collection_id")
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
// Job status
status String @default("pending") @db.VarChar(20) // 'pending' | 'processing' | 'completed' | 'failed'
// Progress tracking
currentBatch Int @default(0) @map("current_batch")
totalBatches Int @map("total_batches")
completedToolIds String[] @default([]) @map("completed_tool_ids")
// Error tracking
error String? @db.Text
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([collectionId])
@@index([status])
@@map("skills_generation_jobs")
}

1718
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff