---
title: Turn a CSV into many videos with a script
description: Turn every row of a CSV into its own video with a bash loop over the invideo CLI: change the text from the row, export, and download, safe to rerun.
---
Turn a spreadsheet into videos: one video per CSV row, personalized videos at volume, a batch render from a template. A bash loop reads each row, changes the template's text with the CLI, exports the video, and saves the file. The loop is safe to stop and rerun. It skips rows that are done, and a retried export never renders twice.

Use invideo for this when a person designs the template in the editor and a script fills it in, and every video can still be opened and fixed by hand.

## Quickest path

Set up the CLI first with [invideo for AI agents](/agents). Then give your coding agent this prompt.

```text
Write a bash script that makes one invideo video per row of rows.csv from
the template edit <edit-url>. Find the text clip ids with invideo edit
show. Set the text from each row, export at 1080p, download to ./out, and
skip rows that are done. Dry-run the first row. Run a few rows, show me,
and wait for my yes.
```

This page is for a terminal. In a chat app, your agent can do a few rows with `duplicate_timeline`, `set_text` and `export`, but a script is the way for many rows.

## What you need

The template is the work. The script is small.

- A template edit in invideo, designed by a person: footage, music, logo, and text clips with placeholder words.
- A CSV with a header row and one row per video. The script below expects no commas inside fields.
- The CLI, signed in. See [Run invideo in CI and on servers](/how-to/ci-and-servers) for a machine with no browser.
- `jq`, to build the operations and read the state token.

## Steps

The agent sets up once, tests one row, then runs the loop.

<Steps>
<Step title="Register a name">
The agent runs `invideo agent register <project-url> --name csv-batch` and sets `INVIDEO_AS=csv-batch` for the script. It sees the name in the project.
</Step>
<Step title="Branch the template">
The agent reads the template's `state_token` with `invideo edit show <edit-url> --json`, then runs `invideo branch new <edit-url> --name csv-batch --token <state_token>`. It sees the branch's URL in `.branch_url`. The `url` key still names main, so the script uses `.branch_url`.
</Step>
<Step title="Find the text clip ids">
The agent runs `invideo edit show <branch-url> --json` and notes the text clip ids, like `t1` and `t2`. It sees the ids and the branch's own `state_token`.
</Step>
<Step title="Dry-run one row">
The agent builds the `set_text` ops for row 1 and runs `invideo edit change` with `--dry-run`. Nothing is applied. It sees the ops checked.
</Step>
<Step title="Run a few rows">
The agent runs the script on a small part of the CSV. Each row changes the text, saves a checkpoint, exports and saves the MP4. It sees one file and one `.done` file per row in `./out`, like `out/r1.mp4`.
</Step>
<Step title="Check the files">
The agent runs `invideo edit capture` on the branch after a row, and you watch the first files. It sees the frames a viewer sees.
</Step>
<Step title="Run the rest in parts">
The agent runs the rest of the CSV in parts, and you check each part before the next. It sees a `.done` file per finished row, so a rerun skips it.
</Step>
</Steps>

The script:

```bash
#!/usr/bin/env bash
# rows.csv: id,name,offer with a header row and no commas in fields
set -u
export INVIDEO_JSON=1
export INVIDEO_AS=csv-batch   # the name from: invideo agent register
EDIT="<branch-url>"   # branch_url from: invideo branch new
NAME=t1               # text clip ids from: invideo edit show "$EDIT"
OFFER=t2
BATCH=$(date +%Y%m%d)
mkdir -p out

token() { invideo edit show "$EDIT" | jq -r .state_token; }

change() {
  invideo edit change "$EDIT" --timeline T1 --token "$(token)" \
    --operations @ops.json --wait
}

tail -n +2 rows.csv | while IFS=, read -r id name offer; do
  [ -f "out/$id.done" ] && continue
  jq -n --arg n "$name" --arg o "$offer" --arg a "$NAME" --arg b "$OFFER" \
    '[{op:"set_text",clip:$a,text:$n},{op:"set_text",clip:$b,text:$o}]' \
    > ops.json
  change; code=$?
  [ "$code" -eq 3 ] && { change; code=$?; }   # stale token: read again
  case "$code" in
    0) ;;
    4|5|8|9) echo "stop at row $id, exit $code" >&2; exit "$code" ;;
    *) echo "skip row $id, exit $code" >&2; continue ;;
  esac
  invideo branch checkpoint "$EDIT" --title "row $id" --token "$(token)"
  ans=$(invideo edit export "$EDIT" --format video --quality 1080 \
    --idempotency-key "csv-$BATCH-$id") || continue
  card=$(jq -r '.sequences[0]' <<<"$ans")
  invideo status "$card" --wait >/dev/null || continue
  invideo notebook show "$card" --out "out/$id" >/dev/null || continue
  [ -f "out/$id/export.mp4" ] || { echo "no file for row $id" >&2; continue; }
  mv "out/$id/export.mp4" "out/$id.mp4" && touch "out/$id.done"
done
```

`<branch-url>` is the `branch_url` that `invideo branch new` returns. The clip ids `t1` and `t2` come from `invideo edit show <branch-url>`. In the steps, `<project-url>` and `<edit-url>` are the template's project and timeline URLs, which the person gives you, and `<state_token>` comes from `invideo edit show`.

The ops for one row, as `jq` writes them. This is the bare list of ops that `--operations @ops.json` takes:

```json
[
  { "op": "set_text", "clip": "t1", "text": "Hi Maya" },
  { "op": "set_text", "clip": "t2", "text": "Two for one in Pune this week" }
]
```

