HomingoLearn
Remotion · 10 min read

Remotion + Claude Code: render 1080x1920 reels and Shorts from a spec

Video as code: a spec file in, a checked vertical mp4 out, with Claude Code writing the React.

By Homingo Learn ·

Remotion renders video from React components: every frame is a function of the frame number. That makes it a natural fit for Claude Code, which writes React well and can re-render a change in seconds. The trick is to stop describing shots in chat and put them in a spec file that both you and Claude edit. This guide sets up a vertical project, wires it to a spec, and ends with a rendered, checked 1080x1920 mp4.

1. Create the Remotion project

You need Node.js 18 or newer. Remotion brings its own ffmpeg for rendering; install ffmpeg yourself only for the checks in step 8 (for the ffprobe ones, npx remotion ffprobe also works). Create a project (pick the Blank template, so its files do not clash with the code below) and open it in Claude Code:

npx create-video@latest my-reels   # choose "Blank"
cd my-reels
npm install
npx remotion studio   # live preview in the browser

2. The spec

One JSON file holds everything that changes between episodes: the voice-over file, the narration lines with their start times, and the shots with their times, art and camera move. Art and audio live in public/. (Our horror Shorts workflow adds story fields to the same file.)

{
  "voice": "voice/ep01.mp3",
  "lines": [
    { "t": 0.3, "text": "The voice in room 5 was mine." },
    { "t": 2.4, "text": "Same words. Same cough. One second behind me." }
  ],
  "shots": [
    { "id": "s01", "start": 0, "end": 2.4, "image": "art/s01.png", "camera": "slow push-in" },
    { "id": "s02", "start": 2.4, "end": 5.8, "image": "art/s02.png", "camera": "drift" }
  ]
}

episode/episode.json

3. One vertical composition, sized from the spec

Reels and Shorts are 1080x1920. Register one composition at that size and let the spec decide its length. (Importing JSON needs "resolveJsonModule": true in tsconfig.json.)

import { Composition } from 'remotion';
import { Episode } from './Episode';
import spec from '../episode/episode.json';

const FPS = 30;
const seconds = Math.max(1, ...spec.shots.map((s) => s.end)); // at least 1 s, even with no shots yet

export const RemotionRoot = () => (
  <Composition
    id="Episode"
    component={Episode}
    width={1080}
    height={1920}
    fps={FPS}
    durationInFrames={Math.round(seconds * FPS)}
    defaultProps={{ spec }}
  />
);

src/Root.tsx

4. Shots, captions and voice, all timed by the spec

Each shot and each narration line becomes a <Sequence>, so changing a time in the spec moves it, with no component edits. Every shot moves from its first frame (a push-in when the spec asks for one, a slow sideways drift otherwise), captions sit inside the safe band, and the voice-over plays under everything. The composition is as long as the last shot, so make sure the last shot’s end covers the whole narration:

import { AbsoluteFill, Audio, Img, Sequence, interpolate, staticFile, useCurrentFrame, useVideoConfig } from 'remotion';

type Shot = { id: string; start: number; end: number; image: string; camera: string };
type Line = { t: number; text: string };
type Spec = { voice?: string; lines: Line[]; shots: Shot[] };

