diff --git a/cmd/mxl-pattern/main.go b/cmd/mxl-pattern/main.go index 973ad40..e073784 100644 --- a/cmd/mxl-pattern/main.go +++ b/cmd/mxl-pattern/main.go @@ -47,6 +47,7 @@ type appArgs struct { videoHeight uint videoFPS string videoUUID string + noVideo bool audioChannels uint8 audioSamplingFreq string @@ -84,6 +85,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) { fmt.Fprintln(w, "Usage: mxl-gen -d [-v ] [-a ] [options]") fmt.Fprintln(w, " or: mxl-gen -d [--width ] [--height ] [--fps ] \\") fmt.Fprintln(w, " [-c ] [-f ]") + fmt.Fprintln(w, " or: mxl-gen -d --no-video (-c | -a )") fmt.Fprintln(w, " or: mxl-gen -d with default params") fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.") fmt.Fprintln(w) @@ -92,7 +94,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) { } func printUsage(w io.Writer) { - fmt.Fprintln(w, "Usage: mxl-gen -d [-v ] [-a ] [options]") + fmt.Fprintln(w, "Usage: mxl-gen -d [--no-video] [-v ] [-a ] [options]") fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.") } @@ -131,6 +133,9 @@ func validateFlowDefPath(label, path string) error { } func validateVideoArgs(args *appArgs) error { + if args.noVideo { + return nil + } if !video.HasPattern(args.pattern) { return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern) } @@ -158,6 +163,16 @@ func validateVideoArgs(args *appArgs) error { return nil } +func validateMediaSelection(args appArgs) error { + if args.noVideo && args.videoFlowDefFile != "" { + return fmt.Errorf("--no-video cannot be used with --video") + } + if args.noVideo && args.audioFlowDefFile == "" && args.audioChannels == 0 { + return fmt.Errorf("--no-video requires audio enabled with --channel or --audio") + } + return nil +} + func validateAudioArgs(args *appArgs) error { if args.audioFlowDefFile == "" && args.audioChannels == 0 { return nil @@ -189,6 +204,7 @@ func validateAudioArgs(args *appArgs) error { func validateArgs(args *appArgs) error { checks := []func() error{ func() error { return validateDomain(args.domain) }, + func() error { return validateMediaSelection(*args) }, func() error { return validateFlowDefPath("video", args.videoFlowDefFile) }, func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) }, func() error { return validateVideoArgs(args) }, @@ -263,10 +279,11 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) { "br - bottom-right corner", ) - fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width. Zero = no video [TODO: 0 width case]") + fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width") fs.UintVar(&args.videoHeight, "height", 1080, "Video pattern height") fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS") fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided") + fs.BoolVar(&args.noVideo, "no-video", false, "Disable video generation; audio must be enabled") // Audio pattern flags fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz") fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz") @@ -302,12 +319,16 @@ func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) { return parseResult{args: args, shouldRun: true}, nil } -func buildVideoConfig(args appArgs) (video.Config, error) { +func buildVideoConfig(args appArgs) (*video.Config, error) { + if args.noVideo { + return nil, nil + } + var definition flowdef.Video if args.videoFlowDefFile != "" { data, err := os.ReadFile(args.videoFlowDefFile) if err != nil { - return video.Config{}, fmt.Errorf( + return nil, fmt.Errorf( "read video flow definition %q: %w", args.videoFlowDefFile, err, @@ -316,7 +337,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) { definition, err = flowdef.ParseV210Video(data) if err != nil { - return video.Config{}, fmt.Errorf( + return nil, fmt.Errorf( "parse video flow definition %q: %w", args.videoFlowDefFile, err, @@ -325,7 +346,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) { } else { rate, ok := frameRates[args.videoFPS] if !ok { - return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS) + return nil, fmt.Errorf("unsupported video FPS %q", args.videoFPS) } var err error @@ -339,17 +360,17 @@ func buildVideoConfig(args appArgs) (video.Config, error) { }, ) if err != nil { - return video.Config{}, fmt.Errorf( + return nil, fmt.Errorf( "build video flow definition: %w", err, ) } } if !video.HasPattern(args.pattern) { - return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern) + return nil, fmt.Errorf("unknown video pattern %q", args.pattern) } - return video.Config{ + return &video.Config{ Definition: definition, Pattern: args.pattern, Overlay: video.OverlayConfig{ diff --git a/cmd/mxl-pattern/main_test.go b/cmd/mxl-pattern/main_test.go index 6b226cd..16686b5 100644 --- a/cmd/mxl-pattern/main_test.go +++ b/cmd/mxl-pattern/main_test.go @@ -95,6 +95,69 @@ func TestValidateAudioArgsSkipsDisabledAudio(t *testing.T) { } } +func TestValidateVideoArgsSkipsDisabledVideo(t *testing.T) { + args := appArgs{ + noVideo: true, + pattern: "not-a-pattern", + videoWidth: 1, + videoFPS: "unsupported", + videoUUID: "not-a-uuid", + } + + if err := validateVideoArgs(&args); err != nil { + t.Fatalf("validateVideoArgs: %v", err) + } + if args.videoUUID != "not-a-uuid" { + t.Fatalf("video UUID changed while video is disabled: %q", args.videoUUID) + } +} + +func TestValidateMediaSelection(t *testing.T) { + tests := []struct { + name string + args appArgs + wantErrSub string + }{ + { + name: "no video with video definition", + args: appArgs{noVideo: true, videoFlowDefFile: "video.json", audioChannels: 2}, + wantErrSub: "cannot be used with --video", + }, + { + name: "no flows", + args: appArgs{noVideo: true}, + wantErrSub: "requires audio enabled", + }, + { + name: "generated audio only", + args: appArgs{noVideo: true, audioChannels: 2}, + }, + { + name: "external audio only", + args: appArgs{noVideo: true, audioFlowDefFile: "audio.json"}, + }, + { + name: "video enabled by default", + args: appArgs{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateMediaSelection(tc.args) + if tc.wantErrSub == "" { + if err != nil { + t.Fatalf("validateMediaSelection: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) { + t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub) + } + }) + } +} + func TestListPatternsIsSorted(t *testing.T) { var output bytes.Buffer listPatterns(&output) @@ -126,6 +189,16 @@ func TestBuildVideoConfigFromArgs(t *testing.T) { } } +func TestBuildVideoConfigDisabled(t *testing.T) { + cfg, err := buildVideoConfig(appArgs{noVideo: true}) + if err != nil { + t.Fatalf("buildVideoConfig: %v", err) + } + if cfg != nil { + t.Fatalf("config = %+v, want nil for disabled video", cfg) + } +} + func TestBuildVideoConfigFromFile(t *testing.T) { definition, err := flowdef.NewV210Video( "5fbec3b1-1b0f-417d-9059-8b94a47197ed", diff --git a/internal/app/app.go b/internal/app/app.go index e0fea40..a870939 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,22 +19,27 @@ const ( type Config struct { Domain string - Video video.Config + Video *video.Config Audio *audio.Config } func Run(ctx context.Context, cfg Config) (runErr error) { + if err := validateConfig(cfg); err != nil { + return err + } + log.Printf("%s %s", Name, Version) log.Printf("Domain: %s", cfg.Domain) - log.Printf("Video: %dx%d %d/%d", - cfg.Video.Width(), cfg.Video.Height(), cfg.Video.Rate().Num, cfg.Video.Rate().Den) - log.Printf("Video ID: %s", cfg.Video.ID()) + if cfg.Video != nil { + log.Printf("Video: %dx%d %d/%d", + cfg.Video.Width(), cfg.Video.Height(), cfg.Video.Rate().Num, cfg.Video.Rate().Den) + log.Printf("Video ID: %s", cfg.Video.ID()) + } if cfg.Audio != nil { log.Printf("Audio: %d channels %d/%d Hz %.0f dBFS", cfg.Audio.Channels(), cfg.Audio.Rate().Num, cfg.Audio.Rate().Den, cfg.Audio.LevelDBFS) log.Printf("Audio ID: %s", cfg.Audio.ID()) } - inst, err := mxl.NewInstance(cfg.Domain, "") if err != nil { return fmt.Errorf("initialize MXL domain %q: %w", cfg.Domain, err) @@ -45,13 +50,14 @@ func Run(ctx context.Context, cfg Config) (runErr error) { } }() - runners := []Runner{ - { + runners := make([]Runner, 0, 2) + if cfg.Video != nil { + runners = append(runners, Runner{ Name: "video", Run: func(ctx context.Context) error { - return video.Run(ctx, inst, cfg.Video) + return video.Run(ctx, inst, *cfg.Video) }, - }, + }) } if cfg.Audio != nil { runners = append(runners, Runner{ @@ -64,3 +70,10 @@ func Run(ctx context.Context, cfg Config) (runErr error) { return RunConcurrent(ctx, runners...) } + +func validateConfig(cfg Config) error { + if cfg.Video == nil && cfg.Audio == nil { + return fmt.Errorf("at least one media flow must be enabled") + } + return nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..ac4f44c --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,15 @@ +package app + +import ( + "strings" + "testing" +) + +func TestValidateConfigRejectsNoFlows(t *testing.T) { + err := validateConfig(Config{ + Domain: "/unused", + }) + if err == nil || !strings.Contains(err.Error(), "at least one media flow") { + t.Fatalf("error = %v, want no-flow validation error", err) + } +}