imx708_wifi_video

# imx708_wifi_video

Live 1080p H.264 from the IMX708, over WiFi, in a browser tab. No SD card, no
recording step, no player to install.

This is [`imx708_video`](../../components/esp_cam_sensor_imx/examples/imx708_video/) and
[`imx708_wifi_snapshot`](../imx708_wifi_snapshot/) joined up. The first proved
the hardware encoder; the second proved the radio and measured what it can
carry. What is new here is everything between them: an encoded-frame ring that
lets the camera and the network run at their own speeds, and a fragmented-MP4
muxer on the board so a `<video>` element can play a stream that has no end.

| Where | Endpoint | What it is |
| --- | --- | --- |
| `:81` | `GET /` | the player |
| `:81` | `GET /info` | geometry, frame rate, codec string, as JSON |
| `:81` | `GET /stream.mp4` | live fragmented MP4 — what the browser plays |
| `:81` | `GET /stream.h264` | live Annex-B elementary stream — what `ffplay` plays |
| `:80` | `GET /stats` | encoder and link counters, as text |
| `:80` | `GET /bench` | 8 MB of pattern from PSRAM, no camera in the loop |
| `:80` | `GET /rate?kbit=N` | retarget rate control, live |
| `:80` | `GET /skip?n=N` | encode 1 frame in N; `n=0` stops the encoder |
| `:80` | `GET /` | redirects to `:81/` |

Credentials come from `components/imx_wifi/include/wifi_credentials.h`, which is
gitignored. See that component's `.example` template.

## Run it

```bash
python tools/capture.py --flash --project examples/imx708_wifi_video --out boot
```

That flashes, resets, and prints the boot log until the board says it is
serving. Then open the address it gives you:

```
I imx708_wifi_video: watch it at http://192.168.0.164:81/
I imx708_wifi_video:   ffplay http://192.168.0.164:81/stream.h264
I imx708_wifi_video:   instruments: http://192.168.0.164/stats  /bench  /skip?n=
```

The stream starts as soon as the board boots and keeps running with nobody
watching, so AE, AWB and autofocus are already converged when you connect. The
first picture appears within one GOP — one second by default — because a
decoder can only join at a keyframe.

To record instead of watch, point ffmpeg at the raw stream; it needs no help
from this repo, because Annex-B is exactly what it expects on the wire:

```bash
wsl ffmpeg -i http://192.168.0.164:81/stream.h264 -t 30 -c copy /mnt/c/tmp/clip.mp4
```

## Why fragmented MP4, and why the board does the muxing

The encoder emits an Annex-B elementary stream: NAL units separated by start
codes, and nothing else. That is the right thing on the wire — it carries its
own synchronisation, so it can be cut and joined anywhere — but no browser will
play it, and `<video src=...>` will not play *any* endless stream, because `src`
wants something with a length that can be seeked.

The one container a browser accepts as a live byte stream is fragmented MP4
through Media Source Extensions. fMP4 splits an ordinary MP4 in two: an init
segment (`ftyp` + `moov`) declares codec, geometry and timescale but indexes
nothing, and every frame after it is a self-contained `moof` + `mdat` carrying
its own timing. Nothing needs to be known in advance and nothing is patched
afterwards.

That last part is the whole reason [`tools/mp4.py`](../../tools/mp4.py) could not
be reused. It writes a `stco` table of absolute file offsets, which can only be
computed once the recording is complete — fine for a clip, impossible for a
stream. [`components/imx_fmp4/`](../../components/imx_fmp4/) is the same job in
C, on the sending side, and the box layouts are deliberately mirrored from that
file so the two can be read against each other.

**MJPEG was ruled out by measurement, not taste.** The snapshot example found
~260 KB per q90 JPEG against a 6–7 Mbit/s link: about 3 fps. Dropping to q60
lands near 100 KB and ~8 fps, at which point it is worth asking what MJPEG is
buying. H.264 at 3 Mbit/s is 375 KB/s for the whole 28 fps.

