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:

MiBeeNVR push-output config: an RTMP target pointing at a domestic live-streaming platform

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

Desktop: MiBeeNVR pushing + the Live Companion receiving

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

Mobile live room: the pushed stream is live

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:

DimensionFFmpeggortmplibgo2rtc
Rolemedia-processing Swiss army knifepure RTMP client/server libraryultimate camera streaming app
FormCLI / C libraryGo library (importable)standalone binary / Docker
RTMP’s placeone of 20+ protocolsthe sole coreone of 20+ protocols
Code sizehugepush core ~1000 linesRTMP core ~580 lines
Typical useffmpeg -i ... -f flv rtmp://...embedded in your Go programturnkey 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:

FeatureSimpleComplex
C1 contenttime+version+1528 random bytessame, but with HMAC-SHA256 digest
digest keynoneGenuine Adobe Flash Player 001
Server checkonly echoes C1→S2verifies C1 digest

The three libraries’ handshake (source-verified):

go2rtc — minimal, and the function name is misspelled in source (clienHandshake, missing a t):

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// go2rtc/pkg/rtmp/client.go
func (c *Conn) clienHandshake() error {
    b := make([]byte, 1+1536)  // all zeros
    b[0] = 0x03                 // C0=3, C1 all zeros
    c.conn.Write(b)             // send C0+C1
    io.ReadFull(c.rd, b)        // read S0+S1
    c.conn.Write(b[1:])         // echo S1 back as C2
    io.ReadFull(c.rd, b[1:])    // read S2, discard
    return nil
}

All-zero C1, no digest, no verification of the server reply.

gortmplib — branches by version:

go
1
2
3
4
5
6
7
// gortmplib/pkg/handshake/handshake.go
func DoClient(rw io.ReadWriter, encrypted bool, strict bool) ([]byte, []byte, error) {
    if encrypted {
        return doClientEncrypted(rw)  // version=6, with digest
    }
    return nil, nil, doClientPlain(rw, strict)  // version=3, no digest
}

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:

mermaid
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) disconnect

The 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 messageFFmpeggortmplibgo2rtc
Send WindowAckSize✅ actively (2500000)❌ doesn’t
Handle received WindowAckSize❌ ignores
Send SetPeerBandwidth✅ actively❌ doesn’t
Periodic Acknowledgement✅ every 1MB✅ auto callbacknever

go2rtc’s readResponse() is the root of it — it only recognises two message types:

go
1
2
3
4
5
6
// go2rtc/pkg/rtmp/conn.go
switch msgType {
case TypeSetPacketSize:   // type 1
case TypeCommand:         // type 20
// type 3(Ack), type 5(WindowAck), type 6(PeerBW) all dropped
}

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:

go
1
2
3
4
5
6
7
8
// gortmplib/writer.go
func (w *Writer) WriteH264(track *Track, pts, dts time.Duration, au [][]byte) error {
    avcc, _ := h264.AVCC(au).Marshal()
    return w.Conn.Write(&message.Video{
        Codec: CodecH264, IsKeyFrame: h264.IsRandomAccess(au),
        Type: VideoTypeAU, AU: avcc, DTS: dts, PTSDelta: pts - dts,
    })
}

go2rtc — FLV translation layer: treats RTMP as a transport for FLV; you feed FLV bytes, it translates to RTMP chunks:

go
1
2
3
4
5
// go2rtc/pkg/rtmp/flv.go
func (c *Conn) Write(p []byte) (int, error) {
    // parse FLV tag → write RTMP chunk
    c.writeMessage(4, tagType, timeMS, payload)
}

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:

Codecgortmplibgo2rtcFFmpeg
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

Capabilitygortmplibgo2rtcFFmpeg
RTMPS (TLS)✅ auto SNIregisters scheme, no impl
RTMPE encryption✅ DH+RC4
Adobe auth (challenge-response)✅ two-stage HMAC-MD5
URL query authplaintext params only

For Wowza/strict FMS that need Adobe auth, gortmplib is the only contender.

Chunk protocol details

Chunk featuregortmplibgo2rtcFFmpeg
Write chunk size6553640964096
Read chunk size initial128128 (too small)4096
Large chunk ID (>63)✅ supports❌ partial
Concurrent write guardReadWritersync.Mutex manual
Video data CSID646

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):

LibraryflashVer
FFmpegFMLE/3.0 (compatible; FMSc/1.0)
go2rtcFMLE/3.0 (compatible; FMSc/1.0)
gortmplibLNX 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

mermaid
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:#fff

Diagram: 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:

bash
1
2
3
4
5
sudo tcpdump -i eth0 'tcp port 1935' -w live-push.pcap
# Decode as RTMP in Wireshark, check three things:
# 1. After C0+C1, did the server reply S0+S1+S2? (yes = handshake passed)
# 2. Did the client send any Acknowledgement (type 3)? (none = confirms ACK timeout)
# 3. At which step does RST happen, and how long after publish?

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:

text
1
2
NVR(Go) ──RTMP(LAN)──> the platform's Live Companion(Win) ──RTMP(internet)──> the platform cloud FMS
                       local receive + transcode                    final landing

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:

LayerProblemFFmpeggortmplibgo2rtcOur status
L1 handshake digestC1 HMAC-SHA256❌ fillPlain skips❌ none✅ added
L2 chunk sizewrite chunk size128655364096✅ set 4096
L3 chunk headerType 0/1/2/3 choiceType 0Type 1/2/3 optimisedType 0✅ forced Type 0
L4 background readconsume server control msgsbuilt-innonegoroutine✅ added goroutine
L5 video frame formatunknownOKunknownOKunsolved

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=4096 before connect (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 publish continuously 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):

  1. MessageStreamID: gortmplib hardcodes 0x1000000; go2rtc uses the createStream reply value.
  2. Sparse onMetaData: gortmplib has 4 fields; FFmpeg has 15+ (incl. width/height/framerate).
  3. Missing audio track: the source camera has no audio, so the relay sends none; the companion may require at least one audio track.
  4. AVCC byte-level differences: gortmplib’s AVCC packaging may differ slightly from FFmpeg’s.
  5. Companion-side frame validation: it may validate SPS/PPS/profile on the first frame and RST on mismatch.
  6. 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

DimensionFFmpeggortmplibgo2rtcDomestic requirement
Handshake digest✅ complex❌ simple (v3)❌ all-zero simplebackend-dependent
WindowAckSize✅ sends❌ doesn’trequired (cloud FMS)
Acknowledgement✅ periodic✅ periodic❌ neverrequired (cloud FMS)
SetPeerBandwidth✅ sends❌ doesn’trequired (cloud FMS)
flashVerFMLELNXFMLEprefers FMLE
releaseStream/FCPublish✅ mergedoptional
Chunk Size4096655364096no hard req
H.265/AV1/multitrack⚠️N/A
RTMPS/RTMPE/Adobe authpartial✅ allvaries
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.