R RenderComp
remotion ai-agents debugging typescript video-generation

Remotion + AI Agents: A Failure-Mode Catalog

By RenderComp Team Editorial policy

Remotion’s central premise is that video is a pure function: given a frame number, your component always renders the same pixels. The renderer exploits this guarantee by jumping to arbitrary frames during preview scrubbing, distributing work across parallel render workers, and caching individual frame outputs. Every assumption the runtime makes flows from this determinism contract.

AI agents break this contract in consistent, predictable ways, not because they misunderstand React. They handle JSX, hooks, and TypeScript just fine. The failures happen because Remotion’s programming model has several sharp edges that look like ordinary React code until a specific frame is requested in a specific render context, and the output is wrong or the render hangs indefinitely.

What follows is a catalog of the six failure modes that surface most often in AI-generated Remotion compositions, each with a minimal reproduction and a mechanical fix. These patterns are independent; real agents commonly trigger several simultaneously.


Failure Mode 1: Math.random() Breaks Frame Determinism

The most common single-line bug. An agent generates a particle system, a randomized text scramble, or a chart with jittered label positions, and reaches for Math.random():

// BROKEN — Math.random() returns a different value on every render visit.
// During multi-threaded rendering, worker A and worker B see different positions
// for the same particle on the same frame.
function Particle({ index }: { index: number }) {
  const frame = useCurrentFrame();
  const x = Math.random() * 1920; // non-deterministic
  const y = Math.random() * 1080;
  return <circle cx={x} cy={y} r={4} />;
}

When Remotion seeks to frame 47 for a preview thumbnail, Math.random() returns one value. When it renders frame 47 to PNG for the final encode, it returns a different value. The fix is Remotion’s random() utility, which produces a deterministic float in [0, 1) from a string seed:

import { random, useCurrentFrame } from 'remotion';

function Particle({ index }: { index: number }) {
  const frame = useCurrentFrame();

  // Stable layout: seed includes only the index, not the frame.
  // Same seed → same value on every visit to this frame, in any worker.
  const x = random(`particle-x-${index}`) * 1920;
  const y = random(`particle-y-${index}`) * 1080;

  // Per-frame animated jitter: incorporate frame into the seed.
  const jitter = random(`jitter-${index}-${frame}`) * 6 - 3;

  return <circle cx={x + jitter} cy={y} r={4} />;
}

The key design decision is whether to include frame in the seed. Omit it for stable layout properties (position, color assignment) and include it for animated variation. Never use Math.random() anywhere in a Remotion component tree.


Unclamped interpolate Extrapolation

Agents write interpolate calls that look correct: input range [0, 30], output range [0, 1] for a fade-in over 30 frames. The problem is that interpolate’s default behavior outside the input range is to extend (extrapolate linearly), not clamp:

// BROKEN — at frame 31, opacity evaluates to 1.033.
// Inside a Sequence that's offset by -1 global frame, opacity = -0.033.
// On scale or translateX, these out-of-range values produce visible artifacts.
const opacity = interpolate(frame, [0, 30], [0, 1]);

A value of 1.033 on opacity is harmless in CSS, but the identical pattern on a scale transform, a color channel, or a value passed to an SVG filter attribute causes visible rendering errors. Agents rarely add the fourth argument because the docs frequently show the terse two-argument form, and TypeScript allows it since the options parameter is optional.

import { interpolate, useCurrentFrame } from 'remotion';

const frame = useCurrentFrame();

