---
title: "Rendering the final mp4"
description: "ralphy render reads the manifest, runs HyperFrames, writes final.mp4. The loudnorm pass, render history, perf targets."
---

`ralphy render <project-id>` is the only render path. It reads `scenario.json` and `asset-manifest.json`, drives HyperFrames (Puppeteer + FFmpeg), and writes the result to `.ralphy/workspaces/default/projects/<id>/render/final.mp4`. No direct `bunx hyperframes render` calls, no ad-hoc `ffmpeg` shells — every recipe lives behind a `ralphy` verb so the cost rollup, the gen-log, and the asset manifest all stay consistent. That's [AGENTS invariant #2](https://github.com/alecs5am/ralphy/blob/main/AGENTS.md).

## What `render` does

A render call walks through the project in order:

1. **Reads** `.ralphy/workspaces/default/projects/<id>/scenario.json` and `asset-manifest.json` to know which slots exist and where their files live.
2. **Resolves** the composition. `.ralphy/workspaces/default/projects/<id>/index.html` is the root composition HyperFrames seeks deterministically via its paused GSAP timeline.
3. **Calls** HyperFrames headlessly. Puppeteer rasterizes each frame, FFmpeg muxes the mp4.
4. **Writes** the result to `.ralphy/workspaces/default/projects/<id>/render/final.mp4` (or wherever you pointed `--output`).
5. **Optionally post-processes** with ffmpeg if `--loudnorm` is set — EBU R128 two-pass normalization to -16 LUFS.
6. **Appends** an entry to `generations.jsonl` with the model `hyperframes`, the latency, and the output path. Renders are first-class citizens in the gen-log.

The whole call is one command:

```bash
ralphy render syrup-001
```

```json
{
  "projectId": "syrup-001",
  "output": ".ralphy/workspaces/default/projects/syrup-001/render/final.mp4",
  "durationSec": 15.0,
  "fileSizeBytes": 4321987,
  "latencyMs": 287443
}
```

## The flags that matter

<ParamField path="--output" type="string">
  Output mp4 path. Default `.ralphy/workspaces/default/projects/<id>/render/final.mp4`.
</ParamField>

<ParamField path="--fps" type="number">
  Frames per second. Default `30`. Bump to `60` for high-motion content.
</ParamField>

<ParamField path="--quality" type="string">
  Render quality preset (`draft`, `standard`, `high`). Default `standard`.
</ParamField>

<ParamField path="--resolution" type="string">
  Resolution preset (`portrait`, `landscape`, `square`) — drives the canvas DPR.
</ParamField>

<ParamField path="--loudnorm" type="boolean">
  Apply EBU R128 loudnorm post-render via ffmpeg. Target -16 LUFS, two-pass. The TikTok / Reels / Shorts loudness standard. On by default for batch renders; off by default for single renders so you can iterate faster.
</ParamField>

<ParamField path="--dry-run" type="boolean">
  Print the resolved render plan (composition path, output path, flags) and exit. No render.
</ParamField>

The full per-flag detail (every option, every default, every edge case) lives in [CLI: rendering verbs](/cli/rendering-verbs).

## The loudnorm pass

TikTok, Reels, and Shorts all target -16 LUFS integrated loudness. A video that's quieter sounds amateur; a video that's louder gets compressed to mush by the platform's normalizer. `--loudnorm` runs a two-pass EBU R128 loudnorm via ffmpeg after HyperFrames writes the mp4:

```bash
ralphy render syrup-001 --loudnorm
```

