Frame Workflow Lab

Remotion Bulk Render From CSV: A Batch Script With Validation, Retries, and Resume

Published November 15, 2026 · 18 min read

Key takeaway. To render one Remotion video per CSV row, bundle once, check every row before rendering anything, and keep a manifest so a stopped job can pick up where it left off. For speed, start with one row at a time and --concurrency=6. On one 10-core Apple M4 with these 10 short title cards, that cut the batch from 30.9 s to 15.8 s compared with --concurrency=2. Rendering several rows at once was faster again in this run, but that comparison is confounded, so treat --jobs above 1 as something to test on your own machine.

Who this is for. You have a spreadsheet of titles, prices, or names, and you want one video per row. You also want a job you can stop and restart without rendering everything again. You should be comfortable running Node scripts from a terminal.

Quick start

Once the files from the sections below are in place, you need three commands:

npm install remotion@4.0.524 @remotion/bundler@4.0.524 @remotion/renderer@4.0.524 react react-dom
ffprobe -version   # must print a version; see "ffprobe" below
node scripts/render-batch.mjs data/videos.csv out --concurrency=6

Each row becomes out/<id>.mp4, and out/manifest.json records which rows finished. If the job stops partway, run the last command again with --resume added.

Safe default: --jobs=1 --concurrency=6. Only try --jobs=2 or --jobs=3 after you have timed that default on your own videos.

The problem

A plain loop over the rows breaks in three ways:

The loop is also slow at this script's default of --concurrency=2. One row at a time took 30.9 s for 10 short cards.

Setup from an empty folder

mkdir remotion-csv-batch && cd remotion-csv-batch
npm init -y
npm install remotion@4.0.524 @remotion/bundler@4.0.524 @remotion/renderer@4.0.524 react react-dom
mkdir -p data public scripts src

You need these packages:

Not tested: the experiment ran in an existing project, not in a fresh folder. I can't confirm whether a clean folder also needs a tsconfig.json or @types/react before src/*.ts(x) will bundle. The scripts are .mjs files, so Node runs them as ES modules. That is what makes the top-level await work.

ffprobe. The script checks every output file with ffprobe. On macOS, brew install ffmpeg installs it. Check that it's on your PATH with ffprobe -version. I didn't run the script without ffprobe. Reading the code, here is what would happen: each row renders, the frame check throws, the .part file is deleted, the row is retried, and then it is marked failed. The run exits with code 1, and all of that render time is wasted.

Project layout:

data/videos.csv
public/logo.png          # any PNG, or leave the image cell of the "logo" row blank
scripts/csv.mjs
scripts/csv.test.mjs     # optional unit tests
scripts/render-batch.mjs
src/index.ts
src/Root.tsx

Terms used below:

The CSV

Export your sheet as CSV. This is the test file, data/videos.csv:

id,title,subtitle,color,seconds,image
intro,Batch rendering with Remotion,One CSV row becomes one video,#1f3a8a,3,
pricing,"Pricing, explained",Commas inside quotes survive,#0f766e,4,
quote,"She said ""ship it""",Doubled quotes survive too,#7c2d12,3,
short,Shortest clip,Two seconds,#4c1d95,2,
long,Longest clip,Six seconds,#134e4a,6,
logo,With an image,Uses public/logo.png,#1e293b,3,logo.png
accent,Café déjà vu,Non-ASCII text,#9d174d,3,
numbers,Q3 revenue up 18%,Numbers and symbols,#365314,3,
half,Half-second lengths,2.5 seconds exactly,#713f12,2.5,
outro,Thanks for watching,Last row,#0c4a6e,3,

The columns work like this:

The composition

src/index.ts:

import {registerRoot} from 'remotion';
import {Root} from './Root';

registerRoot(Root);

src/Root.tsx: the props match the CSV columns, and calculateMetadata turns seconds into durationInFrames:

import React from 'react';
import {AbsoluteFill, CalculateMetadataFunction, Composition, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig} from 'remotion';

const FPS = 30;

export type CardProps = {
  title: string;
  subtitle: string;
  color: string; // #rrggbb
  seconds: number; // each row decides its own length
  image: string; // optional file in public/, '' for none
};