const ShotView = ({ shot, frames }: { shot: Shot; frames: number }) => {
  const frame = useCurrentFrame(); // counts from 0 inside each Sequence
  const p = interpolate(frame, [0, frames], [0, 1], { extrapolateRight: 'clamp' });
  // every shot moves: a push-in zooms 8%; a drift slides 24 px sideways inside a 4% overscan
  const transform = shot.camera === 'slow push-in'
    ? 'scale(' + (1 + 0.08 * p) + ')'
    : 'scale(1.04) translateX(' + (12 - 24 * p) + 'px)';
  return (
    <AbsoluteFill style={{ transform }}>
      <Img src={staticFile(shot.image)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
    </AbsoluteFill>
  );
};

// Safe band: 25% to 65% from the top, 6% margin left, 12% right (the like/share column).
const Caption = ({ text }: { text: string }) => (
  <div style={{ position: 'absolute', top: '25%', bottom: '35%', left: '6%', right: '12%',
                display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
    <div style={{ color: 'white', fontSize: 64, fontWeight: 800, lineHeight: 1.15, textAlign: 'center',
                  textShadow: '0 4px 18px rgba(0,0,0,.85)' }}>{text}</div>
  </div>
);

export const Episode = ({ spec }: { spec: Spec }) => {
  const { fps, durationInFrames } = useVideoConfig();
  const at = (s: number) => Math.round(s * fps);
  return (
    <AbsoluteFill style={{ backgroundColor: '#05060a' }}>
      {spec.shots.map((shot) => {
        const frames = Math.max(1, at(shot.end) - at(shot.start)); // never a zero-length shot
        return (
          <Sequence key={shot.id} from={at(shot.start)} durationInFrames={frames}>
            <ShotView shot={shot} frames={frames} />
          </Sequence>
        );
      })}
      {spec.lines.map((line, i) => {
        const end = i + 1 < spec.lines.length ? at(spec.lines[i + 1].t) : durationInFrames;
        return (
          <Sequence key={i} from={at(line.t)} durationInFrames={Math.max(1, end - at(line.t))}>
            <Caption text={line.text} />
          </Sequence>
        );
      })}
      {spec.voice ? <Audio src={staticFile(spec.voice)} /> : null}
    </AbsoluteFill>
  );
};

src/Episode.tsx

Keep motion in transform and opacity, and give every shot some movement from its first second. A frame that does not change for more than two seconds reads as a frozen video on a phone, and fails the freeze check in step 8.

5. Let Claude Code edit the spec, then the code

The working loop that keeps projects tidy:

  1. You describe the change in story terms (“the reveal comes too early, hold the empty corridor for another second”).
  2. Claude edits episode/episode.json first and shows you the diff.
  3. Only if the spec cannot express it does Claude touch a component, and then in a way the spec can control next time (a new field, not a hard-coded value).
  4. Claude renders stills of the changed shots for you to look at before any full render.

Put this loop in a Claude Code skill and you never have to explain it again.

6. Review stills before you render

A still per shot takes seconds; a full render takes minutes. Render the middle frame of each shot: (start + end) / 2 × fps. For s01 (0 to 2.4 s) that is 1.2 s, frame 36; for s02 (2.4 to 5.8 s), frame 123. Look at them in order, or give only the image files to a separate subagent and ask it to tell the story back from the pictures alone:

npx remotion still src/index.ts Episode out/stills/s01.png --frame=36
npx remotion still src/index.ts Episode out/stills/s02.png --frame=123

7. Render the vertical video

npx remotion render src/index.ts Episode out/episode.mp4 --codec=h264 --crf=18
# on a small laptop, also limit parallel frames:
npx remotion render src/index.ts Episode out/episode.mp4 --codec=h264 --crf=18 --concurrency=2

The first still or render downloads a headless browser, so it is slower. Render one episode at a time on a laptop; parallel renders compete for memory.

8. Check the file before you post

Four commands catch most broken uploads:

# size, frame rate and duration: expect 1080, 1920, 30/1
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate \
  -show_entries format=duration -of default=nw=1 out/episode.mp4

# audio present? (empty output = no audio track)
ffprobe -v error -select_streams a -show_entries stream=codec_name -of csv=p=0 out/episode.mp4

# frozen stretches of 2 s or more
ffmpeg -hide_banner -i out/episode.mp4 -vf "freezedetect=n=-60dB:d=2" -map 0:v:0 -f null - 2>&1 | grep -E "freeze_|rror|No such file"

# black frames of 0.5 s or more
ffmpeg -hide_banner -i out/episode.mp4 -vf "blackdetect=d=0.5:pix_th=0.10" -map 0:v:0 -f null - 2>&1 | grep -E "black_|rror|No such file"
  • A freeze_start with no freeze_duration after it means the video stays frozen to the end: count it as a fail.
  • Low-key horror scenes on a dark background can be reported as black. Look at the reported timestamps before you change anything, or lower pix_th (for example to 0.05).
  • If an ffmpeg command prints an error instead of results, the file did not decode: that is a fail, not a pass.

Ask Claude Code to run them and read every line before it says the file is ready. The reel QA skill in our skills guide wraps these checks.

Where to go from here

This setup gives you the pipeline. What makes people stop scrolling is the story and the look: the hook on frame 0, the beats, the camera, light and grade. Our horror Shorts workflow covers the method, and the skill below packages all of it with a Remotion starter that renders out of the box.