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 (the platform/Huya/Bilibili 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 platforms — one major domestic platform, Huya, Bilibili, Zhanqi, Longzhu — 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| C[check digest/network]
B -->|yes| D{connect OK?}
D -->|no| E[check flashVer/auth params]
D -->|yes| F{RST soon after publish?}
F -->|yes| G[ACK timeout - add control msgs]
F -->|no| H[check metadata/CSID]
style A fill:#2196F3,color:#fff
style G fill:#f44336,color:#fff
style E fill:#FF9800,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 the the platform’s Live Companion (in progress)
A real debugging record from our project. Not fully solved — the handshake layer is cracked, but video-frame transfer still fails. 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 capture.
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.
Five-layer problem model
Break the whole push chain into five 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 video frame format | unknown | OK | unknown | OK | ❌ unsolved |
The four 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 + Type 3, bypassing gortmplib’s Type 1/2/3 (suspected 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.
Still unsolved: RST after the first frame
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.
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. This means ACK timeout is only one failure mode for go2rtc on cloud FMS, not the companion’s blocker.
Already ruled out: digest, chunk header type, chunk size, keyframe/P-frame, background read — none of them. Current suspects (all unconfirmed):
- MessageStreamID: gortmplib hardcodes
0x1000000; go2rtc uses the createStream reply value. - Sparse onMetaData: gortmplib has 4 fields; FFmpeg has 15+ (incl. width/height/framerate).
- Missing audio track: the source camera has no audio, so the relay sends none; the companion may require at least one audio track.
- AVCC byte-level differences: gortmplib’s AVCC packaging may differ slightly from FFmpeg’s.
- Companion-side frame validation: it may validate SPS/PPS/profile on the first frame and RST on mismatch.
- Lingering rate-limit: rapid reconnects during debugging (every 2s, hundreds of times) may have triggered a persistent IP blacklist — even FFmpeg got RST’d then; it recovered only after restarting the companion and waiting.
Next steps & current status
The most valuable next step is a byte-by-byte pcap comparison: under identical conditions, capture one successful FFmpeg push and one failing Go-relay push, and use Wireshark’s RTMP dissector to find the first divergence.
Current status (honest): the native Go relay pushing directly to the Live Companion doesn’t work yet; for now we use an FFmpeg subprocess as the the platform push path (there’s a toggle in the project), and that’s stable. Theory helped us rule out the handshake layer, but the video-frame issue still needs a capture to crack. We’ll update this post once it’s located.
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 fix, frame layer TBD | ❌ 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 and add the handshake digest yourself; 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), and even frame format (where our project is stuck, unsolved). So trust no one-size-fits-all conclusion — triage by “handshake → connect → how soon after publish it disconnects”, pair it with a byte-by-byte tcpdump comparison, and the pit reveals itself. Theory gives direction; the capture settles it.