### Checking the container without a board

The muxer is ordinary C with no ESP-IDF in it, which means it compiles on the
host and can be pointed at a clip you already have. This is how it was checked
before anything was flashed — 217 frames from `clip/imx708.h264`, muxed and then
decoded by ffmpeg with no errors:

```bash
gcc -O2 -Icomponents/imx_fmp4/include components/imx_fmp4/test/fmp4_host_test.c components/imx_fmp4/imx_fmp4.c -o fmp4_host_test
```

```bash
./fmp4_host_test clip/imx708.h264 frag.mp4 && ffmpeg -v error -i frag.mp4 -f null -
```

Silence from ffmpeg means every frame decoded.

A container fault shows up there as `Invalid NAL unit size`, a wrong frame
count, or a duration that disagrees with the source. Doing this on the host
first is worth the twenty minutes: a muxing bug and a transport bug look
identical from the far end of a WiFi link.

## The frame ring, and why a slow viewer is dropped rather than waited for

The camera-side pipeline is one task and one loop — dequeue, encode, requeue,
publish — and it never touches a socket. Encoded frames go into a 1 MB ring in
PSRAM; the HTTP handlers only ever read from that.

The ring size is a **latency budget, not a buffer**. At 3 Mbit/s it holds about
2.8 s. A viewer that falls further behind than that has its frames overwritten,
and is then jumped forward to the newest keyframe rather than being served stale
frames or allowed to stall the encoder. `resyncs` in `/stats` counts those
jumps.

That is the difference between live video and a file transfer. A stream that
buffers in order to keep up is a stream that is no longer live, so the choice
here is always to skip forward and take the visible jolt. The timestamps carry
the gap honestly, so the picture skips while the clock stays true.

Each fragment is held back until the next frame arrives, so its duration can be
the real interval rather than a nominal one. It costs 36 ms of latency and buys
a timeline where each fragment's decode time is exactly the sum of the durations
before it. MSE is unforgiving of the alternative: a nominal duration against a
true timestamp leaves a few milliseconds of gap or overlap on *every* frame, and
the player either stalls in the gaps or drifts out of them.

## Two servers, and which one gets the page

`esp_http_server` runs one task per instance and serves one request at a time in
it. A live stream is a handler that never returns, so it owns its whole server
for as long as someone is watching — no `/stats` during a stream, which is the
one moment the counters are worth reading.

Splitting into two instances is the obvious fix. **Which side the page goes on
is the part that is not obvious**, and getting it wrong cost a debugging round
here. Serving the page from the control port makes the stream cross-origin; an
`Access-Control-Allow-Origin` header covers that for an ordinary browser, but an
embedded browser refused the fetch outright with `ERR_BLOCKED_BY_CLIENT` and the
page sat on `starting...` with nothing whatever wrong on the board. Serving the
page from the *stream* port makes the fetch same-origin and the question stops
existing.

So port 81 is the viewer — page, `/info` and both streams, one origin, every URL
relative. Port 80 is the instruments, answerable at any moment because nothing
there blocks. The page links to them as plain navigations, which no origin rule
touches, and `http://<ip>/` redirects to `:81/` so the obvious address is not a
dead end.

**Two instances cannot share a control port.** `httpd` uses a UDP socket to wake
its own task, it defaults to 32768 for every instance, and the second
`httpd_start()` fails on the bind with nothing useful to say. Set `ctrl_port`
explicitly on both.

**One viewer at a time.** A second connection to `:81` waits for the first to
finish, which for a live stream is never. One 1080p viewer is more than this
link carries anyway.

## Measured, 2026-08-30

### The encoder

| | |
| --- | --- |
| Encode, camera stopped, one frame fed repeatedly | **32.5 ms** |
| Encode, camera streaming | **36.2–36.5 ms** |
| Per-frame budget at 28 fps | 35.7 ms |
| Delivered pipeline rate | **27.2 fps** |

