# Frigate's camera wall on channel 1

> Getting Frigate's birdseye camera grid to play as a Live TV channel on a Roku through Jellyfin. Five stacked failures, and a root cause in a composite that renders slower than the 10 fps it claims.

- Source: https://nabkey.com/tinkerings/frigate-camera-wall
- Author: Chris Nabkey
- Published: 2026-08-30
- Built: 2026
- Status: shipped
- Stack: Frigate, go2rtc, Jellyfin, ffmpeg, HLS, Roku, Proxmox, systemd

I wanted Frigate's birdseye view, its auto-composited grid of every camera, as
channel 1 on the bedroom TV. The TV is a TCL Roku. The media server is
Jellyfin, whose Live TV feature takes an M3U playlist of streams, and Frigate
restreams birdseye. On paper, a one-line M3U.

It took five fixes. I did the watching; a Claude Code session drove the SSH,
the packet captures and a lot of `ffprobe` runs.

## Setup

- Frigate 0.17.1 in an Incus container on `inc1`, with its bundled go2rtc on
  ports 1984 and 8554.
- Jellyfin 10.11 in LXC 100 on `pve1`, a Proxmox box.
- The plan: a go2rtc URL in an M3U, the M3U added to Jellyfin as an "M3U
  Tuner", and the Roku app tunes the channel.

## 1. Roku can't play RTSP

`rtsp://inc1:8554/birdseye` made a channel and a black screen. Jellyfin's M3U
tuner passes the URL through as the channel's `MediaSource.Path`, and Roku's
`Video` node only takes `streamFormat` values like `hls`, `dash`, `mp4` and
`mkv`. `rtsp` isn't on the list. I switched to go2rtc's HLS at
`/api/stream.m3u8?src=birdseye`.

## 2. go2rtc's HLS holds one second of buffer

That played, then rebuffered every few seconds. go2rtc's HLS is a low-latency
browser fallback behind WebRTC and MSE: a rolling window of two half-second
segments, `TARGETDURATION:1`. A Roku wants several seconds of runway, so one
late segment or Wi-Fi retransmit drains it. No encoder setting changes the
window.

## 3. Roku wants audio, birdseye has none

The driveway camera, an Axis with a microphone, played while birdseye didn't.
Birdseye is video only, and Roku's HLS player stalls without an audio track. I
added a silent `anullsrc` AAC track with a go2rtc `exec:` source. Two gotchas,
both in Frigate's restream docs:

- `exec:` is off by default. Without `GO2RTC_ALLOW_ARBITRARY_EXEC=true` the
  stream silently disappears.
- Write the output placeholder as `{{output}}`. Frigate templates the go2rtc
  block first, so a single `{output}` gets eaten and the config won't load.

## Wrong turns

- **The MP4 endpoint.** I tried having Jellyfin transcode go2rtc's
  `stream.mp4` to keep one persistent connection. `ffprobe` read it as 989 fps
  (`avg_frame_rate = 90000/91`) for a 10 fps source. Jellyfin's transcoder
  believed it and fell to half realtime.
- **The "cycling" exec.** The exec ffmpeg's PID kept changing, and I blamed HLS
  polling. go2rtc does stop `exec:` sources when the last consumer leaves, but
  its HLS has a five-second keepalive. Most of the churn came from my own
  separate `curl` and `ffprobe` runs, each opening a fresh session.

## 4. A real HLS, built locally

I stopped using go2rtc's HLS. On the Jellyfin box, one ffmpeg per camera reads
from go2rtc, adds audio where needed, and writes two-second keyframe-aligned
segments into an eight-segment playlist. A small threaded Python server on port
8091 serves them as `application/vnd.apple.mpegurl`. Three `systemd` units,
`Restart=always`. The real cameras are a straight copy.

It was smooth for about a day, then buffered now and then.

## 5. Birdseye runs slower than it says

