Custom Compositions
Write the composition that your own server or Lambda renderer uses to draw an RVE project.
A self-managed renderer (SSR or Lambda) does not use the editor code. It loads a composition that you bundle and deploy. This page shows how to write that composition with the SDK layer components.
Browser exports and RVE Cloud Rendering do not need a custom composition.
The example app apps/with-nextjs-lambda/remotion contains a full version of this code.
What the SDK sends
When the editor or useRender starts a cloud render, the HttpRenderer sends this body to {endpoint}/render:
{
id: string; // the composition id: the projectId of the editor
inputProps: {
tracks: Track[];
durationInFrames: number;
width: number; // the canvas width
height: number; // the canvas height
fps: number;
src: ''; // always an empty string
scale?: number; // only present when the export scale is not 1
background?: CanvasBackground;
backgroundColor?: string; // deprecated, use background
};
}- The
idis theprojectIdthat you give toReactVideoEditor, or the first argument ofuseRender. - The
scalecomes from the export resolution,defaultExportScale, or theexportScaleargument ofrender(). - All media URLs in
tracksandbackgroundare permanent URLs. If a file is still ablob:URL or an upload is not complete, the SDK stops the render with an error before it sends the request. - The SDK does not send the watermark setting or font data. Your server route can add other props, for example
fontInfosandbaseUrl.
Rules for the composition
- Set the composition id to the
idthat the SDK sends. If your route passesidto the renderer, the composition id must equal theprojectId. If your project IDs change, use one fixed composition id in your route. - Read the size, the duration, and the frame rate from
inputPropsincalculateMetadata. Do not use fixed values. - Multiply the width and the height by
inputProps.scaleincalculateMetadata. Then scale the content to fill the larger frame. - Render each item inside
LayerBox. Give the box thezIndexfromlayerZIndex(trackIndex). If you do not useLayerBox, items with keyframes do not move. - Use the
*LayerContentcomponents for the item content. - Draw the canvas background, including a background image.
File structure
remotion/
├─ index.ts // entry point
├─ root.tsx // registers the composition
├─ main.tsx // background and layers
├─ layer.tsx // one item in a LayerBox
└─ layer-content.tsx // the content for each item typeInstall the peer packages
npm install remotion @remotion/media @remotion/lottie@remotion/mediais necessary for video and sound items.@remotion/lottieis an optional peer dependency. If it is not installed, Lottie items do not appear in the output and the render does not show an error.
index.ts
import { registerRoot } from 'remotion';
import { Root } from './root';
registerRoot(Root);root.tsx
calculateMetadata sets the output size from inputProps. The frame size is rounded to even numbers because the H.264 encoder does not accept odd dimensions.
import React from 'react';
import { Composition, type CalculateMetadataFunction } from 'remotion';
import { Main, type MainProps } from './main';
// Must equal the `id` in the render request (the editor projectId),
// unless your route passes its own fixed composition id.
export const COMPOSITION_ID = 'my-project';
const toEven = (value: number) => Math.max(2, Math.round(value / 2) * 2);
const calculateMetadata: CalculateMetadataFunction<MainProps> = ({ props }) => {
const scale = props.scale ?? 1;
return {
durationInFrames: props.durationInFrames,
fps: props.fps,
width: toEven(props.width * scale),
height: toEven(props.height * scale),
};
};
const defaultProps: MainProps = {
tracks: [],
durationInFrames: 900,
fps: 30,
width: 1920,
height: 1080,
src: '',
};
export const Root: React.FC = () => (
<Composition
id={COMPOSITION_ID}
component={Main}
durationInFrames={defaultProps.durationInFrames}
fps={defaultProps.fps}
width={defaultProps.width}
height={defaultProps.height}
defaultProps={defaultProps}
calculateMetadata={calculateMetadata}
/>
);main.tsx
The items use canvas coordinates. The composition draws them at the canvas size, then scales the result to the output size.
import React, { useMemo } from 'react';
import { AbsoluteFill, Img, useVideoConfig } from 'remotion';
import type { GoogleFontInfo } from '@reactvideoeditor/react-video-editor/data/google-fonts';
import {
ItemType,
type CanvasBackground,
type Track,
} from '@reactvideoeditor/react-video-editor/types';
import { Layer } from './layer';
export type MainProps = {
tracks: Track[];
durationInFrames: number;
fps: number;
width: number;
height: number;
src: string;
scale?: number;
background?: CanvasBackground;
backgroundColor?: string;
baseUrl?: string;
fontInfos?: Record<string, GoogleFontInfo>;
};
export const Main: React.FC<MainProps> = ({
tracks,
width,
height,
background,
backgroundColor = 'white',
baseUrl,
fontInfos,
}) => {
const { width: outputWidth, height: outputHeight } = useVideoConfig();
const layers = useMemo(
() =>
tracks.flatMap((track, trackIndex) =>
track.items
// Zoom items have no content of their own.
.filter(item => item.type !== ItemType.ZOOM)
.map(item => ({ item, trackIndex })),
),
[tracks],
);
const image = background?.type === 'image' ? background : null;
const cssBackground = image
? 'transparent'
: background && background.type !== 'image'
? background.value
: backgroundColor;
return (
<AbsoluteFill style={{ background: cssBackground }}>
<div
style={{
position: 'absolute',
width,
height,
overflow: 'hidden',
transformOrigin: '0 0',
transform: `scale(${outputWidth / width}, ${outputHeight / height})`,
}}
>
{image && (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<Img
crossOrigin="anonymous"
src={image.src}
style={{
width: '100%',
height: '100%',
objectFit: image.fit,
objectPosition: image.position,
...(image.blur > 0 && {
filter: `blur(${image.blur}px)`,
transform: `scale(${1 + (image.blur * 3) / Math.min(width, height)})`,
}),
}}
/>
</AbsoluteFill>
)}
{layers.map(({ item, trackIndex }) => (
<Layer
key={item.id}
item={item}
trackIndex={trackIndex}
{...(baseUrl && { baseUrl })}
{...(fontInfos && { fontInfos })}
/>
))}
</div>
</AbsoluteFill>
);
};layer.tsx
LayerBox sets the position, the size, the rotation, and the keyframe values of the item on each frame. It gives the content item to its child function. Sound items have no box.
import React, { useMemo } from 'react';
import { Sequence } from 'remotion';
import type { GoogleFontInfo } from '@reactvideoeditor/react-video-editor/data/google-fonts';
import { LayerBox } from '@reactvideoeditor/react-video-editor/remotion/layer-box';
import { layerZIndex } from '@reactvideoeditor/react-video-editor/remotion/layer-z-index';
import { ItemType, type Item } from '@reactvideoeditor/react-video-editor/types';
import { LayerContent } from './layer-content';
export const Layer: React.FC<{
item: Item;
trackIndex: number;
baseUrl?: string;
fontInfos?: Record<string, GoogleFontInfo>;
}> = ({ item, trackIndex, baseUrl, fontInfos }) => {
const boxStyle = useMemo(() => ({ zIndex: layerZIndex(trackIndex) }), [trackIndex]);
if (item.type === ItemType.AUDIO) {
return (
<Sequence from={item.from} durationInFrames={item.durationInFrames}>
<LayerContent item={item} {...(baseUrl && { baseUrl })} />
</Sequence>
);
}
return (
<Sequence from={item.from} durationInFrames={item.durationInFrames} premountFor={30}>
<LayerBox item={item} style={boxStyle}>
{contentItem => (
<LayerContent
item={contentItem}
{...(baseUrl && { baseUrl })}
{...(fontInfos && { fontInfos })}
/>
)}
</LayerBox>
</Sequence>
);
};layer-content.tsx
import React, { type CSSProperties } from 'react';
import type { GoogleFontInfo } from '@reactvideoeditor/react-video-editor/data/google-fonts';
import {
ItemType,
type AudioItem,
type CaptionItem,
type ImageItem,
type Item,
type LottieItem,
type ShapeItem,
type TextItem,
type VideoItem,
} from '@reactvideoeditor/react-video-editor/types';
import { VideoLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/video-layer-content';
import { TextLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/text-layer-content';
import { ImageLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/image-layer-content';
import { CaptionLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/caption-layer-content';
import { SoundLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/sound-layer-content';
import { ShapeLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/shape-layer-content';
import { LottieLayerContent } from '@reactvideoeditor/react-video-editor/remotion/components/lottie-layer-content';
const fill: CSSProperties = { width: '100%', height: '100%' };
export const LayerContent: React.FC<{
item: Item;
baseUrl?: string;
fontInfos?: Record<string, GoogleFontInfo>;
}> = ({ item, baseUrl, fontInfos }) => {
switch (item.type) {
case ItemType.VIDEO:
return (
<div style={fill}>
<VideoLayerContent item={item as VideoItem} {...(baseUrl && { baseUrl })} />
</div>
);
case ItemType.TEXT:
return (
<div style={fill}>
<TextLayerContent item={item as TextItem} {...(fontInfos && { fontInfos })} />
</div>
);
case ItemType.IMAGE:
return (
<div style={fill}>
<ImageLayerContent item={item as ImageItem} {...(baseUrl && { baseUrl })} />
</div>
);
case ItemType.CAPTION:
return (
<div style={{ ...fill, position: 'relative', overflow: 'hidden', display: 'flex' }}>
<CaptionLayerContent item={item as CaptionItem} {...(fontInfos && { fontInfos })} />
</div>
);
case ItemType.SHAPE:
return (
<div style={fill}>
<ShapeLayerContent item={item as ShapeItem} />
</div>
);
case ItemType.LOTTIE:
return (
<div style={fill}>
<LottieLayerContent item={item as LottieItem} />
</div>
);
case ItemType.AUDIO:
return <SoundLayerContent item={item as AudioItem} {...(baseUrl && { baseUrl })} />;
default:
return null;
}
};The editor-only parts of the example app (SortedOutlines, useAlignmentGuides, and changeItem) are not necessary for a render.
Fonts
On a server, the text and caption components cannot fetch font data from the /api/fonts route of your app. If fontInfos is not present, the text uses the fallback font (Roboto).
Add fontInfos in your render route before you start the render:
import { GOOGLE_FONTS_DATABASE } from '@reactvideoeditor/react-video-editor/data/google-fonts';
import type { GoogleFontInfo } from '@reactvideoeditor/react-video-editor/data/google-fonts';
import { ItemType, type Track } from '@reactvideoeditor/react-video-editor/types';
export function collectFontInfos(tracks: Track[]) {
const fontInfos: Record<string, GoogleFontInfo> = {};
for (const item of tracks.flatMap(track => track.items)) {
const family =
item.type === ItemType.TEXT
? item.styles.fontFamily
: item.type === ItemType.CAPTION
? (item.styles?.fontFamily ?? 'Inter')
: undefined;
if (!family || fontInfos[family]) continue;
const info = GOOGLE_FONTS_DATABASE.find(font => font.fontFamily === family);
if (info) fontInfos[family] = info;
}
return fontInfos;
}Limits
- Zoom effects (scene zoom and cursor zoom) are not available from the SDK exports. A custom composition does not show them.
- The SDK does not send the watermark to a cloud renderer. If you need a watermark in server renders, draw it in your composition.
Upgrade from a version with stickers
The Stickers feature is removed. The ./remotion/components/sticker-layer-content export is removed too.
If your composition imports StickerLayerContent, do these steps:
- Delete the
StickerLayerContentimport. - Delete the
casefor sticker items. - Add the
ShapeLayerContentandLottieLayerContentcases from the code above.
Saved projects still open. The editor removes sticker items when it loads a project.