RVE LogoReact Video EditorDOCS
RVE SDK/Rendering/Rendering/Custom Compositions

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 id is the projectId that you give to ReactVideoEditor, or the first argument of useRender.
  • The scale comes from the export resolution, defaultExportScale, or the exportScale argument of render().
  • All media URLs in tracks and background are permanent URLs. If a file is still a blob: 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 fontInfos and baseUrl.

Rules for the composition

  1. Set the composition id to the id that the SDK sends. If your route passes id to the renderer, the composition id must equal the projectId. If your project IDs change, use one fixed composition id in your route.
  2. Read the size, the duration, and the frame rate from inputProps in calculateMetadata. Do not use fixed values.
  3. Multiply the width and the height by inputProps.scale in calculateMetadata. Then scale the content to fill the larger frame.
  4. Render each item inside LayerBox. Give the box the zIndex from layerZIndex(trackIndex). If you do not use LayerBox, items with keyframes do not move.
  5. Use the *LayerContent components for the item content.
  6. 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 type

Install the peer packages

npm install remotion @remotion/media @remotion/lottie
  • @remotion/media is necessary for video and sound items.
  • @remotion/lottie is 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

remotion/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.

remotion/root.tsx
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.

remotion/main.tsx
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.

remotion/layer.tsx
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

remotion/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:

  1. Delete the StickerLayerContent import.
  2. Delete the case for sticker items.
  3. Add the ShapeLayerContent and LottieLayerContent cases from the code above.

Saved projects still open. The editor removes sticker items when it loads a project.

Next steps