Files
go-mxl-pattern-generator/cmd/mxl-pattern/main.go
T
Dmitry Sergeev dea2e6a59f alpha patterns
2026-09-18 10:11:07 +03:00

501 lines
14 KiB
Go

// MXL pattern generator feed rendered on the GPU via wgpu (WebGPU/Vulkan).
// Run from the repo root: make wgpu-gen
// (mixing libmxl cgo with wgpu/goffi needs the internal linker)
package main
import (
"context"
"fmt"
"io"
"log"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"github.com/google/uuid"
"github.com/qvest-digital/go-mxl/mxl"
"github.com/spf13/pflag"
"mxl-pattern-generator/internal/app"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef"
"mxl-pattern-generator/internal/video"
)
const (
APP_NAME = app.Name
APP_VER = app.Version
)
type appArgs struct {
showHelp bool
domain string
videoFlowDefFile string
audioFlowDefFile string
pattern string
listPatterns bool
textOverlay string
overlayX int
overlayY int
overlayPos string
videoWidth uint
videoHeight uint
videoFPS string
videoUUID string
noVideo bool
videoAlpha bool
videoBackend string
audioChannels uint8
audioSamplingFreq string
audioLevel string
audioUUID string
}
type parseResult struct {
args appArgs
shouldRun bool
}
var frameRates = map[string]mxl.Rational{
"23.97": {Num: 24000, Den: 1001},
"24": {Num: 24, Den: 1},
"25": {Num: 25, Den: 1},
"29.97": {Num: 30000, Den: 1001},
"30": {Num: 30, Den: 1},
"50": {Num: 50, Den: 1},
"59.94": {Num: 60000, Den: 1001},
"60": {Num: 60, Den: 1},
"120": {Num: 120, Den: 1},
"240": {Num: 240, Den: 1},
}
var samplingRates = map[string]mxl.Rational{
"44.1": {Num: 44100, Den: 1},
"48": {Num: 48000, Den: 1},
"96": {Num: 96000, Den: 1},
"192": {Num: 192000, Den: 1},
}
func printHelp(w io.Writer, fs *pflag.FlagSet) {
fmt.Fprintf(w, "%s %s\n", APP_NAME, APP_VER)
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Fprintln(w, " or: mxl-gen -d <domain> [--width <width px>] [--height <height px>] [--fps <framerate>] \\")
fmt.Fprintln(w, " [-c <channels amount>] [-f <sample rate>]")
fmt.Fprintln(w, " or: mxl-gen -d <domain> --no-video (-c <channels amount> | -a <flowDef.json>)")
fmt.Fprintln(w, " or: mxl-gen -d <domain> with default params")
fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.")
fmt.Fprintln(w)
fs.SetOutput(w)
fs.PrintDefaults()
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [--no-video] [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.")
}
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain is required")
}
fi, err := os.Stat(domain)
if err != nil {
return fmt.Errorf("invalid MXL domain %q: %w", domain, err)
}
if !fi.IsDir() {
return fmt.Errorf("invalid MXL domain %q: not a directory", domain)
}
if ok, err := mxl.IsTmpFs(domain); err != nil || !ok {
if err != nil {
return fmt.Errorf("check MXL domain %q: %w", domain, err)
}
return fmt.Errorf("invalid MXL domain %q: directory must be on tmpfs", domain)
}
return nil
}
func validateFlowDefPath(label, path string) error {
if path == "" {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s flow definition %q is not accessible: %w", label, path, err)
}
if fi.IsDir() {
return fmt.Errorf("%s flow definition %q is a directory", label, path)
}
return nil
}
func validateVideoArgs(args *appArgs) error {
if args.noVideo {
return nil
}
if _, err := parseVideoBackend(args.videoBackend); err != nil {
return err
}
if !video.HasPattern(args.pattern) {
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
}
if args.videoFlowDefFile != "" {
return nil
}
if args.videoWidth == 0 || args.videoWidth%2 != 0 {
return fmt.Errorf("video width must be greater than zero and even for 4:2:2 video")
}
if args.videoHeight == 0 {
return fmt.Errorf("video height must be greater than zero")
}
if _, exists := frameRates[args.videoFPS]; !exists {
return fmt.Errorf("unsupported video FPS %q (supported: %s); use a flow definition for other rates",
args.videoFPS, sortedMapKeys(frameRates))
}
if args.videoUUID == "" {
args.videoUUID = uuid.NewString()
return nil
}
if _, err := uuid.Parse(args.videoUUID); err != nil {
return fmt.Errorf("invalid video UUID %q: %w", args.videoUUID, err)
}
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")
}
if args.videoAlpha && args.noVideo {
return fmt.Errorf("--alpha cannot be used with --no-video")
}
if args.videoAlpha && args.videoFlowDefFile != "" {
return fmt.Errorf("--alpha cannot be used with --video; set media_type to %q in the flow definition", flowdef.MediaTypeV210A)
}
return nil
}
func validateAudioArgs(args *appArgs) error {
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
return nil
}
if _, ok := audioLevels[args.audioLevel]; !ok {
return fmt.Errorf(
"unsupported audio level %q (supported: %s)",
args.audioLevel,
sortedMapKeys(audioLevels),
)
}
if args.audioFlowDefFile != "" {
return nil
}
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
return fmt.Errorf("unsupported audio sample rate %q (supported: %s); use a flow definition for other rates",
args.audioSamplingFreq, sortedMapKeys(samplingRates))
}
if args.audioUUID == "" {
args.audioUUID = uuid.NewString()
return nil
}
if err := uuid.Validate(args.audioUUID); err != nil {
return fmt.Errorf("invalid audio UUID %q: %w", args.audioUUID, err)
}
return nil
}
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) },
func() error { return validateAudioArgs(args) },
}
for _, check := range checks {
if err := check(); err != nil {
return err
}
}
return nil
}
func sortedMapKeys[V any](values map[string]V) string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
var audioLevels = map[string]float64{
"ebu": audio.LevelEBUDBFS,
"smpte": audio.LevelSMPTEDBFS,
}
func listPatterns(w io.Writer) {
fmt.Fprintln(w, "List of available video patterns:")
patterns := video.Patterns()
maxNameWidth := 0
for _, pattern := range patterns {
l := len(pattern.Name)
if l > maxNameWidth {
maxNameWidth = l
}
}
for _, pattern := range patterns {
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, pattern.Name, pattern.Description)
}
}
func addFlags(fs *pflag.FlagSet, args *appArgs) {
// common flags
fs.BoolVarP(&args.showHelp, "help", "h", false, "Show help message and exit")
// MXL flags
fs.StringVarP(&args.domain, "domain", "d", "", "MXL domain")
fs.StringVarP(&args.videoFlowDefFile, "video", "v", "", "Video flow definition JSON file path")
fs.StringVarP(&args.audioFlowDefFile, "audio", "a", "", "Audio flow definition JSON file path")
// Video pattern flags
fs.StringVarP(&args.pattern, "pattern", "p", "ebu75", "Video pattern type")
fs.BoolVar(&args.listPatterns, "list-patterns", false, "List video available video patterns and exit")
fs.StringVarP(&args.textOverlay, "text", "t", "", "Text overlay above video pattern. Ignored if text-pos set")
fs.IntVar(&args.overlayX, "text-x", 0, "Text overlay X position in px. Ignored if text-pos set")
fs.IntVar(
&args.overlayY,
"text-y",
0,
"Text overlay Y position in px")
fs.StringVar(
&args.overlayPos,
"text-pos",
"",
"Text overlay position with pre-defined values:\n"+
"tl - top-left corner\n"+
"tc - top-center\n"+
"tr - top-right corner\n"+
"cc - center of the frame\n"+
"bl - bottom-left corner\n"+
"bc - bottom-center\n"+
"br - bottom-right corner",
)
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")
fs.BoolVar(&args.videoAlpha, "alpha", false, "Generate video/v210a with a moving transparent square")
fs.StringVar(&args.videoBackend, "backend", string(video.BackendAuto), "Video generator backend: auto, gpu or cpu")
// 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")
fs.StringVar(&args.audioLevel, "audio-level", "ebu", "Audio alignment level: ebu (-18 dBFS) or smpte (-20 dBFS)")
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided")
}
func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
var args appArgs
flagSet := pflag.NewFlagSet("args", pflag.ContinueOnError)
flagSet.SortFlags = false
flagSet.SetOutput(stderr)
flagSet.Usage = func() { printUsage(stderr) }
addFlags(flagSet, &args)
if err := flagSet.Parse(argv); err != nil {
return parseResult{}, err
}
if args.showHelp {
printHelp(stdout, flagSet)
return parseResult{args: args}, nil
}
if args.listPatterns {
listPatterns(stdout)
return parseResult{args: args}, nil
}
if flagSet.NArg() != 0 {
return parseResult{}, fmt.Errorf("unexpected positional arguments: %v", flagSet.Args())
}
if err := validateArgs(&args); err != nil {
return parseResult{}, err
}
return parseResult{args: args, shouldRun: true}, nil
}
func parseVideoBackend(value string) (video.Backend, error) {
if value == "" {
return video.BackendAuto, nil
}
backend := video.Backend(value)
switch backend {
case video.BackendAuto, video.BackendGPU, video.BackendCPU:
return backend, nil
default:
return "", fmt.Errorf(
"unsupported video backend %q (supported: auto, gpu, cpu)",
value,
)
}
}
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 nil, fmt.Errorf(
"read video flow definition %q: %w",
args.videoFlowDefFile,
err,
)
}
definition, err = flowdef.ParseVideo(data)
if err != nil {
return nil, fmt.Errorf(
"parse video flow definition %q: %w",
args.videoFlowDefFile,
err,
)
}
} else {
rate, ok := frameRates[args.videoFPS]
if !ok {
return nil, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
}
var err error
newVideo := flowdef.NewV210Video
if args.videoAlpha {
newVideo = flowdef.NewV210AVideo
}
definition, err = newVideo(
args.videoUUID,
args.videoWidth,
args.videoHeight,
flowdef.Rational{
Numerator: uint(rate.Num),
Denominator: uint(rate.Den),
},
)
if err != nil {
return nil, fmt.Errorf(
"build video flow definition: %w",
err,
)
}
}
if !video.HasPattern(args.pattern) {
return nil, fmt.Errorf("unknown video pattern %q", args.pattern)
}
backend, err := parseVideoBackend(args.videoBackend)
if err != nil {
return nil, err
}
return &video.Config{
Definition: definition,
Pattern: args.pattern,
Overlay: video.OverlayConfig{
Text: args.textOverlay,
X: args.overlayX,
Y: args.overlayY,
Position: args.overlayPos,
},
Backend: backend,
}, nil
}
// buildAudioConfig returns nil when audio is disabled. An explicit audio flow
// definition enables audio even when --channel is zero.
func buildAudioConfig(args appArgs) (*audio.Config, error) {
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
return nil, nil
}
levelDBFS, ok := audioLevels[args.audioLevel]
if !ok {
return nil, fmt.Errorf("unsupported audio level %q", args.audioLevel)
}
var definition flowdef.Audio
if args.audioFlowDefFile != "" {
data, err := os.ReadFile(args.audioFlowDefFile)
if err != nil {
return nil, fmt.Errorf("read audio flow definition %q: %w", args.audioFlowDefFile, err)
}
definition, err = flowdef.ParseFloat32Audio(data)
if err != nil {
return nil, fmt.Errorf("parse audio flow definition %q: %w", args.audioFlowDefFile, err)
}
} else {
rate, ok := samplingRates[args.audioSamplingFreq]
if !ok {
return nil, fmt.Errorf("unsupported audio sample rate %q", args.audioSamplingFreq)
}
var err error
definition, err = flowdef.NewFloat32Audio(
args.audioUUID,
uint(args.audioChannels),
flowdef.Rational{Numerator: uint(rate.Num), Denominator: uint(rate.Den)},
)
if err != nil {
return nil, fmt.Errorf("build audio flow definition: %w", err)
}
}
return &audio.Config{
Definition: definition,
LevelDBFS: levelDBFS,
}, nil
}
func main() {
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
if err != nil {
fmt.Fprintln(os.Stderr, err)
printUsage(os.Stderr)
os.Exit(2)
}
if !parsed.shouldRun {
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, parsed.args); err != nil {
log.Printf("%s: %v", APP_NAME, err)
os.Exit(1)
}
}
func run(ctx context.Context, args appArgs) error {
videoCfg, err := buildVideoConfig(args)
if err != nil {
return fmt.Errorf("video configuration: %w", err)
}
audioCfg, err := buildAudioConfig(args)
if err != nil {
return fmt.Errorf("audio configuration: %w", err)
}
return app.Run(ctx, app.Config{
Domain: args.domain,
Video: videoCfg,
Audio: audioCfg,
})
}