fix(capture): accept a bare pipeline string in video pipelines entries (#701)

* fix(capture): accept a bare pipeline string in video pipelines entries

NEKO_CAPTURE_VIDEO_PIPELINES expects every entry to be a full pipeline config
object, but the singular capture.video.pipeline option trains people to write
the whole pipeline as a plain string, so a dict like {"high": "ximagesrc ...
! appsink"} failed to unmarshal, the error got reduced to a warn and neko fell
back to the default vp8 pipeline silently, also overriding a directly set
video codec

the json string hook decodes the env value into a generic map, so the shorthand
is restored in the mapstructure chain: a string value for an entry now becomes
VideoConfig{GstPipeline: ...}, the same shape the single pipeline option
produces, object entries are untouched

the new config test drives the production env flow (Init + Set) and fails on
master with the same two warnings the reporter saw

Fixes #689

* feat(capture): add shorthand support for pipeline entries as plain strings.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Abdellatif Anaflous
2026-09-10 12:45:30 +01:00
committed by GitHub
parent 371f5c2c7b
commit 73b9d71b0d
3 changed files with 87 additions and 1 deletions
+16
View File
@@ -2,8 +2,10 @@ package config
import (
"os"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
@@ -322,6 +324,17 @@ func (Capture) InitV2(cmd *cobra.Command) error {
return nil
}
// videoPipelineShorthandHook accepts a bare pipeline string in place of a full
// video pipeline config, mirroring the capture.video.pipeline shorthand.
func videoPipelineShorthandHook() mapstructure.DecodeHookFunc {
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if from.Kind() == reflect.String && to == reflect.TypeOf(types.VideoConfig{}) {
return types.VideoConfig{GstPipeline: data.(string)}, nil
}
return data, nil
}
}
func (s *Capture) Set() {
var ok bool
@@ -342,7 +355,10 @@ func (s *Capture) Set() {
s.VideoIDs = viper.GetStringSlice("capture.video.ids")
if err := viper.UnmarshalKey("capture.video.pipelines", &s.VideoPipelines, viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
utils.JsonStringAutoDecode(s.VideoPipelines),
videoPipelineShorthandHook(),
),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse video pipelines")
}
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// loadCaptureFromEnv mirrors the production config flow: env prefix setup,
// flag registration via Init, then value population via Set.
func loadCaptureFromEnv(t *testing.T, pipelines string) *Capture {
t.Helper()
viper.Reset()
viper.SetEnvPrefix("NEKO")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
t.Setenv("NEKO_CAPTURE_VIDEO_PIPELINES", pipelines)
c := &Capture{}
cmd := &cobra.Command{}
if err := c.Init(cmd); err != nil {
t.Fatal(err)
}
c.Set()
return c
}
func TestVideoPipelinesEnv(t *testing.T) {
const pipeline = "ximagesrc display-name={display} show-pointer=false ! appsink name=appsink"
t.Run("object entries with gst_pipeline keep working", func(t *testing.T) {
c := loadCaptureFromEnv(t, `{"high":{"gst_pipeline":"`+pipeline+`"}}`)
cfg, ok := c.VideoPipelines["high"]
if !ok {
t.Fatalf("pipeline entry missing, got %v", c.VideoPipelines)
}
if cfg.GstPipeline != pipeline {
t.Errorf("expected pipeline %q, got %q", pipeline, cfg.GstPipeline)
}
})
t.Run("bare pipeline string entries are accepted", func(t *testing.T) {
c := loadCaptureFromEnv(t, `{"high":"`+pipeline+`"}`)
cfg, ok := c.VideoPipelines["high"]
if !ok {
t.Fatalf("bare pipeline string entry was dropped and neko fell back to the default vp8 pipeline, got %v", c.VideoPipelines)
}
if cfg.GstPipeline != pipeline {
t.Errorf("expected pipeline %q, got %q", pipeline, cfg.GstPipeline)
}
})
}
+12
View File
@@ -165,6 +165,18 @@ capture:
gst_pipeline: "<gstreamer_pipeline>"
```
As a shorthand, a pipeline entry can also be a plain string instead of an object, which is equivalent to setting only <Opt id="video.pipelines.gst_pipeline" />:
```yaml title="config.yaml"
capture:
video:
...
pipelines:
<pipeline_id>: "<gstreamer_pipeline>"
```
This shorthand also works when defining <Opt id="video.pipelines" /> via the `NEKO_CAPTURE_VIDEO_PIPELINES` environment variable as a JSON object, e.g. `NEKO_CAPTURE_VIDEO_PIPELINES='{"<pipeline_id>":"<gstreamer_pipeline>"}'`.
Since now you have to define the whole pipeline, you need to specify the src element to get the video frames and the sink element to send the encoded video frames to neko. In your pipeline, you can use `{display}` as a placeholder for the display name that will be replaced by the actual display name at runtime. You need to set the `name` property of the sink element to `appsink` so that neko can capture the video frames.
Your typical pipeline string would look like this: