# How to Build an FFmpeg Video Pipeline in a Cloud Sandbox

> FFmpeg 6.1.1 with x264, x265, SVT-AV1, VP9 and Opus is preinstalled in every sandbox, so a pipeline is one script. On 60 seconds of 1080p video, a 720p H.264 encode took 18 s on small and 13 s on default. A full 10.5-minute film took 188 s and 113 s.

Source: https://sandboxer.com/guides/build-an-ffmpeg-video-pipeline-in-a-sandbox. Updated 2026-10-27.

**Short answer:** FFmpeg 6.1.1 with x264, x265, SVT-AV1, VP9 and Opus is preinstalled in every sandbox, so a pipeline is one script. On 60 seconds of 1080p video, a 720p H.264 encode took 18 s on small and 13 s on default. A full 10.5-minute film took 188 s and 113 s.

## What we ran

Everything below ran on prod on October 27, 2026, in two sandboxes: one `small` (2 vCPU, 4 GB, $0.018/hour) and one `default` (4 vCPU, 8 GB, $0.036/hour). The source is [Big Buck Bunny](https://peach.blender.org/) (Blender Foundation, CC BY 3.0): 1080p30 H.264, 634.6 seconds. Each timing is a single run, so treat the numbers as a guide, not a promise.

| Step | `small` | `default` |
| - | - | - |
| Download the 263 MB source inside the sandbox | 3.1 s | not timed |
| Full pipeline on a 60 s clip (720p MP4, 2-rung HLS, thumbnails, poster) | 47.8 s | 26.2 s |
| 720p H.264 `veryfast`, 60 s clip | 18.0 s (3.3x realtime) | 13.2 s (4.6x) |
| 720p H.264 `veryfast`, whole 634.6 s film | 188.3 s (3.4x) | 113.2 s (5.6x) |
| 1080p AV1 (SVT-AV1 preset 8), 60 s clip | 133.3 s (0.45x) | 85.7 s (0.70x) |
| Hosted HLS output on a private URL | 401 without the token, 200 with it | |
| `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, the file server kept serving | |

## 1. Check what FFmpeg can encode

You don't install anything. FFmpeg comes with the image ([what's preinstalled](/guides/whats-preinstalled-in-a-sandbox)), built with the encoders most pipelines need:

```bash
id=$(sbx new --type small --json | jq -r 'select(.event == "ready") | .id')
sbx exec $id 'ffmpeg -version | head -1; ffmpeg -hide_banner -encoders 2>/dev/null | grep -E "libx264|libx265|libsvtav1|libvpx-vp9|libopus|libmp3lame"'
```

```text
ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers
 V..... libsvtav1            SVT-AV1(Scalable Video Technology for AV1) encoder (codec av1)
 V....D libx264              libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264)
 V....D libx264rgb           libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 RGB (codec h264)
 V....D libx265              libx265 H.265 / HEVC (codec hevc)
 V....D libvpx-vp9           libvpx VP9 (codec vp9)
 A....D libmp3lame           libmp3lame MP3 (MPEG audio layer 3) (codec mp3)
 A....D libopus              libopus Opus (codec opus)
```

There is no GPU, so there is no NVENC or VAAPI. Every encode on this page is on the CPU.

## 2. Pull the source into the sandbox

Fetch inputs from inside the sandbox rather than uploading them from your laptop. The sandbox downloaded the 263 MB file in 3.1 seconds:

```bash
sbx exec $id --timeout 600 'cd ~ && curl -sSfL -o bbb.zip https://download.blender.org/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4.zip \
  && unzip -q bbb.zip && rm bbb.zip && mv bbb_sunflower_1080p_30fps_normal.mp4 bbb-full.mp4 \
  && ffmpeg -hide_banner -loglevel error -y -i bbb-full.mp4 -t 60 -map 0:v -map 0:a:0 -c copy clip60.mp4 \
  && ffprobe -v error -show_entries format=duration,bit_rate:stream=codec_type,codec_name,width,height -of compact bbb-full.mp4'
```

```text
stream|codec_name=h264|codec_type=video|width=1920|height=1080
stream|codec_name=mp3|codec_type=audio
stream|codec_name=ac3|codec_type=audio
format|duration=634.600000|bit_rate=3481058
```

The file has two audio tracks, so the commands below map the first one (`0:a:0`) on purpose. Otherwise FFmpeg picks one for you. `sbx exec` stops synchronous commands after 30 seconds unless you pass `--timeout` (600 at most). Use `--detach` for anything longer (step 5).

## 3. The pipeline script

One script turns an upload into what a video page needs: a probe, a 720p MP4 for download, an HLS ladder (720p and 360p) for streaming, a thumbnail sheet and a poster frame. It prints how long each step took.

```bash
#!/usr/bin/env bash
# pipeline.sh <input> <outdir>: probe, 720p MP4, 2-rung HLS ladder, poster and thumbnail sheet
set -euo pipefail
in=$1; out=$2; mkdir -p "$out/hls"
t() { local s=$(date +%s.%N); "$@" ; awk -v a="$s" -v b="$(date +%s.%N)" -v n="$step" 'BEGIN{printf "%-7s %6.1f s\n", n, b-a}' >&2; }
q="-hide_banner -loglevel error -y"