The encoder needs essentially the whole frame interval for 1920×1072, which is
why the pipeline runs at 27.2 fps rather than 28. The 4 ms difference between
the two rows is the ISP writing the next frame into PSRAM while the encoder
reads the current one.

**Encode time does not depend on how many bits come out.** Sweeping rate control
from 1 to 8 Mbit/s moved the output from 4.8 KB to 34.7 KB per frame and left
the time flat at 36.2–36.5 ms. It is a fixed-throughput pipeline, so the levers
that matter are resolution and frame rate, not quality.

> **A note on `imx708_video`'s README.** It quotes `encode 9204 us mean` in its
> "Reading the log" section, and that number sent this work looking for a
> regression that does not exist. Both preserved runs from that example —
> `clip/log.txt` and `clip2/log.txt` — say `encode 36290 us` and `36287 us` at
> 27.2 fps, which is exactly what this example measures. The 9204 figure matches
> no run in the tree; that README block has been corrected.

### The link, and what actually arrives

`/bench`, no viewer: **6.65, 8.51, 9.02 Mbit/s**.

Delivered stream, 15 s per row, `/stream.h264`, counted by `ffprobe`:

| `/rate` | encoded | delivered | delivered rate | resyncs |
| --- | --- | --- | --- | --- |
| 4000 kbit/s | 408 | 381 | 25.4 fps, 3.63 Mbit/s | 0 |
| 3000 kbit/s | 411 | 394 | **26.2 fps, 2.85 Mbit/s** | 0 |
| 2000 kbit/s | 413 | 416 | 27.7 fps, 1.96 Mbit/s | 0 |
| 1000 kbit/s | 414 | 429 | 28.6 fps, 1.06 Mbit/s | 0 |

Everything the encoder produces reaches the viewer, at every setting, with no
resynchronisation at all. Counts above the encoded figure are the ring's backlog
draining in the first moments of the connection, not frames appearing from
nowhere. `send_us_mean` settles around 17 ms against a 36 ms frame interval, so
there is roughly half the interval spare.

In a browser: 2.99 Mbit/s, **0.6 s behind live**, 1920×1072.

**Throughput on this link is not a constant, and it is worth knowing how badly.**
Over one afternoon `/bench` ranged from 15.4 Mbit/s to 0.01 Mbit/s. The
*unchanged* `imx708_wifi_snapshot` binary — the one that measured 7.2 Mbit/s the
day before — was reflashed during the worst of it and measured **0.05 Mbit/s**,
same board, same room, same −40 dBm. Pings through the same collapse came back
**0% lost, 4 ms average**: healthy for small packets, hopeless for bulk, which is
bursty 2.4 GHz interference rather than congestion. Run `/bench` before
concluding anything from a slow stream.

### Two bugs that both looked exactly like a slow network

Neither was, and both were found only because `/bench` and `/stats` were sitting
on a port that keeps answering while a stream runs.

**1. Nagle's algorithm, against a paced sender.** A frame is written, then the
sender goes quiet for 36 ms. Nagle holds the sub-MSS tail of each frame until
the previous write is acknowledged, and the receiver — having nothing to say —
delays that ACK. The two interlock and the stream advances about once per
delayed-ACK timer. Measured: `send_us_mean` **40 902 µs**, 41 ms blocked in a
socket for 9.5 KB, 2–5 fps arriving while the encoder produced 27.
`setsockopt(TCP_NODELAY)` on the stream socket took it to 10.6 fps.

`/bench` never showed this, and that is the instructive part: it always has more
data queued, so its writes are never sub-MSS and Nagle never engages. **A bulk
benchmark cannot stand in for a paced one.**

