fix(webrtc): make WebRTC FFmpeg stream maps configurable (closes #2)
Some checks failed
tests / build (push) Failing after 2s
tests / build (pull_request) Failing after 1s

BuildArgs hardcoded -map 0✌️0 / -map 0🅰️0 for the two RTP legs.
Correct for production RTMP/SRT publishers (single combined input),
but breaks any process whose audio lives on a different input index
— multi-input lavfi test scaffolds, multi-camera pipelines, SDI +
file-audio mixes, etc.

Adds VideoMap and AudioMap fields to ConfigWebRTC (and the API DTO),
defaulting to the prior literals so existing deployments are
unaffected. BuildArgs reads them.

Tests:
- TestBuildArgs_DefaultMaps locks the empty-string default behavior
- TestBuildArgs_CustomMaps drives the multi-input override path
- TestProcessConfigWebRTCMapsRoundtrip extends the DTO roundtrip

Closes #2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-03 12:10:51 +00:00
parent 0417aff3b1
commit 49677fbd3d
5 changed files with 104 additions and 10 deletions

View file

@ -32,7 +32,16 @@ func BuildArgs(cfg appcfg.ConfigWebRTC, videoPort int) []string {
acopy = []string{"-c:a", "libopus", "-b:a", "96k"} acopy = []string{"-c:a", "libopus", "-b:a", "96k"}
} }
args := []string{"-map", "0:v:0"} videoMap := cfg.VideoMap
if videoMap == "" {
videoMap = "0:v:0"
}
audioMap := cfg.AudioMap
if audioMap == "" {
audioMap = "0:a:0"
}
args := []string{"-map", videoMap}
args = append(args, vcopy...) args = append(args, vcopy...)
args = append(args, args = append(args,
"-payload_type", fmt.Sprint(cfg.VideoPT), "-payload_type", fmt.Sprint(cfg.VideoPT),
@ -40,7 +49,7 @@ func BuildArgs(cfg appcfg.ConfigWebRTC, videoPort int) []string {
fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort), fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort),
) )
args = append(args, "-map", "0:a:0") args = append(args, "-map", audioMap)
args = append(args, acopy...) args = append(args, acopy...)
args = append(args, args = append(args,
"-payload_type", fmt.Sprint(cfg.AudioPT), "-payload_type", fmt.Sprint(cfg.AudioPT),

View file

@ -87,3 +87,46 @@ func any(haystack []string, prefix string) bool {
} }
return false return false
} }
// TestBuildArgs_DefaultMaps confirms 0:v:0 / 0:a:0 are emitted when
// VideoMap / AudioMap are empty (regression on the fix for issue #2 —
// the prior version had these as hardcoded literals; if VideoMap is
// ever empty unexpectedly, BuildArgs must still produce a working
// command line).
func TestBuildArgs_DefaultMaps(t *testing.T) {
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}
got := BuildArgs(cfg, 50000)
if !contains(got, "-map", "0:v:0") {
t.Fatalf("expected default video map 0:v:0, got %v", got)
}
if !contains(got, "-map", "0:a:0") {
t.Fatalf("expected default audio map 0:a:0, got %v", got)
}
}
// TestBuildArgs_CustomMaps drives the issue-#2 fix: when the user
// configures a multi-input pipeline (audio on input #1, etc.), the
// emitted -map values must follow the user's choice rather than the
// "0:v:0"/"0:a:0" assumption.
func TestBuildArgs_CustomMaps(t *testing.T) {
cfg := appcfg.ConfigWebRTC{
Enabled: true,
VideoPT: 102,
AudioPT: 111,
VideoMap: "0:v:1",
AudioMap: "1:a:0",
}
got := BuildArgs(cfg, 50000)
if !contains(got, "-map", "0:v:1") {
t.Fatalf("expected custom video map 0:v:1, got %v", got)
}
if !contains(got, "-map", "1:a:0") {
t.Fatalf("expected custom audio map 1:a:0, got %v", got)
}
// The default literals should NOT appear when overridden.
for _, opt := range got {
if opt == "0:v:0" || opt == "0:a:0" {
t.Errorf("expected no default maps in output, found %q in %v", opt, got)
}
}
}

View file

