R RenderComp
remotion calculateMetadata dynamic data-driven composition

Remotion calculateMetadata: Dynamic Duration, Size, and Async Data Fetching

Remotion calculateMetadata: Dynamic Duration, Size, and Async Data Fetching

When you start building video content with Remotion, the first composition you write probably has a hard-coded durationInFrames={180} and a fixed width={1920}. That is fine for a logo animation that never changes. But the moment you want to generate a slide deck where duration depends on the number of slides, or a music visualiser whose length matches a track, static composition configuration becomes a dead end.

calculateMetadata is Remotion’s answer to this problem. It is an async function you attach to a <Composition> that runs before rendering begins and can return a dynamic duration, different dimensions, a different frame rate, or modified props — all driven by data that might come from an API, a local file, or computed logic.

This guide covers the full calculateMetadata API, async data patterns, schema definition with defaultProps, runtime overrides, and two complete production examples: a slide deck generator and a playlist video.

Why Static Duration Is Limiting

Consider a presentation video generator. Each slide should be on screen for 5 seconds (150 frames at 30 fps). If the deck has 8 slides, the video should be 1,200 frames. If the deck has 15 slides, 2,250 frames.

Without calculateMetadata, your options are unappealing:

  1. Hard-code a “maximum” duration and leave the end empty.
  2. Manually update durationInFrames every time the slide count changes.
  3. Build a separate script that patches the composition config before rendering.

None of these scale. calculateMetadata solves the problem cleanly: the composition introspects its own data and reports the correct duration before a single frame renders.

The calculateMetadata Function Signature

import { Composition } from "remotion";

<Composition
  id="SlideDeck"
  component={SlideDeckVideo}
  width={1920}
  height={1080}
  fps={30}
  durationInFrames={1} // Placeholder — overridden by calculateMetadata
  defaultProps={{
    slides: [],
    framesPerSlide: 150,
  }}
  calculateMetadata={async ({ props, defaultProps, compositionId, abortSignal }) => {
    return {
      durationInFrames: props.slides.length * props.framesPerSlide,
      fps: 30,
      width: 1920,
      height: 1080,
      props,
    };
  }}
/>

The function receives an object with four fields:

  • props: The current props for this render — the result of merging defaultProps with any runtime overrides.
  • defaultProps: The static defaults defined on the <Composition>. Useful as a type reference.
  • compositionId: The string ID of the composition being rendered. Useful when sharing one calculateMetadata function across multiple compositions.
  • abortSignal: A standard Web AbortSignal that fires if the composition is no longer needed (e.g. the Studio was closed before fetching completed). Pass this to fetch() calls.

The function must return a partial composition config object. Only the fields you return override the static values — you do not need to return all four. Return just { durationInFrames } if that is all that changes.

defaultProps as the Schema Definition

defaultProps serves two purposes in Remotion: it provides the default data for Studio preview and rendering, and — when combined with TypeScript generics — it acts as the type definition for the props your component accepts.

interface SlideDeckProps {
  slides: Array<{
    title: string;
    body: string;
    imageUrl?: string;
  }>;
  framesPerSlide: number;
  accentColor: string;
}

<Composition<SlideDeckProps>
  id="SlideDeck"
  component={SlideDeckVideo}
  width={1920}
  height={1080}
  fps={30}
  durationInFrames={1}
  defaultProps={{
    slides: [
      { title: "Welcome", body: "Opening slide for preview" },
    ],
    framesPerSlide: 150,
    accentColor: "#3a86ff",
  }}
  calculateMetadata={async ({ props }) => ({
    durationInFrames: Math.max(1, props.slides.length) * props.framesPerSlide,
  })}
/>

The TypeScript generic <SlideDeckProps> on <Composition> ensures that defaultProps and the component prop are type-checked against the same interface. If defaultProps is missing a required field, TypeScript will surface the error at compilation time.