**2. `pdMS_TO_TICKS(5)` is zero at 100 Hz.** The frame-wait loop was written as
"1000 iterations of `vTaskDelay(pdMS_TO_TICKS(5))`", intending a five-second
budget. `pdMS_TO_TICKS` is `(ms * CONFIG_FREERTOS_HZ) / 1000`, so at IDF's
default tick rate that is 5 × 100 / 1000 = **0**, and `vTaskDelay(0)` yields
without sleeping. The loop burned its whole budget in **ten milliseconds** and
dropped each viewer about as fast as it accepted one.

From the client this is indistinguishable from a slow link: a handful of frames
arrive, the connection closes, and every rate computed over the intended window
is wrong by the ratio of the two — which is how a stream really running at 27 fps
was measured at 4. The board's own log said it plainly the moment anyone looked:

```
I h264 viewer joined at frame 4338 (1 watching)
W no frames for 5000 ms - dropping the h264 viewer
I h264 viewer left after 6 frames, 45 KB in 15 ms -> 24.7 Mbit/s
```

Five thousand milliseconds of waiting, in fifteen. `stream_next()` now takes an
`esp_timer_get_time()` deadline and sleeps `vTaskDelay(1)` — one tick, whatever
a tick happens to be. **Never express a short delay in milliseconds without
checking what it rounds to.**

(This example sets `CONFIG_FREERTOS_HZ=1000` anyway, as every WiFi example here
does — esp_hosted warns about bus jitter at 100 Hz. The bug was a stale
`sdkconfig` left at 100 by an unrelated experiment, which is exactly how it will
happen to you.)

### What frame-skip does, and what it does not

`/skip?n=` was added on the theory that the encoder, busy ~99% of the time, was
starving the radio. **That theory is wrong**, and the measurement that killed it
is worth keeping:

| `/skip` | encoder | `/bench` |
| --- | --- | --- |
| 0 (encoder stopped) | 0 fps | 8.77 Mbit/s |
| 1 | 27.3 fps | 6.62 Mbit/s |
| 2 | 14.0 fps | 7.83 Mbit/s |
| 4 | 6.9 fps | 7.23 Mbit/s |

No relationship: switching the encoder off entirely buys about as much as the
link's own run-to-run spread, and the row where it runs flat out is not the
slowest. Sampling `frames_encoded` over the same ten-second window while idle,
while streaming and while benching gives 27.5, 27.0 and 27.5 fps — the encoder
does not slow the radio down, and the radio does not slow the encoder down.

The other thing frame-skip does not do is **save bandwidth**. Rate control's
budget is bits per second, so halving the frame rate does not halve the bitrate;
it gives each remaining frame twice as many bits. This is the obvious lever to
reach for when a link is too slow and it is the wrong one. `/rate?kbit=` is the
bandwidth knob. `/skip` trades frame rate for per-frame quality, and buys chip
time.

## What you can change

All at the top of `main/imx708_wifi_video_main.c`.

| Define | Default | Notes |
| ------ | ------- | ----- |
| `VIDEO_BITRATE` | 3000000 | The bandwidth knob. Live, `/rate?kbit=` changes it without breaking the stream |
| `VIDEO_FPS` | 28 | The sensor's only mode |
| `VIDEO_GOP` | 28 | Also the join latency: a viewer waits up to one GOP for its first picture |
| `VIDEO_FRAME_SKIP` | 1 | Encode 1 frame in N. Does not save bandwidth — see above |
| `VIDEO_QP_MIN/MAX` | 20 / 45 | 51 is the codec maximum |
| `RING_BYTES` | 1 MB | ~2.8 s at `VIDEO_BITRATE`; the most lateness a viewer gets before it is dropped forward |
| `ENC_OUT_BYTES` | 512 KB | Must fit the largest IDR. Also sizes three per-viewer buffers, so 1.5 MB of PSRAM per stream |
| `ENCODE_16_ALIGNED` | 1 | See "1072 lines, not 1080" in `imx708_video`'s README |
| `ENCODER_BITRATE_SWEEP` | 0 | Boot-time encoder diagnostics: free-run timing and the bitrate sweep above |
| `CTRL_PORT` / `STREAM_PORT` | 80 / 81 | |