const Card: React.FC<CardProps> = ({title, subtitle, color, image}) => {
  const frame = useCurrentFrame();
  const {durationInFrames} = useVideoConfig();
  const opacity = interpolate(frame, [0, 12], [0, 1], {extrapolateRight: 'clamp'});
  const bar = interpolate(frame, [0, durationInFrames - 1], [0, 100], {extrapolateRight: 'clamp'});
  return (
    <AbsoluteFill style={{backgroundColor: color, color: '#fff', fontFamily: 'Helvetica, Arial, sans-serif', padding: 64, justifyContent: 'center'}}>
      {image ? <Img src={staticFile(image)} style={{width: 96, height: 96, borderRadius: 16, marginBottom: 24}} /> : null}
      <div style={{fontSize: 64, fontWeight: 700, lineHeight: 1.1, opacity}}>{title}</div>
      <div style={{fontSize: 30, marginTop: 16, opacity: opacity * 0.85}}>{subtitle}</div>
      <div style={{position: 'absolute', left: 0, bottom: 0, height: 10, width: `${bar}%`, background: 'rgba(255,255,255,.8)'}} />
    </AbsoluteFill>
  );
};

const calculateMetadata: CalculateMetadataFunction<CardProps> = ({props}) => ({
  durationInFrames: Math.round(props.seconds * FPS),
});

export const Root: React.FC = () => (
  <Composition
    id="Card"
    component={Card}
    width={1280}
    height={720}
    fps={FPS}
    durationInFrames={90}
    defaultProps={{title: 'Title', subtitle: 'Subtitle', color: '#1f3a8a', seconds: 3, image: ''}}
    calculateMetadata={calculateMetadata}
  />
);

To add a column, add it in three places: CardProps, the props object in validateRows, and the component.

Parsing and validating rows

scripts/csv.mjs uses no CSV library. It handles quoted fields, doubled quotes, commas and newlines inside quotes, CRLF line endings, and Excel's BOM. validateRows turns each valid row into {id, props}, and the render loop passes those props as inputProps. The MAX_TITLE comment points to the experiment's README, which isn't part of this post. All the README says about it is that 40 characters was chosen by eye.

// Small CSV reader (RFC 4180: quoted fields, doubled quotes, commas and newlines inside quotes) plus row validation.
export const parseCsv = (text) => {
  const rows = [];
  let row = [], field = '', quoted = false;
  const src = text.replace(/^/, ''); // Excel adds a BOM
  for (let i = 0; i < src.length; i++) {
    const ch = src[i];
    if (quoted) {
      if (ch === '"' && src[i + 1] === '"') { field += '"'; i++; }
      else if (ch === '"') quoted = false;
      else field += ch;
    } else if (ch === '"') quoted = true;
    else if (ch === ',') { row.push(field); field = ''; }
    else if (ch === '\n' || ch === '\r') {
      if (ch === '\r' && src[i + 1] === '\n') i++;
      row.push(field); field = '';
      if (row.some((f) => f !== '')) rows.push(row);
      row = [];
    } else field += ch;
  }
  if (quoted) throw new Error('CSV ends inside a quoted field');
  row.push(field);
  if (row.some((f) => f !== '')) rows.push(row);
  const [header, ...body] = rows;
  if (!header) return [];
  return body.map((cells, n) => Object.fromEntries(header.map((h, i) => [h.trim(), (cells[i] ?? '').trim()]).concat([['_line', n + 2]])));
};

const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
const COLOR = /^#[0-9a-fA-F]{6}$/;
export const MAX_TITLE = 40; // what fits on two lines at 64px in a 1280px frame; measured by eye, see README

/** Returns {jobs, problems}. A row with any problem is not rendered. */
export const validateRows = (rows) => {
  const jobs = [], problems = [], seen = new Set();
  for (const r of rows) {
    const errs = [];
    if (!ID.test(r.id || '')) errs.push('id must be lowercase letters, digits, dashes (it becomes the file name)');
    else if (seen.has(r.id)) errs.push(`duplicate id "${r.id}" would overwrite another video`);
    if (!r.title) errs.push('title is empty');
    else if (r.title.length > MAX_TITLE) errs.push(`title is ${r.title.length} characters, limit ${MAX_TITLE}`);
    if (!COLOR.test(r.color || '')) errs.push('color must look like #1f3a8a');
    const seconds = Number(r.seconds);
    if (!(seconds >= 1 && seconds <= 60)) errs.push('seconds must be a number from 1 to 60');
    if (errs.length) problems.push({line: r._line, id: r.id || '', errors: errs});
    else {
      seen.add(r.id);
      jobs.push({id: r.id, props: {title: r.title, subtitle: r.subtitle || '', color: r.color, seconds, image: r.image || ''}});
    }
  }
  return {jobs, problems};
};