step=probe  t ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json "$in" > "$out/probe.json"
step=mp4    t ffmpeg $q -i "$in" -vf scale=-2:720 -c:v libx264 -preset veryfast -crf 23 \
                -c:a aac -b:a 128k -movflags +faststart "$out/720p.mp4"
step=hls    t ffmpeg $q -i "$in" -filter_complex "[0:v]split=2[a][b];[a]scale=-2:720[v720];[b]scale=-2:360[v360]" \
                -map "[v720]" -map "[v360]" -map 0:a -map 0:a \
                -c:v libx264 -preset veryfast -g 60 -keyint_min 60 -sc_threshold 0 \
                -b:v:0 3000k -maxrate:v:0 3300k -bufsize:v:0 6000k \
                -b:v:1 800k -maxrate:v:1 880k -bufsize:v:1 1600k -c:a aac -b:a 128k \
                -f hls -hls_time 4 -hls_playlist_type vod -master_pl_name master.m3u8 \
                -var_stream_map "v:0,a:0 v:1,a:1" -hls_segment_filename "$out/hls/%v_%03d.ts" "$out/hls/%v.m3u8"
step=thumbs t ffmpeg $q -i "$in" -vf "fps=1/2,scale=320:-2,tile=5x2" -frames:v 1 "$out/thumbs.jpg"
step=poster t ffmpeg $q -ss 5 -i "$in" -frames:v 1 "$out/poster.jpg"
```

The HLS step decodes the source once and splits it into both renditions. `-g 60 -sc_threshold 0` puts a keyframe every 2 seconds, so every 4-second segment starts on one. Each flag is in the [FFmpeg formats docs](https://ffmpeg.org/ffmpeg-formats.html#hls-2).

```bash
sbx scp ./pipeline.sh $id:/home/user/
sbx exec $id --timeout 600 'chmod +x ~/pipeline.sh && cd ~ && ./pipeline.sh clip60.mp4 out && cat out/hls/master.m3u8'
```

On `small`:

```text
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=3440800,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
0.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
1.m3u8

probe      0.1 s
mp4       20.0 s
hls       25.3 s
thumbs     2.0 s
poster     0.4 s
```

On `default`, the same script took 0.0, 9.7, 14.9, 1.2 and 0.4 seconds. (The `small` run above predates the bitrate cap described next; the `default` run includes it.)

**Cap the bitrate.** Our first `small` run had only `-b:v:0 3000k`. One 4-second 720p segment came out at 4.4 MB, about 8.9 Mbit/s, roughly three times the bitrate the playlist advertises. That can make players pick a rendition the viewer's connection can't sustain. With `-maxrate` and `-bufsize` added (as in the script above), the same segment was 2.0 MB.

## 4. Encode speed per size

We ran the same benchmark script on both sizes. "Realtime" is seconds of video divided by seconds of encoding. Output sizes matched within 0.5 MB across sizes: the CPU count changes speed, not the result.

| Encode (60 s of 1080p30 unless noted) | `small` | `default` | Output |
| - | - | - | - |
| 720p H.264, `-preset veryfast -crf 23` | 18.0 s, 3.33x | 13.2 s, 4.56x | 9.6 MB |
| 720p H.264, `-preset medium -crf 23` | 37.8 s, 1.59x | 21.2 s, 2.83x | 13.3 MB |
| 1080p H.264, `-preset veryfast -crf 23` | 36.7 s, 1.64x | 19.5 s, 3.08x | 18.0 MB |
| 1080p H.265, `-preset fast -crf 28` | 86.9 s, 0.69x | 42.1 s, 1.42x | 10.3 MB |
| 1080p AV1, SVT-AV1 `-preset 8 -crf 35` | 133.3 s, 0.45x | 85.7 s, 0.70x | 13.1 MB |
| Whole film (634.6 s) to 720p H.264 `veryfast` | 188.3 s, 3.37x | 113.2 s, 5.61x | 110 MB |

What it costs: at those whole-film speeds, one hour of source video to 720p H.264 takes about 18 minutes on `small` (about $0.005) and about 11 minutes on `default` (about $0.006). `small` is slightly cheaper per minute of video; `default` finishes sooner. Pick `default` when someone is waiting for the result, and `small` for an overnight backlog. Prices come from the [pricing page](/pricing).

The `default` sandbox ran on an AMD Ryzen 9 7950X3D host. We didn't record the `small` sandbox's host CPU, so part of the gap may come from the host, not only the vCPU count.

## 5. Run several encodes at once, or detach long ones

x264 doesn't use 4 vCPUs fully on a single 720p encode. On `default`, four 720p `veryfast` encodes of the 60-second clip took:

| How | Wall time |
| - | - |
| One after another (4 x 13.2 s) | 52.8 s |
| All four at once with `&` and `wait` | 39.1 s |
| All four at once, each with `-threads 1` | 34.8 s |

For a batch, run one FFmpeg per vCPU with `-threads 1` instead of one big multi-threaded job.

The whole-film encodes ran longer than `sbx exec` waits, so we started the benchmark detached and polled it:

```bash
pid=$(sbx exec $id --detach 'bash ~/bench.sh' | jq -r .processId)
while [ "$(sbx exec $id --status $pid --json | jq -r .running)" = true ]; do sleep 30; done
sbx exec $id --status $pid --json | jq -r '.exitCode, .stdout'
```

```text
0
720p x264 veryfast            13.2 s   4.56x realtime  rc=0
…
full film 720p veryfast      113.2 s   5.61x realtime  rc=0
```

A running detached job counts as activity for idle pause, but it doesn't hold off auto-stop. For jobs that take hours, see [run a long job and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox).

## 6. Serve the HLS output on a private URL

To play the result in a browser or check it with a real player, serve the output folder and host the port. A systemd unit restarts the server if it crashes, and again on any boot:

```ini
# hls-web.service
[Unit]
Description=Serve ~/out over HTTP for HLS playback
After=network.target