Async Data Fetching Inside calculateMetadata

The most powerful feature of calculateMetadata is that it is async. You can call any async operation — fetch(), file system reads, database queries — and use the result to compute the final composition config.

interface PlaylistProps {
  playlistId: string;
  tracks: Array<{ title: string; artist: string; durationSeconds: number }>;
}

<Composition<PlaylistProps>
  id="PlaylistVideo"
  component={PlaylistVisualiser}
  width={1920}
  height={1080}
  fps={30}
  durationInFrames={1}
  defaultProps={{
    playlistId: "preview",
    tracks: [
      { title: "Preview Track", artist: "Demo Artist", durationSeconds: 30 },
    ],
  }}
  calculateMetadata={async ({ props, abortSignal }) => {
    // Fetch real track data from API when playlistId is not "preview"
    let tracks = props.tracks;

    if (props.playlistId !== "preview") {
      const response = await fetch(
        `https://api.example.com/playlists/${props.playlistId}`,
        { signal: abortSignal }
      );
      const data = await response.json();
      tracks = data.tracks;
    }

    const totalSeconds = tracks.reduce((sum, t) => sum + t.durationSeconds, 0);
    const fps = 30;

    return {
      durationInFrames: Math.round(totalSeconds * fps),
      props: {
        ...props,
        tracks, // Pass resolved tracks back into props
      },
    };
  }}
/>

Important: The props you return from calculateMetadata replace the props passed to the component. This lets you enrich props during metadata calculation — here, we replace the stub tracks with real data fetched from an API.

Overriding Props at Render Time

When rendering programmatically (from the CLI or via renderMedia), you can pass input props that override defaultProps. These are the props values that calculateMetadata receives.

Via CLI:

npx remotion render SlideDeck --props='{"slides":[{"title":"Slide 1","body":"Content"},{"title":"Slide 2","body":"More content"}],"framesPerSlide":150,"accentColor":"#ff6b6b"}'

Via a JSON file:

npx remotion render SlideDeck --props=./render-data/deck-001.json

Via renderMedia in Node.js:

import { renderMedia, selectComposition } from "@remotion/renderer";

const composition = await selectComposition({
  serveUrl: bundleLocation,
  id: "SlideDeck",
  inputProps: {
    slides: mySlides,
    framesPerSlide: 120,
    accentColor: "#7eb8f7",
  },
});

await renderMedia({
  composition,
  serveUrl: bundleLocation,
  codec: "h264",
  outputLocation: "./output/deck.mp4",
});

The flow is: inputProps → passed to calculateMetadata as propscalculateMetadata returns final config (including possibly modified props) → durationInFrames and props used for rendering.

Full Example 1: Slide Deck Video Generator

// SlideDeck.tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";

interface Slide {
  title: string;
  body: string;
}

interface SlideDeckProps {
  slides: Slide[];
  framesPerSlide: number;
  accentColor: string;
}