### What the script relies on

Each line uses a flag from the command's `--help`.

- `INVIDEO_JSON=1` makes every command return one JSON object, so `jq -r .state_token` reads the token.
- `--wait` on any command retries when nothing ran, exit 7. It does not make `edit export` wait for the render.
- The export answer's `.sequences[0]` is the card's URL. `invideo status <card-url> --wait` waits for the render and saves nothing.
- `invideo notebook show <card-url> --out <dir>` saves the file as `<dir>/export.mp4`. `--file-name` does not change that name, so the script renames it.
- The script writes `.done` only when the MP4 is on disk, so a rerun redoes a row whose file is missing.
- `--idempotency-key` makes a retried export find the first export instead of rendering again.

### A timeline per row

The script reuses one timeline, so each row's video lives in its export and its checkpoint. To keep an editable timeline per row, add a `duplicate_timeline` op per row, then read the copy's clip ids with `invideo edit show --timeline <id>` before `set_text`. Export it with `--timeline`.

### Paid steps per row

If each row needs its own voice or image, add an `invideo gen run` per row. The agent finds a model with `invideo gen models <project-url> --query "<job>"`, the CLI's `find_models`. A good pick for a voice is `seed_tts_2_0`. Your agent can search for others with `find_models`. It reads the inputs once with `invideo gen model`, and runs row 1 with `--dry-run`, and tells you the price. For a price of 1 credit or more it waits for your yes. Then it passes `--max-credits` set to that price and an `--idempotency-key` on every row, so a rerun never pays twice. The paid line goes inside the loop, before `change`.

```bash
invideo gen models <project-url> --query "voice for a short greeting"
invideo gen model <project-url> --model seed_tts_2_0
invideo gen run <page-url> --model seed_tts_2_0 --dry-run \
  -i prompt="Hi Maya, this week only." -i voice_id=<voice-id>
# inside the loop, per row:
invideo gen run <page-url> --model seed_tts_2_0 \
  --max-credits <price> --idempotency-key "voice-$BATCH-$id" \
  -i prompt="Hi $name, this week only." -i voice_id=<voice-id>
```

`<project-url>` is the template's project: its URL up to the project name, before `/tree/`. `<voice-id>` comes from `invideo library search` in `seed_tts_2_0_voices`; a voice id works only with its own model. `<page-url>` is a notebook page in the project; `invideo notebook show <project-url>` lists them. `<price>` is the price row 1's dry run gave. Each paid run returns a thread URL. The agent waits with `invideo status <thread-url> --wait`, then reads the voice's sequence URL with `invideo notebook show` before it places the voice. It never runs `gen run` again to check.

## Cost and time

Edits and exports are free. Generation and transcripts cost credits. Paid stock costs a small credit charge, or one of your workspace's iStock licenses. Your agent runs a dry run first and tells you the price before it spends anything.

- Free: every `edit change`, checkpoint and export in the script.
- Costs credits: only what you add per row, like a voice with `invideo gen run`.

Each row takes the time of one export. Run large CSVs in parts, and check each part. See [Credits](/account/credits) for prices.

## If something goes wrong

The script reads the exit code of each command. These are the codes from `invideo help exit-codes`.

| Symptom | Fix |
| --- | --- |
| `get_model` or `generate` refuses the model as not found | Models retire. Run `find_models` with the job in words, use the best match, and tell the person which model you used. |
| Exit 2 | The request cannot be done as asked. Check the ops with `--dry-run`. The refusal names the field to fix. |
| Exit 3 | The edit changed since the read. The script reads the token again and retries once. |
| Exit 4 | Not signed in. Run `invideo auth login`, or the device code login on a server. |
| Exit 5 | Credits or quota ran out. The script stops. Rerun later; done rows are skipped. |
| Exit code 6, or `status` is `held` | A safety review, not a refusal. Wait, then run `invideo status <url> --wait` again. Do not resend. If it ends `failed`, change the input the review flagged. |
| Exit 7 | Nothing ran. `--wait` retries it. |
| Exit 8 | The person must confirm in invideo. The script stops. |
| Exit 9 | Run `invideo update`, then rerun. |
| A row has no MP4 and no `.done` file | The export or the save failed. Rerun the script. It redoes only that row. |
| Text overflows its box in the files | Fix the template in the editor, delete the `.done` files, then rerun the script. |
| A name with a comma breaks the row | Parse the CSV with a CSV tool such as Miller (`mlr`) instead of `read`. |
| Avoid: run the loop on main | Instead: branch the template first. |

## Prompts to try

Each prompt is a real variation of this job.

```text
Change the script to read rows.json instead of a CSV, one object per
line, and add a city field to a third text clip.
```

```text
Add a voice per row with seed_tts_2_0 saying the name. Dry-run the first
row and tell me the price per row before you run the rest.
```

```text
Make a 9:16 and a 16:9 file per row from two template timelines, T1 and
T2, in the same edit.
```

## When to use something else

For a pure render API with no editor and no person in the loop, a JSON-to-video service such as Creatomate or Shotstack fits. invideo fits when a person designs and fixes the template in a real editor.

## Related

- [Run invideo in CI and on servers](/how-to/ci-and-servers)
- [Run several agents and a person in one project](/how-to/developers/several-agents-one-project)
- [CLI commands and flags](/reference/cli)
- [Exit codes](/reference/exit-codes)
- [Environment variables](/reference/environment)
- [When to use Creatomate or Shotstack vs invideo](/compare/creatomate-shotstack)