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 (domestic live-streaming platforms 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 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

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

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.

Six-layer problem model

Break the whole push chain into six 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 onMetaData fieldswidth/height/frameratecompleteonly 4 fieldsnot sent✅ buildFullMetadata()
L6 streamID endianness ⭐MessageStreamID byte orderbig-endianbig-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=4096 before connect (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 publish continuously 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’s chunk0.Marshal and 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:

  1. The RTMP spec says MessageStreamID is little-endian — so using little-endian in code “looks correct”
  2. gortmplib uses big-endian — contrary to spec, but it works (because the companion also uses big-endian)
  3. 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:

text
1
2
3
4
5
6
7
8
9
Before fix:
#9 onMetaData    (writeRawMessage): streamID bytes = 00 00 00 01  ← little-endian ❌
#10 seq header   (mrw.Write):       streamID bytes = 01 00 00 00  ← big-endian
→ mismatch → companion RSTs

After fix:
#9 onMetaData    (writeRawMessage): streamID bytes = 01 00 00 00  ← big-endian ✅
#10 seq header   (mrw.Write):       streamID bytes = 01 00 00 00  ← big-endian
→ consistent → companion accepts, stream continues

The fix simply changes streamID byte order in writeRawMessage from little-endian to big-endian:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Before (little-endian, wrong):
buf[8]  = byte(c.streamID)        // = 0x00
buf[9]  = byte(c.streamID >> 8)
buf[10] = byte(c.streamID >> 16)
buf[11] = byte(c.streamID >> 24)  // = 0x01

// After (big-endian, correct, matches gortmplib chunk0.Marshal):
buf[8]  = byte(c.streamID >> 24)  // = 0x01
buf[9]  = byte(c.streamID >> 16)
buf[10] = byte(c.streamID >> 8)
buf[11] = byte(c.streamID)        // = 0x00

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:

  1. 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.
  2. 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_DUMP let us see the exact bytes sent by each Write() call, and parsing chunk headers one by one revealed the streamID endianness mismatch. When debugging binary protocols, add the dump first, then analyse.
  3. 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

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 + 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.