gortmplib vs go2rtc vs FFmpeg: RTMP Push Source Comparison & Integrating with Chinese Live-Streaming Platforms
To push camera feeds from the MiBee NVR project to a domestic live-streaming platform, there are three candidates: call FFmpeg directly, use the pure-Go gortmplib, or use go2rtc. All three “do RTMP”, but their behaviour against Chinese live-streaming platforms differs wildly — some disconnect instantly, some after a few seconds, some are rock solid. This post tears all three apart at the source level, and walks through the pitfalls and fixes for integrating with FMS-compatible domestic platforms.
Every claim about the three libraries is from line-by-line source review (go2rtc master / gortmplib main / FFmpeg master), not documentation speculation.
First, get it working: FFmpeg already pushes to the live room
Before agonising over which library to use, get the pipeline working with the safest option — configure an RTMP target in MiBeeNVR’s “push output”, backed by an FFmpeg subprocess. The config screen looks like this:

Once configured, MiBeeNVR pushes on the desktop while the platform’s Live Companion receives — each running on its own:

And eventually the picture shows up in the mobile live room — the link works end to end:

So FFmpeg gets the job done. But FFmpeg is an external process — you have to install and manage its lifecycle separately, which is less than ideal for deployment and resource overhead. I’d rather have the entire push pipeline in pure Go — no external process, embeddable inside the NVR process for unified management. That’s what motivated the source-level comparison of the three libraries below (FFmpeg as the reference, plus gortmplib and go2rtc): which Go library can actually carry this pure-Go path through.
The three libraries’ positioning
First, separate the three contestants — they aren’t the same kind of thing:
| Dimension | FFmpeg | gortmplib | go2rtc |
|---|---|---|---|
| Role | media-processing Swiss army knife | pure RTMP client/server library | ultimate camera streaming app |
| Form | CLI / C library | Go library (importable) | standalone binary / Docker |
| RTMP’s place | one of 20+ protocols | the sole core | one of 20+ protocols |
| Code size | huge | push core ~1000 lines | RTMP core ~580 lines |
| Typical use | ffmpeg -i ... -f flv rtmp://... | embedded in your Go program | turnkey transcode/relay |
Simply: FFmpeg is “the all-rounder that can push”, gortmplib is “a library born for RTMP”, go2rtc is “a multi-protocol app”. Once you get the positioning, the differences below make sense.
RTMP handshake comparison
The first step of an RTMP connection is the handshake: C0/C1/C2 (client→server) and S0/S1/S2. There are simple and complex variants, differing by whether the 1536-byte C1 carries a digest:
| Feature | Simple | Complex |
|---|---|---|
| C1 content | time+version+1528 random bytes | same, but with HMAC-SHA256 digest |
| digest key | none | Genuine Adobe Flash Player 001 |
| Server check | only echoes C1→S2 | verifies C1 digest |
The three libraries’ handshake (source-verified):
go2rtc — minimal, and the function name is misspelled in source (clienHandshake, missing a t):
| |
All-zero C1, no digest, no verification of the server reply.
gortmplib — branches by version:
| |
Plain mode (v3) runs fillPlain() which only fills random bytes, no digest; only encrypted mode (v6/RTMPE) runs fill() to compute HMAC-SHA256.
FFmpeg — implements complex handshake, C1 carries a digest computed with rtmp_player_key via HMAC-SHA256.
Note: whether the handshake difference is a blocker depends on the backend. Some cloud FMS accept simple handshake; stricter backends (e.g. the the platform’s Live Companion in our real-world case below) enforce digest even at version=3 — we got bitten there first.
Client push timing: control messages are the crux
After the handshake comes the connect command, then a precise flow of protocol control messages. The FMS standard (domestic live-streaming platforms are all FMS-compatible) flows like this:
sequenceDiagram
participant C as Client
participant S as the platform FMS
C->>S: connect(app,flashVer,tcUrl)
S-->>C: WindowAckSize(2500000)
S-->>C: SetPeerBandwidth(2500000,2)
C->>S: WindowAckSize(2500000) reply
S-->>C: _result(Connect.Success)
C->>S: createStream / FCPublish / publish
S-->>C: onStatus(Publish.Start)
Note over C,S: ⏱️ 5s timer starts<br/>must ACK every 2.5MB received
C-->>S: ❌ no ACK → 5s timeout
S->>C: StreamReset(RST) disconnectThe key is WindowAcknowledgementSize: the server declares a 2.5MB window, and the client must reply with an Acknowledgement (type 3) for every 2.5MB received. No ACK within 5 seconds and the server RSTs. The three libraries diverge here:
| Control message | FFmpeg | gortmplib | go2rtc |
|---|---|---|---|
| Send WindowAckSize | ✅ | ✅ actively (2500000) | ❌ doesn’t |
| Handle received WindowAckSize | ✅ | ✅ | ❌ ignores |
| Send SetPeerBandwidth | ✅ | ✅ actively | ❌ doesn’t |
| Periodic Acknowledgement | ✅ every 1MB | ✅ auto callback | ❌ never |
go2rtc’s readResponse() is the root of it — it only recognises two message types:
| |
This is one typical failure mode for go2rtc on ACK-enforcing cloud FMS: handshake and push commands succeed, then it’s RST’d after a few seconds for never ACKing. But be clear: this is not the only failure point — in the case study below, the Live Companion has independent pitfalls at the handshake (digest) and frame layers, and its 74ms RST doesn’t match the 5-second ACK-timeout pattern. FFmpeg and gortmplib both do periodic ACK, so at least they avoid this one.
Push data writing mechanism
Beyond handshake and control messages, how data is written into the RTMP connection differs philosophically between the two Go libraries.
gortmplib — strongly-typed messages: each codec has a dedicated method, fields are strongly typed:
| |
go2rtc — FLV translation layer: treats RTMP as a transport for FLV; you feed FLV bytes, it translates to RTMP chunks:
| |
gortmplib gives protocol-level fine control; go2rtc is less code but sacrifices control. Each has its merits.
Codecs & multitrack support
This is gortmplib’s core selling point — it implements Enhanced RTMP v2, supporting new codecs and multitrack that standard RTMP can’t carry:
| Codec | gortmplib | go2rtc | FFmpeg |
|---|---|---|---|
| H.264/AVC | ✅ | ✅ | ✅ |
| H.265/HEVC | ✅ native | ❌ | ⚠️ needs config |
| AV1 | ✅ native | ❌ | ⚠️ |
| AAC/MP3/G.711 | ✅ | ✅ | ✅ |
| Opus/FLAC/AC-3 | ✅ native | ❌ | ✅ |
| Multi video/audio tracks | ✅ native | ❌ | ❌ |
To push H.265/AV1 or multi-angle/multi-language, gortmplib is the only option. Standard H.264+AAC works on all three.
Auth & encryption support
| Capability | gortmplib | go2rtc | FFmpeg |
|---|---|---|---|
| RTMPS (TLS) | ✅ auto SNI | registers scheme, no impl | ✅ |
| RTMPE encryption | ✅ DH+RC4 | ❌ | ❌ |
| Adobe auth (challenge-response) | ✅ two-stage HMAC-MD5 | ❌ | ❌ |
| URL query auth | ✅ | plaintext params only | ✅ |
For Wowza/strict FMS that need Adobe auth, gortmplib is the only contender.
Chunk protocol details
| Chunk feature | gortmplib | go2rtc | FFmpeg |
|---|---|---|---|
| Write chunk size | 65536 | 4096 | 4096 |
| Read chunk size initial | 128 | 128 (too small) | 4096 |
| Large chunk ID (>63) | ✅ supports | ❌ partial | ✅ |
| Concurrent write guard | ReadWriter | sync.Mutex manual | — |
| Video data CSID | 6 | 4 | 6 |
go2rtc’s rdPacketSize initial 128 is small; it updates after the server’s SetChunkSize, but the first round is inefficient, and it explicitly doesn’t support chunkID>63.
connect params & metadata
The connect command’s flashVer (source-verified, easy to trip on):
| Library | flashVer |
|---|---|
| FFmpeg | FMLE/3.0 (compatible; FMSc/1.0) |
| go2rtc | FMLE/3.0 (compatible; FMSc/1.0) |
| gortmplib | LNX 9,0,124,2 (Linux Flash Player 9) |
Some FMS check flashVer and may reject gortmplib’s LNX value — a prime suspect when gortmplib fails against domestic platforms.
Metadata (@setDataFrame/onMetaData): gortmplib’s onMetaData has only 4 fields (missing width/height/framerate), versus FFmpeg’s 15+. FMS uses width/height to init the decoder; missing fields can make the server refuse to initialize.
Integrating with Chinese platforms: problems & solutions
Domestic live-streaming platforms are essentially Adobe FMS-compatible implementations. They share a common set of requirements, and the three libraries trip differently. Layered below.
Problem 1: ACK timeout (go2rtc’s typical cloud-FMS pitfall)
Symptom: push commands all succeed, then the connection is RST’d after a few seconds; logs show “disconnect after second frame”.
Root cause: the platform requires the client to reply an Acknowledgement per 2.5MB received; no ACK within 5s → RST. go2rtc doesn’t implement this at all.
Fix:
- go2rtc: add byte counting in
readMessage(), send ACK (type 3) when the window is reached, and handle types 5/6. - gortmplib/FFmpeg: already implemented.
Problem 2: handshake digest (enforced or not depends on the backend)
Symptom: some blame every push failure on a missing digest; others insist digest is irrelevant.
Truth: it depends on the backend. Some cloud FMS accept simple handshake; stricter ones — our real-world the platform’s Live Companion enforces digest at version=3, and only after we added it did the handshake pass (see the case study). Don’t generalize; confirm with a capture whether the handshake actually fails.
Fix: consider digest only if the handshake fails; skip it if the handshake passes.
Problem 3: flashVer rejected
Symptom: after connect, the server doesn’t reply Connect.Success or outright refuses.
Root cause: some FMS check flashVer; non-FMLE formats may be rejected. gortmplib defaults to LNX 9,0,124,2.
Fix: change flashVer to FMLE/3.0 (compatible; FMSc/1.0) and retry.
Problem 4: auth params (wsSecret/wsTime)
Symptom: the platform push URLs look like rtmp://<platform-push-domain>/live/{key}?wsSecret=xxx&wsTime=xxx; auth info is in the URL query.
Root cause: the query must be preserved verbatim in tcUrl passed to connect; dropping or mis-parsing it fails auth.
Fix: confirm the URL parsing keeps the query; go2rtc appends ?wsSecret=... to the stream by default and gortmplib’s splitPath keeps RawQuery, so it’s usually fine — but verify.
Solution outline: layered triage
flowchart TD
A[Push fails] --> B{Handshake passed?}
B -->|no| PARAM[check digest/network<br/>flashVer/auth<br/>metadata/CSID]
B -->|yes| D{connect OK?}
D -->|no| PARAM
D -->|yes| F{RST soon after publish?}
F -->|yes| G[ACK timeout - add control msgs]
F -->|no| PARAM
style A fill:#2196F3,color:#fff
style B fill:#9C27B0,color:#fff
style D fill:#9C27B0,color:#fff
style F fill:#9C27B0,color:#fff
style PARAM fill:#FF9800,color:#fff
style G fill:#f44336,color:#fffDiagram: triage by “handshake → connect → how soon after publish it disconnects” locates whether it’s ACK, flashVer, or digest. The final word is a packet capture, not a guess.
Selection guide
Pick FFmpeg: fastest path to domestic platforms, don’t mind an external-process dependency. Its control messages and compatibility are the most complete — it’s what OBS/Streamlabs use underneath.
Pick gortmplib: to push H.265/AV1/multitrack, to talk to servers needing Adobe auth or RTMPS, to embed RTMP in a Go program with fine control. Note its default flashVer is LNX and you must add the handshake digest yourself.
Pick go2rtc: for transcode/relay between RTMP/RTSP/WebRTC/HLS, turnkey multi-protocol fan-out, when codecs stay within H.264+AAC. But you must add the control messages before it’ll push to strict domestic backends, otherwise it’s RST’d by ACK timeout.
Debug method: settle it with a capture
Source analysis points the way; tcpdump nails it:
| |
Three steps separate ACK timeout, flashVer rejection, or handshake digest.
Case study: integrating with a Live Companion (solved)
A real debugging record from our project. Fully solved (2026-07-03) — six protocol layers cracked one by one, pure-Go relay push successful. Included as an empirical counterpoint to the theory above, and as a reminder: source analysis gives direction, but “where this backend actually bites” is only settled by a byte-level dump.
Target architecture: Live Companion ≠ the platform cloud
An easy mistake: we’re not talking to the the platform cloud (<platform-push-domain>), but to the the platform’s Live Companion installed on the streamer’s PC — a local RTMP receiver + transcoder that decodes → re-encodes → pushes to the the platform cloud:
| |
The local push URL looks like rtmp://<companion-LAN-IP>:1955/live/<stream-key>, with the stream key generated dynamically by the companion. The companion itself implements an Adobe FMS-compatible RTMP server, behaving differently from the the platform cloud — which directly shapes the conclusions below.
Empirical result overturns “digest is irrelevant”
From source we inferred “some cloud FMS accept simple handshake”. Reality disagreed: the Live Companion enforces C1 digest verification at version=3. Once we added complex-handshake digest to our relay (HMAC-SHA256 with Genuine Adobe Flash Player 001 written into C1), the handshake passed immediately and we received NetStream.Publish.Start.
The lesson is direct: whether a backend wants digest is only settled by testing. The Live Companion is stricter than a typical cloud FMS.
Six-layer problem model
Break the whole push chain into six layers and locate layer by layer:
| Layer | Problem | FFmpeg | gortmplib | go2rtc | Our status |
|---|---|---|---|---|---|
| L1 handshake digest | C1 HMAC-SHA256 | ✅ | ❌ fillPlain skips | ❌ none | ✅ added |
| L2 chunk size | write chunk size | 128 | 65536 | 4096 | ✅ set 4096 |
| L3 chunk header | Type 0/1/2/3 choice | Type 0 | Type 1/2/3 optimised | Type 0 | ✅ forced Type 0 |
| L4 background read | consume server control msgs | built-in | none | goroutine | ✅ added goroutine |
| L5 onMetaData fields | width/height/framerate | complete | only 4 fields | not sent | ✅ buildFullMetadata() |
| L6 streamID endianness ⭐ | MessageStreamID byte order | big-endian | big-endian | — | ✅ fixed writeRawMessage |
The six layers we cracked
- L1 digest: custom handshake function, HMAC-SHA256 at scheme-0 position written into C1; handshake passes.
- L2 chunk size: send
SetChunkSize=4096beforeconnect(align with OBS), fewer continuation chunks. - L3 chunk header: override the write path so video/audio AUs always use Type 0, bypassing gortmplib’s Type 1/2/3 (the companion can’t parse them).
- L4 background read: a goroutine after
publishcontinuously reads server messages, preventing the receive buffer from filling and triggering RST. - L5 onMetaData fields:
buildFullMetadata()parses width/height/fps from SPS, constructs a complete FFmpeg-style metadata object (duration/width/height/framerate/videocodecid/audiosamplerate…), replacing gortmplib’s sparse 4-field version. - L6 streamID endianness:
writeRawMessage()changed streamID from little-endian to big-endian, matching gortmplib’schunk0.Marshaland FFmpeg’s practice.
Root cause: streamID endianness (L6)
After fixing L1–L4, handshake, connect, publish, Publish.Start, onMetaData, and the AVC seq header all succeed, but ~74–252ms after the first video frame is written, the companion RSTs. Fixing L5 onMetaData alone still failed — only discovering L6 cracked it.
Note the timing: 74–252ms is far faster than ACK timeout (5s / 2.5MB). So the “ACK timeout” discussed earlier doesn’t fit here — the companion rejects at the frame level, not at the connection-maintenance timeout.
L6’s subtlety is extreme:
- The RTMP spec says MessageStreamID is little-endian — so using little-endian in code “looks correct”
- gortmplib uses big-endian — contrary to spec, but it works (because the companion also uses big-endian)
- It only manifests when mixing two write paths — seq header goes through gortmplib’s standard writer (big-endian), onMetaData/video frames go through our
writeRawMessage(little-endian); the inconsistency triggers RST
Using NVR_RTMP_DEBUG_DUMP to capture bytes and parse chunk headers one by one:
| |
The fix simply changes streamID byte order in writeRawMessage from little-endian to big-endian:
| |
We initially listed MessageStreamID as the top suspect, but only noticed the value difference (0x1000000 vs the createStream reply) — without realising it was an endianness issue. That one missing step cost an extra day.
Current status: pure-Go relay works
Final result: the native Go relay pushing directly to the Live Companion works — TCP connection stays established, continuous streaming for 45+ seconds with no broken pipe, no reconnects. FFmpeg subprocess is no longer required (retained as a fallback config option use_ffmpeg, but the companion doesn’t need it).
Debugging lessons
Three key takeaways:
- FFmpeg comparison is the gold standard: FFmpeg manual push succeeds on the NVR while Go relay fails — this is the decisive evidence confirming “Go protocol-layer problem”. Without this comparison, you can’t distinguish protocol issues from environment issues.
- In-process byte dump is the only way to find root cause at this level: Endianness details can’t be reliably guessed.
NVR_RTMP_DEBUG_DUMPlet us see the exact bytes sent by eachWrite()call, and parsing chunk headers one by one revealed the streamID endianness mismatch. When debugging binary protocols, add the dump first, then analyse. - Rule out the environment layer first: We were briefly misled by an “invalid stream key” popup, suspecting IP banning/account issues. Pushing from OBS on the local PC succeeded, ruling out the companion/account layer and focusing attention on the protocol layer.
Full comparison table
| Dimension | FFmpeg | gortmplib | go2rtc | Domestic requirement |
|---|---|---|---|---|
| Handshake digest | ✅ complex | ❌ simple (v3) | ❌ all-zero simple | backend-dependent |
| WindowAckSize | ✅ | ✅ sends | ❌ doesn’t | required (cloud FMS) |
| Acknowledgement | ✅ periodic | ✅ periodic | ❌ never | required (cloud FMS) |
| SetPeerBandwidth | ✅ | ✅ sends | ❌ doesn’t | required (cloud FMS) |
| flashVer | FMLE | LNX | FMLE | prefers FMLE |
| releaseStream/FCPublish | ✅ | ✅ | ✅ merged | optional |
| Chunk Size | 4096 | 65536 | 4096 | no hard req |
| H.265/AV1/multitrack | ⚠️ | ✅ | ❌ | N/A |
| RTMPS/RTMPE/Adobe auth | partial | ✅ all | ❌ | varies |
| Domestic FMS push | ✅ stable | ✅ needs digest + flashVer + onMetaData + streamID | ❌ needs digest + control msgs | — |
Summary
The three libraries each have their place: FFmpeg has the broadest compatibility and is the safest bet for domestic platforms; gortmplib has the most complete protocol implementation and is the only choice for new codecs and fine control, but watch its default flashVer, add the handshake digest yourself, and fix onMetaData fields and streamID endianness; go2rtc is the most convenient for multi-protocol relay, but you must add both the handshake digest and control messages before it’ll push to strict domestic backends.
The pitfalls of integrating with Chinese platforms are layered: handshake (digest — enforced or not depends on the backend; the Live Companion wants it, some cloud FMS don’t), connect (flashVer / auth params), connection maintenance (ACK timeout — mainly bites go2rtc-style implementations that never ACK), metadata (onMetaData field completeness), and even byte order (streamID big-endian vs little-endian — the ultimate root cause in our project). So trust no one-size-fits-all conclusion — triage by “handshake → connect → how soon after publish it disconnects”, pair it with a byte-level dump comparison, and the pit reveals itself. Theory gives direction; bytes settle it.