Nothing was starved. The producer had zero restarts, segments came back in 30
ms, the stream was about 600 kbps, and the Roku sat on 5 GHz at 526 Mbps and
-48 dBm. (The SSID is "WRT54G". It's a UniFi AP.)

Counting frames over twenty seconds of wall clock found it: birdseye advertises
10 fps and delivers 4.7 to 9, depending on motion. In Frigate 0.17.1 the
composite is built on the CPU in Python, a numpy canvas with one `cv2.resize`
per tile, driven by detect frames. Both my cameras detect at 5 fps, so the
composite floats between about 5 fps and the hardcoded 10 fps cap in
`birdseye.py`, and no setting pins it.

go2rtc reads that pipe with `-r 10`, hardcoded in Frigate's generated config,
so every frame gets stamped as 100 ms of video. At 6 or 7 real frames a second
the stream's timeline falls behind the wall clock. A live player drains one
media-second per wall-second, so a source that refills slower runs it dry at
any buffer depth. My sixteen-second buffer only delayed the stall.

For birdseye only, the producer now re-times to the wall clock:

```
ffmpeg -use_wallclock_as_timestamps 1 -rtsp_transport tcp -i rtsp://inc1:8554/birdseye \
  -f lavfi -i anullsrc=r=48000:cl=stereo -map 0:v -map 1:a \
  -fps_mode cfr -r 10 -c:v libx264 -preset veryfast -g 20 -c:a libmp3lame ...
```

`-use_wallclock_as_timestamps 1` stamps each frame when it arrived, and
`-fps_mode cfr -r 10` duplicates frames to hold a real 10 fps. The picture only
changes as often as the composite renders, but the clock matches the wall.
libx264 `veryfast` at 720p costs about 7% of one core, and the whole pipeline
stays under 10% CPU on the Jellyfin box. That held.

## 6. The Roku app forces a relay, and MP3

The Roku never plays my `:8091` HLS directly. Every connection to it comes from
Jellyfin's IP: for Live TV, Jellyfin runs its own ffmpeg that re-segments the
stream for the TV, and it was transcoding my silent AAC to 256 kbps MP3. Both
come from the Roku app's code.

- **The relay.** In jellyfin-roku's `LoadVideoContentTask.bs`, the direct-play
  attempt is gated on `not meta.live`. `VideoPlayerView.bs` also runs a
  `playbackFailureTest` that force-transcodes when the reported and measured
  durations differ by more than about 30 seconds, which a live stream always
  does. The reason string: "Roku thought it could direct play this media, but
  the playback duration was incorrect."
- **The MP3.** The app's HLS transcoding profile leaves AAC out on purpose; a
  comment in `deviceCapabilities.bs` says multichannel AAC decode is unreliable
  on Rokus. `StreamBuilder.cs` intersects that list with aac, ac3, eac3 and
  mp3, and MP3 is what's left.

So the silent track is now `libmp3lame`, and the relay should have nothing to
transcode on birdseye. I haven't confirmed it. Both Axis cameras dropped off at
11:02:09 the morning I made the change, at the same second (shared power behind
a TL-SG108PE), so there was no live session to check.

<figure class="diagram">
  *Diagram: Data flow: Frigate birdseye composite on the CPU, running below its declared 10 fps, feeds a go2rtc restream; a per-camera ffmpeg on the Jellyfin box re-times birdseye to a steady 10 fps HLS with a deep buffer served on port 8091; Jellyfin relays that as a pure remux, video and audio both copied; the Roku Jellyfin app plays the relay.* ([view it on nabkey.com](https://nabkey.com/tinkerings/frigate-camera-wall))
</figure>

## Still open

- Three passes for two cameras: Frigate encodes, my ffmpeg re-encodes, Jellyfin
  re-segments. `-use_wallclock_as_timestamps 1 -c:v copy` might drop my encode,
  but upstream keyframes are about five seconds apart (`-g 50` at 10 fps), so
  the playlist would lag. Untried. 7% of a core can wait.
- Is the relay actually copying the audio now? That needs checking once the
  cameras have power.
- The driveway's real AAC track probably gets transcoded to MP3 on the relay
  too. I haven't checked.

I also removed the dead `birdseye_tv` and `garage_tv` exec streams from the
Frigate config (the go2rtc API won't delete config-defined streams, so that
waited for a restart) and raised the container's SHM cap from 178 MB to 512 MB,
which Frigate had been warning about since before any of this.

## If you're doing this

- Roku doesn't play RTSP. Convert to HLS.
- go2rtc's HLS is for browsers. For a TV, repackage it with a real buffer.
- Don't trust `ffprobe`'s frame rate from a live fMP4 endpoint. Check it
  against the RTSP source.
- When Jellyfin transcodes something you didn't ask for, read the client's
  device profile before the server's settings.
- If a stream claims more fps than it delivers, re-time it to the wall clock. A
  bigger buffer won't fix it.

The wall's been on channel 1, steady, at about five frames a second. For a
driveway that's plenty.
