From 7bb2a1d0ae290dfe2df8ab8049e12cd14e15e6cd Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 18 Oct 2026 16:16:21 -0700 Subject: [PATCH] Keep Desktop's node on loopback; ask OBS only for what it can encode Desktop's node takes any valid stream key, so its RTMP and MoQ listeners now bind loopback like its HTTP ones. The node asks OBS for one encode per audio codec, so a duplicate record can't push a stream past OBS's six audio encoders, and refuses a video frame rate OBS's canvas rate isn't a whole multiple of (OBS can only drop whole frames), telling the streamer why instead of streaming at another rate. --- js/desktop/src/node.ts | 4 +++ pkg/api/fmp4_ingest.go | 60 ++++++++++++++++++++++++++++++------- pkg/api/fmp4_ingest_test.go | 38 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/js/desktop/src/node.ts b/js/desktop/src/node.ts index 7d95b2827..76a24ed13 100644 --- a/js/desktop/src/node.ts +++ b/js/desktop/src/node.ts @@ -48,6 +48,10 @@ export default async function makeNode(opts: { ...process.env, SP_HTTP_ADDR: addr, SP_HTTP_INTERNAL_ADDR: internalAddr, + // Every listener on loopback: the node takes any valid stream key, so + // it must only hear this machine's encoders. + SP_RTMP_ADDR: "127.0.0.1:1935", + SP_MOQ_ADDR: "127.0.0.1:38443", ...opts.env, }, windowsHide: true, diff --git a/pkg/api/fmp4_ingest.go b/pkg/api/fmp4_ingest.go index 9c71ea87e..a653a6c83 100644 --- a/pkg/api/fmp4_ingest.go +++ b/pkg/api/fmp4_ingest.go @@ -28,6 +28,10 @@ type clientConfigurationRequest struct { // AudioTracks names the OBS mixer tracks: track_id 0..5, name empty // when the streamer left the track unnamed. AudioTracks []audioTrackPreference `json:"audio_tracks"` + // Canvases are OBS's video outputs; the plugin sends one. + Canvases []struct { + Framerate placestream.IngestDefs_Framerate `json:"framerate"` + } `json:"canvases"` } `json:"preferences"` } @@ -106,10 +110,11 @@ var defaultAudioRenditions = []*placestream.IngestAudioRendition{ } // audioConfigurations lists the audio encoders the plugin should create: one -// per rendition (or the defaults) for track 0 (the full mix) and for each -// mixer track the streamer named beyond the first, in track order, up to -// maxStems of them. With no renditions and no named tracks it returns nil, -// and the plugin keeps the encoder from OBS's output settings. +// per codec the streamer chose (or the defaults) for track 0 (the full mix) +// and for each mixer track the streamer named beyond the first, in track +// order, up to maxStems of them; two codecs over three tracks is OBS's limit +// of six. With no renditions and no named tracks it returns nil, and the +// plugin keeps the encoder from OBS's output settings. func audioConfigurations(tracks []audioTrackPreference, renditions []*placestream.IngestAudioRendition) []audioConfiguration { var stems []int for _, t := range tracks { @@ -123,11 +128,17 @@ func audioConfigurations(tracks []audioTrackPreference, renditions []*placestrea if len(renditions) == 0 { renditions = defaultAudioRenditions } + var codecs []*placestream.IngestAudioRendition + for _, r := range renditions { + if !slices.ContainsFunc(codecs, func(c *placestream.IngestAudioRendition) bool { return c.Codec == r.Codec }) { + codecs = append(codecs, r) + } + } slices.Sort(stems) ids := append([]int{0}, stems[:min(len(stems), maxStems)]...) var out []audioConfiguration for _, id := range ids { - for _, r := range renditions { + for _, r := range codecs { cfg := audioConfiguration{Codec: r.Codec, TrackID: id, Channels: 2} cfg.Settings.Bitrate = r.Bitrate out = append(out, cfg) @@ -138,10 +149,14 @@ func audioConfigurations(tracks []audioTrackPreference, renditions []*placestrea // videoConfigurations lists the video encoders the plugin should create from // the streamer's renditions: none, and the plugin keeps OBS's own. A stream -// has one video track for now, so only the first rendition is encoded. -func videoConfigurations(renditions []*placestream.IngestVideoRendition) []videoConfiguration { - var out []videoConfiguration +// has one video track for now, so only the first rendition is encoded. OBS +// can only drop whole frames from its canvas, so a rate the canvas (when +// known) can't be divided into is refused with a problem for the streamer. +func videoConfigurations(renditions []*placestream.IngestVideoRendition, canvas *placestream.IngestDefs_Framerate) (out []videoConfiguration, problem string) { for _, r := range renditions[:min(len(renditions), 1)] { + if canvas != nil && !dividesFramerate(*canvas, r.Framerate) { + return nil, fmt.Sprintf("OBS runs at %s fps, which can't be encoded at the %s fps set in Streamplace Desktop. Pick a frame rate OBS's divides into, or change OBS's.", fps(*canvas), fps(r.Framerate)) + } cfg := videoConfiguration{Codec: r.Codec, Width: r.Width, Height: r.Height, Framerate: r.Framerate} cfg.Settings.Bitrate = r.Bitrate cfg.Settings.RateControl = "CBR" @@ -150,7 +165,24 @@ func videoConfigurations(renditions []*placestream.IngestVideoRendition) []video cfg.Settings.BFrames = 0 out = append(out, cfg) } - return out + return out, "" +} + +// dividesFramerate reports whether rate is canvas divided by a whole number, +// the only rates OBS can encode from it. +func dividesFramerate(canvas, rate placestream.IngestDefs_Framerate) bool { + if canvas.Numerator < 1 || canvas.Denominator < 1 { + return true // OBS didn't say + } + n, d := canvas.Numerator*rate.Denominator, canvas.Denominator*rate.Numerator + return d > 0 && n >= d && n%d == 0 +} + +func fps(f placestream.IngestDefs_Framerate) string { + if f.Denominator == 1 { + return fmt.Sprint(f.Numerator) + } + return fmt.Sprintf("%.2f", float64(f.Numerator)/float64(f.Denominator)) } // localRecords returns repoDID's records of collection kept on this node, @@ -222,8 +254,14 @@ func (a *StreamplaceAPI) HandleClientConfiguration(ctx context.Context) http.Han errors.WriteHTTPInternalServerError(w, "could not read audio renditions", err) return } - config.EncoderConfigurations = videoConfigurations(video) - if live := audioConfigurations(req.Preferences.AudioTracks, audio); live != nil { + var canvas *placestream.IngestDefs_Framerate + if len(req.Preferences.Canvases) > 0 { + canvas = &req.Preferences.Canvases[0].Framerate + } + config.EncoderConfigurations, problem = videoConfigurations(video, canvas) + if problem != "" { + config = clientConfiguration{Meta: config.Meta, Status: clientConfigurationStatus{Result: "error", HTMLEnUS: problem}} + } else if live := audioConfigurations(req.Preferences.AudioTracks, audio); live != nil { config.AudioConfigurations = map[string][]audioConfiguration{"live": live} } } diff --git a/pkg/api/fmp4_ingest_test.go b/pkg/api/fmp4_ingest_test.go index 22d174ae1..2a1a11986 100644 --- a/pkg/api/fmp4_ingest_test.go +++ b/pkg/api/fmp4_ingest_test.go @@ -54,4 +54,42 @@ func TestAudioConfigurations(t *testing.T) { require.JSONEq(t, `[ {"codec":"opus","track_id":0,"channels":2,"settings":{"bitrate":96}}, {"codec":"opus","track_id":4,"channels":2,"settings":{"bitrate":96}}]`, string(got)) + + // One encode per codec, so two stems stay within OBS's six encoders even + // with a second record of a codec. + three := []*placestream.IngestAudioRendition{{Codec: "aac", Bitrate: 160}, {Codec: "opus", Bitrate: 128}, {Codec: "aac", Bitrate: 96}} + require.Len(t, audioConfigurations(named.Preferences.AudioTracks, three), 6) +} + +// OBS encodes a rendition by dropping whole frames from its canvas, so only +// rates the canvas divides into evenly can be asked for. +func TestVideoConfigurationsFramerate(t *testing.T) { + rate := func(n, d int64) placestream.IngestDefs_Framerate { + return placestream.IngestDefs_Framerate{Numerator: n, Denominator: d} + } + at := func(r placestream.IngestDefs_Framerate) []*placestream.IngestVideoRendition { + return []*placestream.IngestVideoRendition{{Codec: "h264", Width: 1920, Height: 1080, Framerate: r, Bitrate: 6000}} + } + for _, tc := range []struct { + canvas, rendition placestream.IngestDefs_Framerate + ok bool + }{ + {rate(60, 1), rate(60, 1), true}, + {rate(60, 1), rate(30, 1), true}, + {rate(60000, 1001), rate(30000, 1001), true}, + {rate(60, 1), rate(24, 1), false}, + {rate(30, 1), rate(60, 1), false}, + {rate(0, 0), rate(24, 1), true}, // OBS didn't say + } { + configs, problem := videoConfigurations(at(tc.rendition), &tc.canvas) + if tc.ok { + require.Empty(t, problem, "%v from %v", tc.rendition, tc.canvas) + require.Len(t, configs, 1) + } else { + require.Contains(t, problem, "can't be encoded", "%v from %v", tc.rendition, tc.canvas) + } + } + configs, problem := videoConfigurations(at(rate(24, 1)), nil) + require.Empty(t, problem, "no canvas, no check") + require.Len(t, configs, 1) } -- 2.54.0