// Rule: always specify extrapolation when the output has hard bounds.
const opacity = interpolate(frame, [0, 30], [0, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

// For a scale that should ease up slightly then settle, use multiple keyframes
// rather than relying on extrapolation. This is safer in generated code
// because the bounds are explicit, not inferred from linear projection.
const scale = interpolate(frame, [0, 15, 25], [0.85, 1.04, 1.0], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

The multi-keyframe form (three or more values per array) gives you controlled overshoot without relying on extrapolation math, and makes the intent readable to anyone auditing the generated code.


Spring Configs That Never Settle

Agents reach for spring() freely, since it produces smooth, physics-based animations with a single config object. What they rarely compute is the settling time. A spring with low damping and low stiffness can oscillate for hundreds of frames past the end of a 3-second composition:

// BROKEN — this spring has a settling time of ~180 frames at 30 fps.
// In a 90-frame (3 second) composition, it is still bouncing at fadeout.
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
const progress = spring({
  frame,
  fps,
  config: { mass: 2, stiffness: 20, damping: 5 },
});

Remotion exports measureSpring for exactly this diagnostic. Run it outside of a component (in a setup script or test) to get the frame count at which the spring reaches within a threshold of its target:

import { measureSpring } from 'remotion';

const frames = measureSpring({
  fps: 30,
  config: { mass: 2, stiffness: 20, damping: 5 },
  threshold: 0.005, // within 0.5% of target value
});
// → 183 frames. A 90-frame composition cannot contain this animation.

Two fixes are available, depending on intent. If the visual goal is a snappy UI-style entrance that finishes in exactly N frames, use the durationInFrames parameter, which remaps the spring output to complete within that window regardless of the physics config:

import { spring, useCurrentFrame, useVideoConfig } from 'remotion';

const { fps } = useVideoConfig();
const frame = useCurrentFrame();

// The config governs the shape of the easing curve (how it overshoots and
// decelerates), while durationInFrames bounds when it reaches 1.0.
// stiffness: 200, damping: 26 is a good starting point for a snappy feel.
const progress = spring({
  frame,
  fps,
  config: { stiffness: 200, damping: 26 },
  durationInFrames: 20, // animation complete by frame 20
});

If you want physically realistic bounce, keep the underdamped config and ensure the containing <Sequence> is long enough to hold the full animation. Use measureSpring to calculate the required duration rather than guessing.


The Sequence Local Frame Model

Inside a <Sequence from={N}>, useCurrentFrame() returns a frame number relative to the sequence start, beginning at 0 when the global composition frame is N. Agents trained on documentation that describes useCurrentFrame() as “the current frame” often assume this means the global composition frame and manually subtract the offset:

// BROKEN — the agent subtracts 60 because it thinks frame is global here.
// Inside <Sequence from={60}>, frame is already local (it is 0 at global frame 60).
// At composition frame 60: frame = 0, frame - 60 = -60.
// interpolate(-60, [0, 20], [0, 1]) with clamping = 0. Without clamping = -3.0.
function TextReveal() {
  const frame = useCurrentFrame();
  const opacity = interpolate(
    frame - 60, // BUG: double-offset
    [0, 20],
    [0, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );
  return <div style={{ opacity }}>Hello</div>;
}

// Used as: <Sequence from={60} durationInFrames={90}><TextReveal /></Sequence>

The fix is to remove the subtraction entirely. Sequence handles the global-to-local offset automatically; component logic should never replicate it:

function TextReveal() {
  // frame = 0 when the Sequence starts, regardless of where it sits on the timeline.
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  return <div style={{ opacity }}>Hello</div>;
}

This applies equally to spring(); pass the local frame directly. The same mental-model error appears in a second form: agents that want to control timing at the component level hard-code from props inside the component’s JSX rather than accepting them as parameters, which scatters timeline logic across the component tree. Keep all from and durationInFrames values at the Composition or root layout level; components should be timeline-agnostic.


Async Asset Loading Without delayRender

Remotion’s renderer is not a browser. There is no network or I/O layer running between frame renders. If a component needs an asset (a JSON dataset, a WASM binary, a font loaded from disk), it must explicitly pause the render until that asset is ready using delayRender and continueRender. Agents almost universally generate the React idiom instead:

// BROKEN — useEffect does not execute during Remotion's headless rendering.
// The component renders with data = null on every frame. The output is empty.
function DataChart() {
  const [data, setData] = useState<number[] | null>(null);

  useEffect(() => {
    fetch('/data.json').then(r => r.json()).then(setData);
  }, []);

  if (!data) return null; // this branch is taken on every rendered frame
  return <Chart values={data} />;
}

The correct pattern uses Remotion’s render lifecycle protocol. delayRender returns an opaque handle; the renderer blocks the current frame until every outstanding handle has been passed to continueRender:

import { continueRender, delayRender, staticFile } from 'remotion';
import { useEffect, useState } from 'react';

function DataChart() {
  const [data, setData] = useState<number[] | null>(null);
  // Initialize the handle in useState so it's created once and stable.
  const [handle] = useState(() => delayRender('Loading chart data'));

  useEffect(() => {
    // staticFile() resolves a filename to the correct URL in both
    // development (localhost bundle) and headless render (filesystem) contexts.
    fetch(staticFile('data.json'))
      .then(r => r.json())
      .then(d => {
        setData(d);
        continueRender(handle);
      })
      .catch(() => {
        // Always call continueRender on error.
        // A handle that is never continued causes the render to hang for
        // the full timeout (default: 30 seconds per frame) before failing.
        continueRender(handle);
      });
  }, [handle]);

  if (!data) return null;
  return <Chart values={data} />;
}

The string label passed to delayRender('Loading chart data') appears in progress output and timeout error messages, which is the primary debugging surface when a render hangs. If multiple components call delayRender, all handles must be continued independently before the frame renders.


Conditional Hook Calls

React’s hook dispatcher tracks call order across renders and throws if a component calls a different number of hooks between renders. Agents generating conditional rendering logic occasionally place hook calls after an early return:

// BROKEN — hooks are called after an early return.
// When show changes from true to false, React sees fewer hooks than the
// previous render and throws: "Rendered fewer hooks than during the previous render."
function ConditionalScene({ show }: { show: boolean }) {
  if (!show) return null; // early return before any hooks

  const frame = useCurrentFrame();    // only reached when show = true
  const { fps } = useVideoConfig();
  // ...
}

During development with the Remotion Studio, this throws immediately and is easy to diagnose. In headless rendering with --concurrency greater than 1, the error surface depends on which worker picks up the frame, and may produce corrupted output or a silent empty frame rather than a clear stack trace.

Hoist all hook calls unconditionally above any branch:

function ConditionalScene({ show }: { show: boolean }) {
  // All hooks called unconditionally, in stable order.
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const opacity = interpolate(frame, [0, 15], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // Condition checked after hooks — valid by React's rules.
  if (!show) return null;
  return <AbsoluteFill style={{ opacity }}>...</AbsoluteFill>;
}

For cases where an entire subtree should not exist on the timeline at all (rather than being conditionally invisible), the correct tool is a <Sequence> with a durationInFrames that ends before the “off” period begins, not a conditional return inside the component.


Wrapping Up

The six failure modes above share a common root: AI agents write Remotion code by analogy with React DOM rendering, but Remotion’s renderer is a frame-seeking, deterministic engine with a synchronous frame contract that has no equivalent in a browser. The mental model gap surfaces consistently across these areas:

  • Use random() from remotion, seeded by a string. Never call Math.random() inside a component.
  • Always pass extrapolateLeft: 'clamp' and extrapolateRight: 'clamp' to interpolate when the output has a hard floor or ceiling.
  • Run measureSpring to audit settling time before a composition ships; use durationInFrames to bound animations that must complete within a known window.
  • useCurrentFrame() inside a <Sequence> is already local to that sequence. Never subtract the from offset manually.
  • delayRender and continueRender are the only protocol the render worker respects. useEffect with fetch is invisible to it.
  • Hoist all useCurrentFrame, useVideoConfig, and other hook calls above any conditional returns.

If you are evaluating AI-generated video code at scale (for example, automatically populating templates like the ones in the RenderComp catalog), a static analysis pass for these six patterns catches the majority of failures before a single frame is rendered. Five of the six are mechanical enough to encode in a TypeScript ESLint plugin or a pre-render validation script; the spring settling-time check requires a runtime call to measureSpring but can be integrated into a CI step that runs against every generated composition config before handing off to the render worker.

Now available

Get 1,000+ Remotion Templates

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

View pricing →