## Reading `/stats`

```
frames_encoded=13373  idr=477  encode_failed=0
fps=26.0  fps_recent=27.4
encode_us_mean=36387  encode_us_worst=51227  encode_us_budget=35714
dqbuf_us_mean=1773  dqbuf_us_worst=44163
send_us_mean=16822  send_us_worst=2413830  frames_sent=8241
encoded_kbit_s=2339  target_kbit_s=3000
viewers=0  resyncs=5  frame_skip=1
```

- **`dqbuf_us_mean`** is who the bottleneck is. Near zero means a frame was
  always already waiting, so the encoder paces the loop and frames are being
  dropped between camera and encoder — which at skip 1 is the normal state here,
  and why 27.2 fps rather than 28. A large value means the camera paces it and
  the encoder has headroom, as it does at any `/skip` above 1.
- **`fps_recent`** covers only the interval since the previous `/stats`. The
  cumulative `fps` cannot show the effect of anything changed while the board is
  running, because it stays dominated by however long the old setting had
  already been averaging.
- **`send_us_mean`** is how long the socket took to accept one frame. Near the
  frame interval means the link is the limit; near zero means the pipeline is.
- **`resyncs`** climbing steadily means the link cannot carry the current
  bitrate and viewers are being jumped forward. Lower `/rate?kbit=`.
- **`encode_failed`** is the encoder rejecting frames. `ESP_H264_ERR_OVERFLOW`
  means `ENC_OUT_BYTES` is too small for an IDR of this scene.

## If there is no picture

- **The page says `waiting for the first keyframe...`** — normal for the first
  second after boot; the init segment cannot be built until the encoder has
  emitted an IDR, because that is the only place the SPS and PPS appear.
- **The page says `this browser will not play ...`** — no MSE, which on iOS
  means Safari. Use `ffplay <the URL it prints>` instead.
- **The page sits on `starting...`** — the `/stream.mp4` fetch was refused.
  Check the browser console; `ERR_BLOCKED_BY_CLIENT` is the client's policy, not
  the board. Confirm the board is fine with `curl -s http://<ip>/stats`.
- **`/stream.h264` works and `/stream.mp4` does not** — that is the muxer, and
  the split is deliberate: the raw endpoint has no container in it at all, so it
  answers whether the problem is the camera, the encoder and the link, or only
  the MP4 boxes.
- **Very low frame rate with `resyncs` climbing** — the link genuinely cannot
  carry the current bitrate. Run `/bench`, then lower `/rate?kbit=`.
- **Very low frame rate with `resyncs` at zero** — not the link. Frames are not
  reaching the sender, or the connection is being dropped and re-made; watch the
  board's log for `viewer left after N frames`, which reports how long the
  connection actually lasted. Both of the bugs above presented this way.
- **Colours wrong** — ISP tuning, shared with every other example here. The CCM
  is an uncalibrated seed and the module is NoIR, so infrared contaminates all
  three channels.

## Traps inherited from the snapshot example

Both still apply and are written up in full in
[its README](../imx708_wifi_snapshot/README.md):

1. **The board asserts in `prvCreateIdleTasks` before `app_main`** if
   esp_hosted's SDIO mempool is left at its default queue sizes. Nothing in the
   log connects it to WiFi. Fixed here by `CONFIG_ESP_HOSTED_SDIO_*_Q_SIZE=10`.
2. **`CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y` fixes trap 1 and breaks bulk
   transfers.** Keep the transport's DMA buffers in internal RAM.

And one of this example's own: the `ISP_AWB_WINDOW_X_NUM/_Y_NUM=5` block in
`CMakeLists.txt` is required, between `project.cmake` and `project()`, or
autofocus silently reads unrelated heap memory.

To create a project from this example, run:

idf.py create-project-from-example "mushbraindave/esp_cam_sensor_imx=0.1.2:imx708_wifi_video"

or download archive (~50.46 KB)