Introduces the subsystem layer that sits alongside api.API and wires
the M1 core/webrtc primitives into the per-process restream lifecycle.
app/webrtc/subsystem.go:
- Subsystem struct holding the global WebRTC config, core PeerFactory,
per-process stream map, and logger
- New(config.DataWebRTC, logger) constructor
- Enabled(), Hooks(), Close(), lookup() methods
app/webrtc/lifecycle.go:
- onProcessStart: allocates an adjacent UDP port pair, binds two
Pion Sources (video on V, audio on V+1), registers them under the
process id, and returns the two RTP output legs to append to the
FFmpeg command.
- onProcessStop: tears down the pair.
- allocAdjacentPair: retries up to 10 times to find a free (V, V+1)
pair since the kernel's ephemeral picker can hand us an odd port.
- splitRTPLegs: converts BuildArgs' flat []string into two ConfigIO
entries by splitting on the second -map token.
core/webrtc/peer.go + forward.go:
- Adds PeerFactory.CreatePeerFromSources for the M2 two-source
forwarding mode (video and audio on separate UDP ports, no
payload-type sniffing). Leaves CreatePeer intact for the M1 PoC.
- Adds forwardRTPSplit companion goroutine.
config/data.go:
- Promote anonymous WebRTC struct to named type DataWebRTC so
app/webrtc can accept it by value.
191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package webrtc
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
|
)
|
|
|
|
// Default payload types. These match the values the M1 PoC / M2
|
|
// forwarder expects (H.264 = 102, Opus = 111). Operators can override
|
|
// per-process via the restream Config.
|
|
const (
|
|
defaultVideoPT = 102
|
|
defaultAudioPT = 111
|
|
)
|
|
|
|
// allocAttempts is the maximum number of times onProcessStart will
|
|
// retry port allocation to find two adjacent free loopback UDP ports.
|
|
// The kernel sometimes hands us an odd port for video, making V+1
|
|
// unavailable — in practice 2-3 retries is plenty.
|
|
const allocAttempts = 10
|
|
|
|
// onProcessStart is registered as the restream ProcessStartHook. It
|
|
// fires with the restream write lock held, just before FFmpeg Start.
|
|
//
|
|
// When the per-process WebRTC config is disabled, it returns (nil, nil)
|
|
// — FFmpeg starts normally without any extra output legs. When enabled
|
|
// it:
|
|
//
|
|
// 1. Allocates two adjacent loopback UDP ports (video on V, audio on V+1).
|
|
// 2. Binds Pion Sources on those ports and registers the pair under
|
|
// the process ID.
|
|
// 3. Builds the two RTP ConfigIO output legs via BuildArgs and returns
|
|
// them to the restream manager, which appends them to cfg.Output
|
|
// and rebuilds the FFmpeg command.
|
|
//
|
|
// Any error aborts the process start. On partial allocation failure,
|
|
// all allocated resources are cleaned up before returning.
|
|
func (s *Subsystem) onProcessStart(id string, cfg *appcfg.Config) ([]appcfg.ConfigIO, error) {
|
|
if cfg == nil || !cfg.WebRTC.Enabled {
|
|
return nil, nil
|
|
}
|
|
|
|
// Normalize PTs — zero values mean "use defaults".
|
|
wcfg := cfg.WebRTC
|
|
if wcfg.VideoPT == 0 {
|
|
wcfg.VideoPT = defaultVideoPT
|
|
}
|
|
if wcfg.AudioPT == 0 {
|
|
wcfg.AudioPT = defaultAudioPT
|
|
}
|
|
|
|
// Refuse to re-register — the restream manager should never
|
|
// double-start a process but defensive check avoids a silent
|
|
// Source leak if it does.
|
|
s.mu.Lock()
|
|
if _, exists := s.streams[id]; exists {
|
|
s.mu.Unlock()
|
|
return nil, fmt.Errorf("webrtc: process %q already has an active stream", id)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
videoPort, videoSrc, audioSrc, err := s.allocAdjacentPair(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Start the UDP readers so they're draining packets the moment
|
|
// FFmpeg comes online.
|
|
videoSrc.Start()
|
|
audioSrc.Start()
|
|
|
|
s.mu.Lock()
|
|
s.streams[id] = &processStream{id: id, video: videoSrc, audio: audioSrc}
|
|
s.mu.Unlock()
|
|
|
|
s.logger.WithFields(map[string]interface{}{
|
|
"id": id,
|
|
"video_port": videoPort,
|
|
"audio_port": videoPort + 1,
|
|
"video_pt": wcfg.VideoPT,
|
|
"audio_pt": wcfg.AudioPT,
|
|
}).Info().Log("WebRTC egress registered for process")
|
|
|
|
args := BuildArgs(wcfg, videoPort)
|
|
return splitRTPLegs(args), nil
|
|
}
|
|
|
|
// onProcessStop is registered as the restream ProcessStopHook. It
|
|
// fires with the restream write lock held, just after FFmpeg has been
|
|
// stopped. It tears down the per-process Sources (which closes their
|
|
// sockets and hangs up any subscribed peers).
|
|
func (s *Subsystem) onProcessStop(id string) {
|
|
s.mu.Lock()
|
|
st, ok := s.streams[id]
|
|
if ok {
|
|
delete(s.streams, id)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if !ok {
|
|
return
|
|
}
|
|
if st.video != nil {
|
|
_ = st.video.Close()
|
|
}
|
|
if st.audio != nil {
|
|
_ = st.audio.Close()
|
|
}
|
|
s.logger.WithField("id", id).Info().Log("WebRTC egress torn down for process")
|
|
}
|
|
|
|
// allocAdjacentPair finds a pair of free loopback UDP ports (V, V+1)
|
|
// and binds a Source to each. It retries up to allocAttempts times
|
|
// because the kernel's ephemeral picker may hand us a port whose +1
|
|
// neighbor is already taken. Caller owns the returned Sources; on
|
|
// error all partial allocations are cleaned up.
|
|
func (s *Subsystem) allocAdjacentPair(id string) (int, *corewebrtc.Source, *corewebrtc.Source, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < allocAttempts; attempt++ {
|
|
port, err := Alloc()
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
videoSrc, err := corewebrtc.NewSourceOn(id, "127.0.0.1", port)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
audioSrc, err := corewebrtc.NewSourceOn(id+":audio", "127.0.0.1", port+1)
|
|
if err != nil {
|
|
_ = videoSrc.Close()
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return port, videoSrc, audioSrc, nil
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("unknown allocation failure")
|
|
}
|
|
return 0, nil, nil, fmt.Errorf("webrtc: allocate adjacent UDP port pair after %d attempts: %w", allocAttempts, lastErr)
|
|
}
|
|
|
|
// splitRTPLegs converts the flat BuildArgs output into two ConfigIO
|
|
// entries — one per RTP output leg. It splits on the second "-map"
|
|
// token, which marks the audio leg's start (see ffmpeg_args_test.go).
|
|
// The Address of each ConfigIO is the last argument (the udp:// URL);
|
|
// everything preceding it forms that output's Options.
|
|
func splitRTPLegs(args []string) []appcfg.ConfigIO {
|
|
// Find the two -map indices.
|
|
mapIdx := []int{}
|
|
for i, a := range args {
|
|
if a == "-map" {
|
|
mapIdx = append(mapIdx, i)
|
|
}
|
|
}
|
|
if len(mapIdx) != 2 {
|
|
// BuildArgs always emits exactly 2 -maps; a different count
|
|
// means an upstream bug. Return a single leg covering
|
|
// everything to avoid silent truncation.
|
|
return []appcfg.ConfigIO{toLeg(args)}
|
|
}
|
|
|
|
videoTokens := args[mapIdx[0]:mapIdx[1]]
|
|
audioTokens := args[mapIdx[1]:]
|
|
|
|
return []appcfg.ConfigIO{
|
|
toLeg(videoTokens),
|
|
toLeg(audioTokens),
|
|
}
|
|
}
|
|
|
|
// toLeg splits a contiguous RTP-output token slice into a ConfigIO:
|
|
// the trailing token is the udp:// Address; everything before is the
|
|
// Options slice.
|
|
func toLeg(tokens []string) appcfg.ConfigIO {
|
|
if len(tokens) == 0 {
|
|
return appcfg.ConfigIO{}
|
|
}
|
|
addr := tokens[len(tokens)-1]
|
|
opts := make([]string, len(tokens)-1)
|
|
copy(opts, tokens[:len(tokens)-1])
|
|
return appcfg.ConfigIO{
|
|
ID: "webrtc",
|
|
Address: addr,
|
|
Options: opts,
|
|
}
|
|
}
|