export const SlideDeckVideo: React.FC<SlideDeckProps> = ({
  slides,
  framesPerSlide,
  accentColor,
}) => {
  const frame = useCurrentFrame();
  const { width, height } = useVideoConfig();

  const currentSlideIndex = Math.floor(frame / framesPerSlide);
  const frameWithinSlide = frame % framesPerSlide;

  const slide = slides[Math.min(currentSlideIndex, slides.length - 1)];

  // Transition animation: fade + slide on entry, fade out near end
  const entryProgress = spring({
    frame: frameWithinSlide,
    fps: 30,
    config: { damping: 20, stiffness: 100, mass: 1 },
  });

  const exitOpacity = interpolate(
    frameWithinSlide,
    [framesPerSlide - 20, framesPerSlide - 5],
    [1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const titleY = interpolate(entryProgress, [0, 1], [40, 0]);
  const bodyY = interpolate(entryProgress, [0, 1], [60, 0]);
  const entryOpacity = interpolate(entryProgress, [0, 1], [0, 1]);

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0a0a14",
        justifyContent: "center",
        alignItems: "flex-start",
        padding: `${height * 0.1}px ${width * 0.08}px`,
        opacity: exitOpacity,
      }}
    >
      {/* Accent line */}
      <div
        style={{
          width: width * 0.05,
          height: 5,
          backgroundColor: accentColor,
          marginBottom: height * 0.04,
          opacity: entryOpacity,
        }}
      />

      {/* Slide number */}
      <div
        style={{
          fontFamily: `-apple-system, "Segoe UI", Roboto, sans-serif`,
          fontSize: height * 0.022,
          color: accentColor,
          letterSpacing: "0.15em",
          textTransform: "uppercase",
          marginBottom: height * 0.025,
          opacity: entryOpacity,
        }}
      >
        {currentSlideIndex + 1} / {slides.length}
      </div>

      {/* Title */}
      <h1
        style={{
          fontFamily: `-apple-system, "Segoe UI", Roboto, sans-serif`,
          fontSize: height * 0.075,
          fontWeight: 700,
          color: "#ffffff",
          margin: 0,
          marginBottom: height * 0.04,
          transform: `translateY(${titleY}px)`,
          opacity: entryOpacity,
          maxWidth: width * 0.7,
          lineHeight: 1.15,
        }}
      >
        {slide.title}
      </h1>

      {/* Body */}
      <p
        style={{
          fontFamily: `-apple-system, "Segoe UI", Roboto, sans-serif`,
          fontSize: height * 0.032,
          color: "#aaaacc",
          margin: 0,
          transform: `translateY(${bodyY}px)`,
          opacity: entryOpacity,
          maxWidth: width * 0.65,
          lineHeight: 1.7,
        }}
      >
        {slide.body}
      </p>
    </AbsoluteFill>
  );
};

And the composition registration in Root.tsx:

// Root.tsx
import { Composition } from "remotion";
import { SlideDeckVideo } from "./SlideDeck";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="SlideDeck"
    component={SlideDeckVideo}
    width={1920}
    height={1080}
    fps={30}
    durationInFrames={1}
    defaultProps={{
      slides: [
        { title: "Welcome", body: "This is the opening slide." },
        { title: "Our Approach", body: "We focus on data-driven solutions." },
        { title: "Results", body: "Outcomes that exceed expectations." },
      ],
      framesPerSlide: 150,
      accentColor: "#3a86ff",
    }}
    calculateMetadata={async ({ props }) => ({
      durationInFrames: props.slides.length * props.framesPerSlide,
    })}
  />
);

Render with a custom slide set:

npx remotion render SlideDeck --props='{"slides":[{"title":"Q1 Review","body":"Strong growth across all verticals."},{"title":"Q2 Targets","body":"Expanding into three new markets."},{"title":"Thank You","body":"Questions welcome."}],"framesPerSlide":180,"accentColor":"#f4a261"}'

The output is 540 frames (3 slides × 180 frames) — exactly right, with no manual duration calculation.

Full Example 2: Playlist Video (Dynamic from Sum of Track Durations)

// Root.tsx — playlist composition
import { Composition } from "remotion";
import { PlaylistVisualiser } from "./PlaylistVisualiser";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="PlaylistVideo"
    component={PlaylistVisualiser}
    width={1920}
    height={1080}
    fps={30}
    durationInFrames={1}
    defaultProps={{
      playlistId: "preview",
      tracks: [
        { title: "Track One", artist: "Artist A", durationSeconds: 45 },
        { title: "Track Two", artist: "Artist B", durationSeconds: 38 },
      ],
    }}
    calculateMetadata={async ({ props, abortSignal }) => {
      let resolvedTracks = props.tracks;

      if (props.playlistId !== "preview") {
        const res = await fetch(
          `https://api.example.com/playlists/${props.playlistId}`,
          { signal: abortSignal }
        );
        const json = await res.json();
        resolvedTracks = json.tracks;
      }

      const totalFrames = resolvedTracks.reduce(
        (sum: number, t: { durationSeconds: number }) =>
          sum + Math.round(t.durationSeconds * 30),
        0
      );

      return {
        durationInFrames: Math.max(1, totalFrames),
        props: { ...props, tracks: resolvedTracks },
      };
    }}
  />
);

