Remotion Bulk Render From CSV: A Batch Script With Validation, Retries, and Resume
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:
- A bad row, such as an empty title or a typo in a color, shows up only when its render fails.
- A crash halfway through means starting over from the first row.
- A half-written MP4 looks the same on disk as a finished one.
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:
remotion,@remotion/bundler, and@remotion/renderer. Keep all three on the same version (4.0.524 here).reactandreact-dom. The experiment didn't record their versions, so the command lets npm choose them.
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:
- Composition: a React component registered with
<Composition id="Card">. Each CSV row becomes its props (inputProps). - **
--concurrency:** passed torenderMedia(). The script's comment describes it as the number of frames rendered in parallel inside one video. - **
--jobs:** how many rows (videos) render at the same time.jobs × concurrencyis the number of frames rendering in parallel overall. - Manifest:
out/manifest.json, which records each row's status, frame count, and props hash. - Props hash: a SHA-256 hash of the row's props. If you edit a row, the hash changes, and
--resumerenders that row again.
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:
idbecomes the file name.secondssets the video's length.imageis an optional file inpublic/.
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:
- **Line 2 (
good-1)** renders. - Line 3 reuses
good-1. The first row with that id is kept, and this one is rejected. - Lines 4–8 each break one rule and are rejected.
- **Line 9 (
missing-image)** passes validation and then fails at render time. - Line 10 renders.
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:
save()writes the manifest to a.tmpfile and then renames it, so the manifest is never left half-written.--stop-after=Nis a test hook. It exits with code 137 right after the Nth row finishes.bundle()leaves its output in the system temp folder. With cleanup turned off (--keep-bundle), that folder was 25.8 MB after one run, so the script deletes it at the end. A run that crashes before that line leaves the folder behind.
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:
SKIP: a row rejected by validation.DONE: a row that rendered and passed the frame check.FAIL: one failed attempt.KEEP: a row that--resumeleft alone.
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:
- The manifest says the row is
done. - The props hash matches.
- ffprobe counts the same number of frames as last time.
**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.
| scenario | result |
|---|---|
| Bad CSV, first run | exit 1, 6 rejected, 2 rendered, 1 failed after 2 attempts, 2 files |
Add the missing image, --resume | exit 0, 2 kept, 1 rendered, 3 files |
--stop-after=4, then --resume | First 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, --resume | exit 0, 9 kept, 1 rendered |
One row's title edited, --resume | exit 0, 9 kept, 1 rendered |
Nothing to do, --resume | exit 0, 10 kept, 0 rendered, 1.4 s |
Notes on these results:
- The simulated crash happens after a row is renamed into place, so finding no
.partfiles is expected. A kill in the middle of a render was not tested. - The retry didn't help, because the only failing row had a permanent error. Transient failures were not reproduced.
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 once | concurrency | jobs × concurrency | run 1 | run 2 | average |
|---|---|---|---|---|---|
| 1 | 2 | 2 | 30.8 s | 31.0 s | 30.9 s |
| 2 | 2 | 4 | 17.3 s | 18.0 s | 17.6 s |
| 3 | 2 | 6 | 12.6 s | 12.3 s | 12.4 s |
| 1 | 6 | 6 | 16.2 s | 15.4 s | 15.8 s |
- Concurrency, one row at a time: 30.9 s → 15.8 s, about 2x. Both settings render rows one by one, so their per-row overhead is the same.
- Parallel rows at the same jobs × concurrency: 15.8 s → 12.4 s. This comparison is confounded. Three rows at once also overlap per-row work (
selectComposition, render startup, the ffprobe check). The first three settings ran in a fixed order (1, 1, 2, 2, 3, 3), and the 1 × 6 baseline was recorded in a separate session. Thermal state or disk cache could explain part of the gap. The cause is not confirmed. - Bundle time is not broken out. The
SUMMARYline includesbundleSeconds, but those values weren't part of the published results, so I can't report render-only times. The no-op resume, which still bundles, took 1.4 s in total. That suggests bundling was small, but it wasn't checked for the speed runs. - Memory: only the Node process was measured, not the browser processes, so no memory figure is reported.
Test environment
| Date | 2026-11-11 |
| Machine | Apple M4, 10 cores, 16 GB, macOS (Darwin 25.6.0, arm64) |
| Node | 24.13.1 |
| Remotion | 4.0.524 |
| Videos | 1280x720, 30 fps, H.264, 10 rows of 2–6 s (32.5 s, 975 frames by the CSV's seconds column) |
Limitations
- Resume ignores changes to code, assets, codec, and Remotion version.
- The frame check catches only wrong frame counts and unreadable files.
- Missing image files are found at render time, not during validation.
- The speed results come from short, simple title cards on one 10-core machine, with two runs per setting. Heavier compositions, videos with media, or machines with less memory may scale differently. More than 3 parallel rows was not tried.
- Bundle time, per-row overhead, and browser memory were not measured separately.
- A kill during a render, transient errors, and running without ffprobe were not tested.
- The 40-character title limit was chosen by eye.
- macOS only. No Lambda, no Docker, and no clean-install test.
Reproduce
Prerequisites:
- Node (tested on 24.13.1)
- The packages from Setup
ffprobeon your PATHpublic/logo.png, or a blank image cell in thelogorow
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
- Remotion docs, "Render videos programmatically from a dataset": https://www.remotion.dev/docs/dataset-render
Last tested: November 11, 2026, Remotion 4.0.524.