Optional: scripts/csv.test.mjs, which you can run with node --test scripts/csv.test.mjs:

import assert from 'node:assert/strict';
import {test} from 'node:test';
import {parseCsv, validateRows} from './csv.mjs';

test('quoted commas, doubled quotes, CRLF and BOM', () => {
  const rows = parseCsv('id,title,subtitle\r\na,"Hello, world","She said ""hi"""\r\nb,Plain,\r\n');
  assert.equal(rows.length, 2);
  assert.equal(rows[0].title, 'Hello, world');
  assert.equal(rows[0].subtitle, 'She said "hi"');
  assert.equal(rows[1]._line, 3);
});
test('newline inside a quoted field stays in one row', () => {
  assert.equal(parseCsv('id,title\na,"two\nlines"\n')[0].title, 'two\nlines');
});
test('unterminated quote is an error, not silent data loss', () => {
  assert.throws(() => parseCsv('id,title\na,"oops\n'), /quoted field/);
});
test('validation rejects bad rows and keeps good ones', () => {
  const {jobs, problems} = validateRows(parseCsv([
    'id,title,subtitle,color,seconds,image',
    'ok-1,Fine,,#112233,3,',
    'ok-1,Same id,,#112233,3,',
    'Bad Id,Fine,,#112233,3,',
    'no-title,,,#112233,3,',
    'long,' + 'x'.repeat(41) + ',,#112233,3,',
    'color,Fine,,blue,3,',
    'secs,Fine,,#112233,abc,',
    'ok-2,Fine,,#112233,2.5,logo.png',
  ].join('\n')));
  assert.deepEqual(jobs.map((j) => j.id), ['ok-1', 'ok-2']);
  assert.equal(jobs[1].props.seconds, 2.5);
  assert.deepEqual(problems.map((p) => p.line), [3, 4, 5, 6, 7, 8]);
  assert.match(problems[0].errors[0], /duplicate id/);
});

A broken file, row by row

data/videos-bad.csv has 9 data rows, on file lines 2–10:

id,title,subtitle,color,seconds,image
good-1,This row is fine,,#1f3a8a,2,
good-1,Duplicate id,,#1f3a8a,2,
Bad Id,Spaces and capitals in the id,,#1f3a8a,2,
empty-title,,,#1f3a8a,2,
too-long,This title is far too long to fit on the card without overflowing,,#1f3a8a,2,
bad-color,Color is a word,,blue,2,
bad-seconds,Seconds is not a number,,#1f3a8a,soon,
missing-image,Image file does not exist,,#1f3a8a,2,nope.png
good-2,This row is fine too,,#0f766e,2,

Here is what happens to each line:

That makes 6 rejected, 2 rendered, and 1 failed. The exit code is 1 because a row failed. Rejected rows alone don't change the exit code.

Missing image files aren't caught up front. validateRows doesn't check whether public/<image> exists. The experiment used that gap on purpose to exercise the retry and failure path. With the default --retries=1, such a row goes through two full render attempts and is marked failed. The other rows still render, and the run exits 1 at the end. The time those failed attempts took wasn't measured. To catch this before rendering, you could add an existsSync(path.join('public', r.image)) check to validateRows. I did not add or test that change. The same applies to the logo row if you don't create public/logo.png.

The render script

scripts/render-batch.mjs, unabridged:

// Batch render: one CSV row -> one MP4.
//   node scripts/render-batch.mjs <csv> <outDir> [--jobs=1] [--concurrency=2] [--retries=1] [--resume] [--stop-after=N] [--keep-bundle]
// Bundles once, validates every row before rendering anything, keeps a manifest so a second run can
// skip finished videos, retries a failed row, and verifies each output with ffprobe.
import {bundle} from '@remotion/bundler';
import {renderMedia, selectComposition} from '@remotion/renderer';
import {execFileSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs';
import path from 'node:path';
import {parseCsv, validateRows} from './csv.mjs';

const [csvPath, outDir, ...flags] = process.argv.slice(2);
const flag = (name, fallback) => {
  const hit = flags.find((f) => f === `--${name}` || f.startsWith(`--${name}=`));
  return hit === undefined ? fallback : hit.includes('=') ? hit.split('=')[1] : true;
};
const JOBS = Number(flag('jobs', 1)), RETRIES = Number(flag('retries', 1)), RESUME = !!flag('resume', false);
const CONCURRENCY = Number(flag('concurrency', 2)); // frames rendered in parallel inside one video
const KEEP_BUNDLE = !!flag('keep-bundle', false); // test hook: leave the bundle folder to measure it
const STOP_AFTER = Number(flag('stop-after', 0)); // test hook: simulate a crash after N finished rows

const probeFrames = (file) => {
  const out = execFileSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-count_frames', '-show_entries', 'stream=nb_read_frames,width,height', '-of', 'json', file], {encoding: 'utf8'});
  const s = JSON.parse(out).streams[0];
  return {frames: Number(s.nb_read_frames), width: s.width, height: s.height};
};
const propsHash = (props) => createHash('sha256').update(JSON.stringify(props)).digest('hex').slice(0, 16);

mkdirSync(outDir, {recursive: true});
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = RESUME && existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {rows: {}};
const save = () => { writeFileSync(manifestPath + '.tmp', JSON.stringify(manifest, null, 2)); renameSync(manifestPath + '.tmp', manifestPath); };

const {jobs, problems} = validateRows(parseCsv(readFileSync(csvPath, 'utf8')));
for (const p of problems) console.log(`SKIP line ${p.line}${p.id ? ` (${p.id})` : ''}: ${p.errors.join('; ')}`);

const t0 = Date.now();
const bundleStart = Date.now();
const serveUrl = await bundle({entryPoint: path.resolve('src/index.ts')});
const bundleSeconds = (Date.now() - bundleStart) / 1000;

let finished = 0, skipped = 0;
const renderOne = async (job) => {
  const out = path.join(outDir, `${job.id}.mp4`);
  const hash = propsHash(job.props);
  const prev = manifest.rows[job.id];
  // Resume: skip only if the same props were rendered and the file on disk still checks out.
  if (RESUME && prev?.status === 'done' && prev.propsHash === hash && existsSync(out)) {
    try {
      if (probeFrames(out).frames === prev.frames) { skipped++; console.log(`KEEP ${job.id}`); return; }
    } catch { /* unreadable file: render again */ }
  }
  let lastError = '';
  for (let attempt = 1; attempt <= RETRIES + 1; attempt++) {
    const started = Date.now();
    const tmp = out + '.part.mp4'; // never leave a half-written file under the final name
    try {
      const composition = await selectComposition({serveUrl, id: 'Card', inputProps: job.props});
      await renderMedia({composition, serveUrl, codec: 'h264', outputLocation: tmp, inputProps: job.props, concurrency: CONCURRENCY, logLevel: 'error'});
      const got = probeFrames(tmp);
      if (got.frames !== composition.durationInFrames) throw new Error(`expected ${composition.durationInFrames} frames, file has ${got.frames}`);
      renameSync(tmp, out);
      manifest.rows[job.id] = {status: 'done', propsHash: hash, frames: got.frames, seconds: +((Date.now() - started) / 1000).toFixed(2), attempts: attempt};
      save();
      console.log(`DONE ${job.id} ${got.frames} frames in ${manifest.rows[job.id].seconds}s${attempt > 1 ? ` (attempt ${attempt})` : ''}`);
      finished++;
      if (STOP_AFTER && finished >= STOP_AFTER) { console.log(`simulated crash after ${finished} rows`); rmSync(serveUrl, {recursive: true, force: true}); process.exit(137); }
      return;
    } catch (err) {
      rmSync(tmp, {force: true});
      lastError = String(err?.message || err).split('\n')[0].slice(0, 200);
      console.log(`FAIL ${job.id} attempt ${attempt}: ${lastError}`);
    }
  }
  manifest.rows[job.id] = {status: 'failed', propsHash: hash, error: lastError, attempts: RETRIES + 1};
  save();
};

// A tiny worker pool: JOBS rows render at the same time.
const queue = [...jobs];
await Promise.all(Array.from({length: JOBS}, async () => { while (queue.length) await renderOne(queue.shift()); }));

const rows = Object.values(manifest.rows);
const summary = {
  csv: csvPath, jobs: JOBS, concurrency: CONCURRENCY, retries: RETRIES, resume: RESUME,
  rowsInCsv: jobs.length + problems.length, rejectedByValidation: problems.length,
  rendered: finished, keptFromLastRun: skipped, failed: jobs.filter((j) => manifest.rows[j.id]?.status === 'failed').length,
  bundleSeconds: +bundleSeconds.toFixed(1), totalSeconds: +((Date.now() - t0) / 1000).toFixed(1),
};
manifest.lastRun = summary;
save();
if (KEEP_BUNDLE) console.log('BUNDLE ' + serveUrl);
else rmSync(serveUrl, {recursive: true, force: true}); // bundle() leaves its output in the temp folder on every run
console.log('SUMMARY ' + JSON.stringify(summary));
process.exit(summary.failed ? 1 : 0);

A few details in this script:

What a run prints

This is the output format taken from the console.log calls. It is not a captured log. Values in <> vary from run to run, and the frame counts follow from the CSV (3 s × 30 fps = 90).

SKIP line 3 (good-1): duplicate id "good-1" would overwrite another video
DONE intro 90 frames in <seconds>s
FAIL missing-image attempt 1: <first line of the error>
KEEP intro
SUMMARY {"csv":"data/videos.csv","jobs":1,"concurrency":6,"retries":1,"resume":false,"rowsInCsv":10,"rejectedByValidation":0,"rendered":10,"keptFromLastRun":0,"failed":0,"bundleSeconds":<s>,"totalSeconds":<s>}

Each prefix means:

A clean run of data/videos.csv prints 10 DONE lines and a SUMMARY line with "failed":0, and it exits 0.

What resume does and does not notice

--resume skips a row only when all three of these hold:

**It does not notice changes to your composition code, to files in public/, to the codec or resolution, or to the Remotion version.** After any of those changes, run without --resume. Otherwise old videos are kept without any warning.

The frame check catches only files that are missing, unreadable, or have the wrong frame count. ffprobe reads width and height, but the script doesn't compare them. A file with the right frame count but damaged pictures would pass.

Results

Failure and resume

Columns: scenario is what was done, and result is what the script reported. All six matched the expected outcome.

scenarioresult
Bad CSV, first runexit 1, 6 rejected, 2 rendered, 1 failed after 2 attempts, 2 files
Add the missing image, --resumeexit 0, 2 kept, 1 rendered, 3 files
--stop-after=4, then --resumeFirst run: exit 137, 4 rendered, 0 .part files left. Resume: 4 kept, 6 rendered (10 files) in 19.9 s
One MP4 truncated to 2,000 bytes, --resumeexit 0, 9 kept, 1 rendered
One row's title edited, --resumeexit 0, 9 kept, 1 rendered
Nothing to do, --resumeexit 0, 10 kept, 0 rendered, 1.4 s

Notes on these results:

Parallel rows vs. concurrency

Columns: rows at once is --jobs, and concurrency is --concurrency per row. jobs × concurrency is their product. Run 1 and run 2 are wall-clock seconds for the whole process, including bundling. Average is the mean of the two runs.

rows at onceconcurrencyjobs × concurrencyrun 1run 2average
12230.8 s31.0 s30.9 s
22417.3 s18.0 s17.6 s
32612.6 s12.3 s12.4 s
16616.2 s15.4 s15.8 s

Test environment

Date2026-11-11
MachineApple M4, 10 cores, 16 GB, macOS (Darwin 25.6.0, arm64)
Node24.13.1
Remotion4.0.524
Videos1280x720, 30 fps, H.264, 10 rows of 2–6 s (32.5 s, 975 frames by the CSV's seconds column)

Limitations

Reproduce

Prerequisites:

node --test scripts/csv.test.mjs                              # optional: 4 unit tests
node scripts/render-batch.mjs data/videos.csv out --concurrency=6
node scripts/render-batch.mjs data/videos.csv out --concurrency=6 --resume
node scripts/render-batch.mjs data/videos-bad.csv out-bad     # failure path, exits 1

The experiment produced its tables with a runner script, scripts/experiment.mjs. That script is not included in this post and there is no public repo for it, so the commands above won't regenerate the results tables. You can repeat the speed comparison by hand. Delete the output folder before each run, then time each setting:

rm -rf out-speed && time node scripts/render-batch.mjs data/videos.csv out-speed --jobs=1 --concurrency=2
rm -rf out-speed && time node scripts/render-batch.mjs data/videos.csv out-speed --jobs=1 --concurrency=6
rm -rf out-speed && time node scripts/render-batch.mjs data/videos.csv out-speed --jobs=3 --concurrency=2

Run each setting several times, and alternate the order between settings. The SUMMARY line gives you bundleSeconds and totalSeconds, so you can separate bundling from rendering.

Sources

Last tested: November 11, 2026, Remotion 4.0.524.