@ -44,10 +44,12 @@ type ProcessConfigLimits struct {
// ProcessConfig represents the configuration of an ffmpeg process // ProcessConfig represents the configuration of an ffmpeg process
type ProcessConfigWebRTC struct { type ProcessConfigWebRTC struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
VideoPT uint8 `json:"video_pt,omitempty"` VideoPT uint8 `json:"video_pt,omitempty"`
AudioPT uint8 `json:"audio_pt,omitempty"` AudioPT uint8 `json:"audio_pt,omitempty"`
ForceTranscode bool `json:"force_transcode,omitempty"` ForceTranscode bool `json:"force_transcode,omitempty"`
VideoMap string `json:"video_map,omitempty"`
AudioMap string `json:"audio_map,omitempty"`
} }
type ProcessConfig struct { type ProcessConfig struct {
@ -83,6 +85,8 @@ func (cfg *ProcessConfig) Marshal() *app.Config {
VideoPT: cfg.WebRTC.VideoPT, VideoPT: cfg.WebRTC.VideoPT,
AudioPT: cfg.WebRTC.AudioPT, AudioPT: cfg.WebRTC.AudioPT,
ForceTranscode: cfg.WebRTC.ForceTranscode, ForceTranscode: cfg.WebRTC.ForceTranscode,
VideoMap: cfg.WebRTC.VideoMap,
AudioMap: cfg.WebRTC.AudioMap,
}, },
} }
@ -168,6 +172,8 @@ func (cfg *ProcessConfig) Unmarshal(c *app.Config) {
cfg.WebRTC.VideoPT = c.WebRTC.VideoPT cfg.WebRTC.VideoPT = c.WebRTC.VideoPT
cfg.WebRTC.AudioPT = c.WebRTC.AudioPT cfg.WebRTC.AudioPT = c.WebRTC.AudioPT
cfg.WebRTC.ForceTranscode = c.WebRTC.ForceTranscode cfg.WebRTC.ForceTranscode = c.WebRTC.ForceTranscode
cfg.WebRTC.VideoMap = c.WebRTC.VideoMap
cfg.WebRTC.AudioMap = c.WebRTC.AudioMap
cfg.Options = make([]string, len(c.Options)) cfg.Options = make([]string, len(c.Options))
copy(cfg.Options, c.Options) copy(cfg.Options, c.Options)

View file

@ -79,3 +79,31 @@ func TestProcessConfigWebRTCDefaults(t *testing.T) {
t.Fatalf("default should be disabled, got %+v", cfg.WebRTC) t.Fatalf("default should be disabled, got %+v", cfg.WebRTC)
} }
} }
// TestProcessConfigWebRTCMapsRoundtrip extends the WebRTC DTO
// roundtrip with the issue-#2 VideoMap/AudioMap fields so the
// regression doesn't repeat: a multi-input pipeline that sets
// `audio_map: "1:a:0"` must reach the restream config layer
// unchanged.
func TestProcessConfigWebRTCMapsRoundtrip(t *testing.T) {
body := []byte(`{
"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}],
"webrtc":{"enabled":true,"video_map":"0:v:1","audio_map":"1:a:0"}
}`)
var dto ProcessConfig
if err := json.Unmarshal(body, &dto); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if dto.WebRTC.VideoMap != "0:v:1" || dto.WebRTC.AudioMap != "1:a:0" {
t.Fatalf("DTO maps lost: %+v", dto.WebRTC)
}
cfg := dto.Marshal()
if cfg.WebRTC.VideoMap != "0:v:1" || cfg.WebRTC.AudioMap != "1:a:0" {
t.Fatalf("app.Config maps lost: %+v", cfg.WebRTC)
}
var back ProcessConfig
back.Unmarshal(cfg)
if back.WebRTC.VideoMap != "0:v:1" || back.WebRTC.AudioMap != "1:a:0" {
t.Fatalf("Unmarshal lost maps: %+v", back.WebRTC)
}
}

View file

@ -25,10 +25,18 @@ type ConfigIO struct {
// RTP to a loopback UDP port the subsystem allocates. The subsystem reads // RTP to a loopback UDP port the subsystem allocates. The subsystem reads
// that RTP and fans it out to WHEP subscribers. // that RTP and fans it out to WHEP subscribers.
type ConfigWebRTC struct { type ConfigWebRTC struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
VideoPT uint8 `json:"video_pt"` VideoPT uint8 `json:"video_pt"`
AudioPT uint8 `json:"audio_pt"` AudioPT uint8 `json:"audio_pt"`
ForceTranscode bool `json:"force_transcode"` ForceTranscode bool `json:"force_transcode"`
// VideoMap / AudioMap select which input stream the WebRTC RTP
// legs draw from. Defaults are "0:v:0" and "0:a:0" — correct for
// any RTMP / SRT publisher (single input, both A and V on input
// 0). For multi-input pipelines (lavfi test sources, SDI capture
// fed alongside file audio, etc.) the operator can override.
VideoMap string `json:"video_map,omitempty"`
AudioMap string `json:"audio_map,omitempty"`
} }
// Clone returns a deep copy of the WebRTC config (currently a value copy; // Clone returns a deep copy of the WebRTC config (currently a value copy;