[Service]
User=user
WorkingDirectory=/home/user/out
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
Restart=always

[Install]
WantedBy=multi-user.target
```

```bash
sbx scp ./hls-web.service $id:/tmp/
sbx exec $id 'sudo mv /tmp/hls-web.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now hls-web && systemctl is-active hls-web'
URL=$(sbx host $id 8080 --title "HLS output" --json | jq -r .url)   # private by default
```

The URL is `https://<subdomain>-8080.box.sandboxer.com?_token=…`. Keep it out of logs. We checked it from a laptop:

```bash
BASE=${URL%%\?*}
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/hls/master.m3u8"          # no token
curl -s -c jar -b jar -L -o /dev/null -w '%{http_code} after %{num_redirects} redirect\n' \
  "$BASE/hls/master.m3u8?_token=${URL##*_token=}"                          # token sets a cookie
curl -s -b jar -o /dev/null -w '%{http_code} %{size_download} bytes %{time_total}s\n' "$BASE/hls/0_001.ts"
```

```text
401
200 after 1 redirect
200 4430972 bytes 2.072551s
```

The first request with `_token` returns a `302` that sets a cookie. After that, plain paths work, which is what a player needs when it fetches `0.m3u8` and the segments relative to the master playlist. That 4.4 MB segment (from the uncapped first run) took about 2 seconds to download from our laptop, so playback keeps ahead. Your network sets that number. For a public demo, `sbx host $id 8080 --public` drops the token. See [give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) for the details.

## 7. What survives a stop

```bash
sbx stop $id --wait
sbx resume $id
sbx info $id --json | jq '.sandbox | {memoryRestored, stop: .stop.reason}'
```

```json
{
  "memoryRestored": true,
  "stop": "user"
}
```

The output files, the file server and the hosted URL all came back: the same segment URL returned `200` with the same 4,430,972 bytes. If a resume lands on a machine with a different CPU type, the sandbox boots from disk (`memoryRestored: false`). Your files stay, but running processes are lost. The enabled `hls-web` unit starts again on that boot. A half-finished FFmpeg job doesn't, so rerun it. We didn't trigger a cold boot in this test.

## 8. Get the results out

```bash
sbx scp $id:/home/user/out/720p.mp4 .
```

The 10.1 MB file took 5.3 seconds to copy to our laptop, tunnel setup included. For whole folders, `rsync` and the SDK limits, see [move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox). Delete the sandbox when you're done: `sbx delete $id --yes`.

## FAQ

### Is FFmpeg already installed in a Sandboxer sandbox?

Yes. Version 6.1.1 from Ubuntu 24.04, with libx264, libx265, SVT-AV1, libvpx-vp9, Opus and MP3.

### Which sandbox size should I use for transcoding?

`default` encoded 1.4x to 2.1x faster than `small` in our runs, and cost a little more per minute of video. Use `default` when a user is waiting, and `small` for batch backlogs. Encoding to AV1 or H.265 at 1080p ran below realtime on `small`.

### Can I use GPU encoding such as NVENC?

No. Sandboxes have no GPU, so use the CPU encoders. For H.264 at 720p, `veryfast` ran at 3.3x to 5.6x realtime in our tests.

### How do I process files longer than the exec timeout?

Start FFmpeg with `sbx exec --detach`, which returns a process id at once, then check `sbx exec --status`. Synchronous `exec` stops at 600 seconds even with `--timeout`.

### Can a browser play the HLS output straight from the sandbox?

Yes. Host the port that serves the folder. With a private URL, the first request needs `?_token=`. It sets a cookie, so the player's follow-up requests for playlists and segments work without the token.

## Related

- [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [What's preinstalled in a sandbox](/guides/whats-preinstalled-in-a-sandbox)