The pass is documented in detail in the [editor playbook](https://github.com/alecs5am/ralphy/blob/main/docs/playbooks/editor.md). Behind the scenes it shells out to `cli/lib/ffmpeg-recipes.ts` — the only sanctioned place ffmpeg gets called from. Never run `ffmpeg` directly on a project file; use `ralphy audio loudnorm` or pass `--loudnorm` on render.

If you forgot `--loudnorm` and want to normalize an existing render:

```bash
ralphy audio loudnorm \
  --in .ralphy/workspaces/default/projects/syrup-001/render/final.mp4 \
  --out .ralphy/workspaces/default/projects/syrup-001/render/final.loudnorm.mp4
```

## The iteration loop

The normal cadence:

1. Render with `--dry-run` first if you want to see the plan.
2. Render for real. Wait until HyperFrames + ffmpeg finishes.
3. Watch the mp4. Decide what's off — wrong scene timing, captions out of sync, hook too slow, music too loud.
4. Iterate on the underlying assets via the [art-director playbook](https://github.com/alecs5am/ralphy/blob/main/docs/playbooks/art-director.md) (regenerate a slot) or the [scenarist playbook](https://github.com/alecs5am/ralphy/blob/main/docs/playbooks/scenarist.md) (rework a scene's pacing).
5. Render again. The new render lands at `final.<iter>.mp4` so the previous take is preserved.

The render history pattern parallels asset versioning: every new render writes a new file, never overwrites. Pattern on disk:

```text
.ralphy/workspaces/default/projects/syrup-001/render/
  final.mp4         ← first render
  final.2.mp4       ← second render, after fixing scene-03 timing
  final.3.mp4       ← third render, after loudnorm pass
```

[AGENTS invariant #13](https://github.com/alecs5am/ralphy/blob/main/AGENTS.md): no overwriting, no deleting, the history stays.

## Preview vs final render

There's no auto-launched preview in Ralphy. [AGENTS invariant #5](https://github.com/alecs5am/ralphy/blob/main/AGENTS.md): no background processes, chat is the interface. If you want to preview frames live, run it foreground in a separate terminal:

```bash
bunx hyperframes preview .ralphy/workspaces/default/projects/syrup-001
```

Don't ask Ralphy to launch it for you — it won't. Preview is a developer tool; the production path is `ralphy render`.

## Perf targets

[`docs/perf-targets.md`](https://github.com/alecs5am/ralphy/blob/main/docs/perf-targets.md) sets two numbers:

- **Cold-start single video** ≤ 8 minutes end-to-end (intake → render).
- **Batch of 10** ≤ 25 minutes total.

If a render exceeds 50% of these targets, the producer playbook says report before continuing. The numbers exist so you can spot a stuck queue or a misconfigured model before it eats your afternoon.

## Troubleshooting

Common render failures break down by stage:

- **`E_NOT_FOUND` on the project.** The project ID is wrong, or you're in the wrong workspace. Check `ralphy project list -p`.
- **Manifest references a missing asset.** Re-run `ralphy project verify <id>` to see what's broken. The fix is usually to regenerate the missing slot.
- **HyperFrames lint fails.** Missing `class="clip"`, missing `data-composition-id`, timeline registration key mismatch. Run `bunx hyperframes lint <project>` to see the exact issue.
- **ffmpeg loudnorm fails.** Usually a corrupt mp4 from a failed render. Inspect with `ffprobe`, then re-render.
- **The render is silent.** Check that `asset-manifest.json` has voiceover slots and that they point at non-empty files. The composition mounts them via `<audio data-start="..." data-volume="...">`; an empty file becomes silence.

The full error catalog is at [CLI: error catalog](/cli/error-catalog). For env health (missing keys, missing deps), the first move is always `ralphy doctor` — see [Setup and doctor](/cli/setup-doctor).

## Related

- [Reviewing and iterating](/working/reviewing-iterating) — what to do between renders
- [Generating assets](/working/generating-assets) — what feeds the render
- [CLI: rendering verbs](/cli/rendering-verbs) — every flag, full reference
- [CLI: error catalog](/cli/error-catalog) — render-stage errors
- [`editor.md`](https://github.com/alecs5am/ralphy/blob/main/docs/playbooks/editor.md) — the playbook this page paraphrases
- [`docs/perf-targets.md`](https://github.com/alecs5am/ralphy/blob/main/docs/perf-targets.md) — the perf budget