Dynamic Dimensions: Matching Output to Asset Aspect Ratio

You can also return different width and height from calculateMetadata. This is useful when rendering thumbnails or graphics whose dimensions must match an uploaded image.

calculateMetadata={async ({ props, abortSignal }) => {
  const response = await fetch(props.imageUrl, { signal: abortSignal });
  const blob = await response.blob();
  const bitmap = await createImageBitmap(blob);

  return {
    width: bitmap.width,
    height: bitmap.height,
    durationInFrames: 90,
  };
}}

Handling Errors and Fallbacks

If calculateMetadata throws, Remotion surfaces the error in the Studio and halts the render. Build in graceful fallbacks for network failures:

calculateMetadata={async ({ props, abortSignal }) => {
  try {
    const res = await fetch(dataUrl, { signal: abortSignal });
    const data = await res.json();
    return {
      durationInFrames: data.items.length * 90,
      props: { ...props, items: data.items },
    };
  } catch (e) {
    console.warn("calculateMetadata fetch failed, using defaults:", e);
    return {
      durationInFrames: props.items.length * 90,
    };
  }
}}

Ready-Made Dynamic Composition Templates from RenderComp

calculateMetadata is a powerful API, but wiring it up correctly — handling abort signals, error boundaries, type safety across the async boundary, and CLI override workflows — takes time to get right. RenderComp at rendercomp.com offers Remotion templates with production-grade calculateMetadata integrations pre-built: slide deck generators, data-bound chart videos, and podcast episode templates that automatically match audio duration. Browse the library to start from a working foundation rather than building the plumbing from scratch.


Frequently Asked Questions

Q1: Is it safe to make network requests inside calculateMetadata? Yes, with two caveats. First, always pass the abortSignal to fetch() so the request is cancelled if the composition is no longer needed. Second, implement error handling so a failed fetch does not block rendering — fall back to defaultProps values if the request fails.

Q2: Does calculateMetadata run on every frame or just once? It runs once per render (or once per composition load in the Studio). The result is cached for the duration of the render job. Remotion does not call it per-frame.

Q3: Can I change the fps dynamically with calculateMetadata? Yes. Return { fps: 25 } from calculateMetadata to override the static fps on the <Composition>. This is useful for broadcast outputs that require 25 fps for PAL markets while keeping the Studio preview at 30 fps.

Q4: What happens to the durationInFrames I set on the Composition element if calculateMetadata also returns one? calculateMetadata’s return value takes precedence. The static durationInFrames on <Composition> acts as a placeholder shown in the Studio before calculateMetadata has resolved. A common convention is to set durationInFrames={1} as a minimal placeholder.

Q5: Can I use calculateMetadata to validate props and throw a friendly error? Yes. Throw an Error inside calculateMetadata and Remotion will display the message in the Studio overlay and halt CLI renders with a non-zero exit code. This is a good place to validate required environment variables or check that a data URL is reachable before committing to a render.

Q6: Is calculateMetadata supported in Remotion Lambda? Yes. Remotion Lambda respects calculateMetadata exactly as the local renderer does. The function runs on the Lambda function before rendering begins. Ensure any URLs you fetch from are accessible from the Lambda VPC/region.

Q7: How do I debug calculateMetadata in the Studio? Add console.log statements — they appear in the browser console (DevTools) when the Studio evaluates the metadata. You can also use the Studio’s “Composition” tab to see the resolved durationInFrames after calculateMetadata has run, confirming the function returned the expected value.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →