// 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" flowdef "mxl-pattern-generator/internal/flow-def" "mxl-pattern-generator/internal/generator" ) const ( APP_NAME = "MXL pattern generator" APP_VER = "0.1.0" ) 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 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 [-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 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 [-v ] [-a ] [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.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 _, exists := patterns[args.pattern]; !exists { return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern) } 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 _, 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, ", ") } type pattern struct { name string description string kernelPath string motion bool } var patterns = map[string]pattern{ "ebu75": { name: "ebu75", description: "EBU 75% Color Bar Signal", kernelPath: "kernels/static/ebu75.wgsl", motion: false, }, "ebu75-move": { name: "ebu75-move", description: "EBU 75% Color Bar Signal with moving square", kernelPath: "kernels/dynamic/ebu75.wgsl", motion: true, }, "ebu100": { name: "ebu100", description: "EBU 100% Color Bar Signal", kernelPath: "kernels/static/ebu100.wgsl", motion: false, }, "ebu100-move": { name: "ebu100-move", description: "EBU 100% Color Bar Signal with moving square", kernelPath: "kernels/dynamic/ebu100.wgsl", motion: true, }, "smpte": { name: "smpte", description: "SMPTE RP-219 Color Bar Signal", kernelPath: "kernels/static/smpteBars.wgsl", motion: false, }, "smpte-move": { name: "smpte-move", description: "SMPTE RP-219 Color Bar Signal with moving square", kernelPath: "kernels/dynamic/smpteBars.wgsl", motion: true, }, "gray-bars": { name: "gray-bars", description: "13-step grayscale bars (Y 64..940)", kernelPath: "kernels/static/yBars.wgsl", motion: false, }, "gray-bars-move": { name: "gray-bars-move", description: "13-step grayscale bars (Y 64..940) with moving square", kernelPath: "kernels/dynamic/yBars.wgsl", motion: true, }, "gray-ramp": { name: "gray-ramp", description: "Y gradient (black -> 100% white)", kernelPath: "kernels/static/yRamp.wgsl", motion: false, }, "gray-ramp-move": { name: "gray-ramp-move", description: "Y gradient with moving square", kernelPath: "kernels/dynamic/yRamp.wgsl", motion: true, }, } func listPatterns(w io.Writer) { fmt.Fprintln(w, "List of available video patterns:") names := make([]string, 0, len(patterns)) maxNameWidth := 0 for name := range patterns { names = append(names, name) l := len(name) if l > maxNameWidth { maxNameWidth = l } } sort.Strings(names) for _, name := range names { p := patterns[name] fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, name, p.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 [TODO]") // 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. Zero = no sound [TODO]") fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz [TODO]") fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided [TODO]") } 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 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 } args := parsed.args type videoInfo struct { uuid string width uint height uint fps mxl.Rational } var vi videoInfo var videoFlowDef string if args.videoFlowDefFile == "" { args.videoUUID = "8f1d2a4b-6c3e-4f5a-9b2c-1d7e8a3f0b5d" // TODO: remove before public release vi = videoInfo{ uuid: args.videoUUID, width: args.videoWidth, height: args.videoHeight, fps: frameRates[args.videoFPS], } flowDef, err := flowdef.NewFlowDefJSON( flowdef.TYPE_VIDEO, vi.uuid, vi.width, vi.height, uint(vi.fps.Num), uint(vi.fps.Den), ) videoFlowDef = flowDef if err != nil { log.Fatalf("Could not create Flow Definition: %v", err) } } else { flowDef, err := flowdef.ReadFlowDefFile(args.videoFlowDefFile) if err != nil { log.Fatalf("Could not read video flow def .json: %s. Reason: %v", args.videoFlowDefFile, err) } videoFlowDef = flowDef } log.Printf("%s %s", APP_NAME, APP_VER) log.Printf("Domain: %s", args.domain) log.Printf("Video: %dx%d %d/%d", vi.width, vi.height, vi.fps.Num, vi.fps.Den) log.Printf("Video UUID: %s", vi.uuid) // TODO: if init failed -> CPU generator videoPattern := patterns[args.pattern] gen, err := generator.NewWGPUGenerator(vi.width, vi.height, videoPattern.kernelPath) if err != nil { log.Fatalf("wgpu init failed: %v", err) } defer gen.Close() // Static text overlay: rasterized + pre-packed once, stamped on each // frame after the GPU render (microseconds per frame). var overlay *generator.TextOverlay if args.textOverlay != "" { face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48) if err != nil { log.Fatalf("text overlay init failed: %v", err) } defer face.Close() if args.overlayX < 0 || args.overlayX > int(vi.width) || args.overlayY < 0 || args.overlayY > int(vi.height) { log.Fatalf("overlay X/Y pos can't be negative or greater, than video width/height") } overlay, err = generator.NewTextOverlay( args.textOverlay, int(vi.width), int(vi.height), args.overlayX, args.overlayY, args.overlayPos, face, ) if err != nil { log.Fatalf("text overlay init failed: %v", err) } } inst, err := mxl.NewInstance(args.domain, "") if err != nil { log.Fatalf("MXL Init Failed: %v", err) } defer inst.Close() writer, isCreated, err := inst.NewWriter(videoFlowDef) if err != nil { log.Fatalf("Failed to create MXL writer: %v", err) } if !isCreated { log.Printf("reusing existing flow: %s, domain: %s", vi.uuid, args.domain) } defer writer.Close() flowCfg := writer.Config() rate := flowCfg.Common.GrainRate idx := mxl.CurrentIndex(rate) log.Printf("writing flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx) stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) // core loop var grainsWritten int64 // animation clock: small counter, not the huge grain index. // Reason: current wgpu shaders limitations var tick uint32 for { select { case <-stop: log.Printf("stopping after %d grains", grainsWritten) return default: } gwa, err := writer.OpenGrain(idx) if err != nil { log.Fatalf("OpenGrain(%d): %v", idx, err) } if err := gen.GenerateFrame(gwa.Payload, int(tick)); err != nil { log.Fatalf("GenerateFrame(%d): %v", idx, err) } if overlay != nil { if err := overlay.ApplyV210(gwa.Payload); err != nil { log.Fatalf("text overlay: %v", err) } } if err := gwa.Commit(gwa.TotalSlices, 0); err != nil { log.Fatalf("Commit(%d): %v", idx, err) } grainsWritten++ idx++ tick++ if grainsWritten%100 == 0 { log.Printf("grains written=%d, index=%d", grainsWritten, idx) } // Pace ourselves to roughly the grain rate mxl.SleepNs(mxl.NsUntilIndex(idx, rate)) } } func run(ctx context.Context, args []string) error { return nil } func runVideo(ctx context.Context) error { return nil } func runAudio(ctx context.Context) error { return nil }