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