442 lines
12 KiB
Go
442 lines
12 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
|
|
|
|
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> 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> [-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 !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%6 != 0 {
|
|
// v210 stores 6 pixels in each 16-byte block.
|
|
return fmt.Errorf("video width must be greater than zero and divisible by 6")
|
|
}
|
|
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 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 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. Zero = no video [TODO: 0 width case]")
|
|
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")
|
|
// 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 buildVideoConfig(args appArgs) (video.Config, error) {
|
|
var definition flowdef.Video
|
|
if args.videoFlowDefFile != "" {
|
|
data, err := os.ReadFile(args.videoFlowDefFile)
|
|
if err != nil {
|
|
return video.Config{}, fmt.Errorf(
|
|
"read video flow definition %q: %w",
|
|
args.videoFlowDefFile,
|
|
err,
|
|
)
|
|
}
|
|
|
|
definition, err = flowdef.ParseV210Video(data)
|
|
if err != nil {
|
|
return video.Config{}, fmt.Errorf(
|
|
"parse video flow definition %q: %w",
|
|
args.videoFlowDefFile,
|
|
err,
|
|
)
|
|
}
|
|
} else {
|
|
rate, ok := frameRates[args.videoFPS]
|
|
if !ok {
|
|
return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
|
|
}
|
|
|
|
var err error
|
|
definition, err = flowdef.NewV210Video(
|
|
args.videoUUID,
|
|
args.videoWidth,
|
|
args.videoHeight,
|
|
flowdef.Rational{
|
|
Numerator: uint(rate.Num),
|
|
Denominator: uint(rate.Den),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return video.Config{}, 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 video.Config{
|
|
Definition: definition,
|
|
Pattern: args.pattern,
|
|
Overlay: video.OverlayConfig{
|
|
Text: args.textOverlay,
|
|
X: args.overlayX,
|
|
Y: args.overlayY,
|
|
Position: args.overlayPos,
|
|
},
|
|
}, 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,
|
|
})
|
|
}
|