From d341d2261169f3d4ae923a4316e14f535c3a391d Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Thu, 17 Sep 2026 20:21:16 +0300 Subject: [PATCH] video run extraction --- cmd/mxl-pattern/main.go | 212 ++------------------------------------ internal/video/pattern.go | 72 +++++++++++++ internal/video/runner.go | 133 ++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 204 deletions(-) create mode 100644 internal/video/pattern.go create mode 100644 internal/video/runner.go diff --git a/cmd/mxl-pattern/main.go b/cmd/mxl-pattern/main.go index 6d99599..d562e34 100644 --- a/cmd/mxl-pattern/main.go +++ b/cmd/mxl-pattern/main.go @@ -5,7 +5,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -22,7 +21,6 @@ import ( "mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/flowdef" - "mxl-pattern-generator/internal/generator" "mxl-pattern-generator/internal/video" ) @@ -143,7 +141,7 @@ func validateFlowDefPath(label, path string) error { } func validateVideoArgs(args *appArgs) error { - if _, exists := patterns[args.pattern]; !exists { + if !video.HasPattern(args.pattern) { return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern) } if args.videoFlowDefFile != "" { @@ -223,76 +221,6 @@ func sortedMapKeys[V any](values map[string]V) string { 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, - }, -} - var audioLevels = map[string]float64{ "ebu": audio.LevelEBUDBFS, "smpte": audio.LevelSMPTEDBFS, @@ -300,19 +228,16 @@ var audioLevels = map[string]float64{ func listPatterns(w io.Writer) { fmt.Fprintln(w, "List of available video patterns:") - names := make([]string, 0, len(patterns)) + patterns := video.Patterns() maxNameWidth := 0 - for name := range patterns { - names = append(names, name) - l := len(name) + for _, pattern := range patterns { + l := len(pattern.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) + for _, pattern := range patterns { + fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, pattern.Name, pattern.Description) } } @@ -430,7 +355,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) { ) } } - if _, ok := patterns[args.pattern]; !ok { + if !video.HasPattern(args.pattern) { return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern) } @@ -543,7 +468,7 @@ func run(ctx context.Context, args appArgs) (runErr error) { { name: "video", run: func(ctx context.Context) error { - return runVideo(ctx, inst, videoCfg) + return video.Run(ctx, inst, videoCfg) }, }, } @@ -584,124 +509,3 @@ func runConcurrent(ctx context.Context, runners ...namedRunner) error { } return resultErr } - -func runVideo(ctx context.Context, inst *mxl.Instance, cfg video.Config) (runErr error) { - pattern, ok := patterns[cfg.Pattern] - if !ok { - return fmt.Errorf("unknown video pattern %q", cfg.Pattern) - } - - // TODO: fall back to a CPU generator if GPU initialization fails. - gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath) - if err != nil { - return fmt.Errorf("initialize wgpu video generator: %w", err) - } - defer func() { - if err := gen.Close(); err != nil { - runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err)) - } - }() - - overlay, err := buildTextOverlay(cfg) - if err != nil { - return err - } - - flowJSON, err := json.Marshal(cfg.Definition) - if err != nil { - return fmt.Errorf("marshal video flow definition: %w", err) - } - writer, isCreated, err := inst.NewWriter(string(flowJSON)) - if err != nil { - return fmt.Errorf("create video writer: %w", err) - } - defer func() { - if err := writer.Close(); err != nil { - runErr = errors.Join(runErr, fmt.Errorf("close video writer: %w", err)) - } - }() - if !isCreated { - log.Printf("reusing existing video flow: %s", cfg.ID()) - } - - rate := writer.Config().Common.GrainRate - idx := mxl.CurrentIndex(rate) - log.Printf("writing video flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx) - - var grainsWritten int64 - var tick uint32 // Small animation counter; shaders cannot use the full grain index yet. - for { - select { - case <-ctx.Done(): - log.Printf("stopping video after %d grains", grainsWritten) - return nil - default: - } - - grain, err := writer.OpenGrain(idx) - if err != nil { - return fmt.Errorf("open video grain %d: %w", idx, err) - } - if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil { - return cancelVideoGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err)) - } - if overlay != nil { - if err := overlay.ApplyV210(grain.Payload); err != nil { - return cancelVideoGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err)) - } - } - if err := grain.Commit(grain.TotalSlices, 0); err != nil { - return fmt.Errorf("commit video grain %d: %w", idx, err) - } - - grainsWritten++ - idx++ - tick++ - if grainsWritten%100 == 0 { - log.Printf("video grains written=%d, next index=%d", grainsWritten, idx) - } - mxl.SleepNs(mxl.NsUntilIndex(idx, rate)) - } -} - -func buildTextOverlay(cfg video.Config) (overlay *generator.TextOverlay, resultErr error) { - if cfg.Overlay.Text == "" { - return nil, nil - } - if cfg.Overlay.X < 0 || cfg.Overlay.X > int(cfg.Width()) || - cfg.Overlay.Y < 0 || cfg.Overlay.Y > int(cfg.Height()) { - return nil, fmt.Errorf("text overlay position (%d, %d) is outside the %dx%d video frame", - cfg.Overlay.X, cfg.Overlay.Y, cfg.Width(), cfg.Height()) - } - - face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48) - if err != nil { - return nil, fmt.Errorf("load text overlay font: %w", err) - } - defer func() { - if err := face.Close(); err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("close text overlay font: %w", err)) - } - }() - - overlay, err = generator.NewTextOverlay( - cfg.Overlay.Text, - int(cfg.Width()), - int(cfg.Height()), - cfg.Overlay.X, - cfg.Overlay.Y, - cfg.Overlay.Position, - face, - ) - if err != nil { - return nil, fmt.Errorf("create text overlay: %w", err) - } - return overlay, nil -} - -func cancelVideoGrain(grain *mxl.GrainWriteAccess, cause error) error { - if err := grain.Cancel(); err != nil { - return errors.Join(cause, fmt.Errorf("cancel video grain: %w", err)) - } - return cause -} diff --git a/internal/video/pattern.go b/internal/video/pattern.go new file mode 100644 index 0000000..ab7aa64 --- /dev/null +++ b/internal/video/pattern.go @@ -0,0 +1,72 @@ +package video + +import "sort" + +type PatternInfo struct { + Name string + Description string +} + +type pattern struct { + PatternInfo + kernelPath string +} + +var patterns = map[string]pattern{ + "ebu75": { + PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"}, + kernelPath: "kernels/static/ebu75.wgsl", + }, + "ebu75-move": { + PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"}, + kernelPath: "kernels/dynamic/ebu75.wgsl", + }, + "ebu100": { + PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"}, + kernelPath: "kernels/static/ebu100.wgsl", + }, + "ebu100-move": { + PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"}, + kernelPath: "kernels/dynamic/ebu100.wgsl", + }, + "smpte": { + PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"}, + kernelPath: "kernels/static/smpteBars.wgsl", + }, + "smpte-move": { + PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"}, + kernelPath: "kernels/dynamic/smpteBars.wgsl", + }, + "gray-bars": { + PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"}, + kernelPath: "kernels/static/yBars.wgsl", + }, + "gray-bars-move": { + PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"}, + kernelPath: "kernels/dynamic/yBars.wgsl", + }, + "gray-ramp": { + PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"}, + kernelPath: "kernels/static/yRamp.wgsl", + }, + "gray-ramp-move": { + PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"}, + kernelPath: "kernels/dynamic/yRamp.wgsl", + }, +} + +func HasPattern(name string) bool { + _, ok := patterns[name] + return ok +} + +func Patterns() []PatternInfo { + result := make([]PatternInfo, 0, len(patterns)) + for _, pattern := range patterns { + result = append(result, pattern.PatternInfo) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} diff --git a/internal/video/runner.go b/internal/video/runner.go new file mode 100644 index 0000000..c66b637 --- /dev/null +++ b/internal/video/runner.go @@ -0,0 +1,133 @@ +package video + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "mxl-pattern-generator/internal/generator" + + "github.com/qvest-digital/go-mxl/mxl" +) + +func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) { + pattern, ok := patterns[cfg.Pattern] + if !ok { + return fmt.Errorf("unknown video pattern %q", cfg.Pattern) + } + + // TODO: fall back to a CPU generator if GPU initialization fails. + gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath) + if err != nil { + return fmt.Errorf("initialize wgpu video generator: %w", err) + } + defer func() { + if err := gen.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err)) + } + }() + + overlay, err := buildTextOverlay(cfg) + if err != nil { + return err + } + + flowJSON, err := json.Marshal(cfg.Definition) + if err != nil { + return fmt.Errorf("marshal video flow definition: %w", err) + } + writer, isCreated, err := inst.NewWriter(string(flowJSON)) + if err != nil { + return fmt.Errorf("create video writer: %w", err) + } + defer func() { + if err := writer.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close video writer: %w", err)) + } + }() + if !isCreated { + log.Printf("reusing existing video flow: %s", cfg.ID()) + } + + rate := writer.Config().Common.GrainRate + idx := mxl.CurrentIndex(rate) + log.Printf("writing video flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx) + + var grainsWritten int64 + var tick uint32 // Small animation counter; shaders cannot use the full grain index yet. + for { + select { + case <-ctx.Done(): + log.Printf("stopping video after %d grains", grainsWritten) + return nil + default: + } + + grain, err := writer.OpenGrain(idx) + if err != nil { + return fmt.Errorf("open video grain %d: %w", idx, err) + } + if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil { + return cancelGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err)) + } + if overlay != nil { + if err := overlay.ApplyV210(grain.Payload); err != nil { + return cancelGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err)) + } + } + if err := grain.Commit(grain.TotalSlices, 0); err != nil { + return fmt.Errorf("commit video grain %d: %w", idx, err) + } + + grainsWritten++ + idx++ + tick++ + if grainsWritten%100 == 0 { + log.Printf("video grains written=%d, next index=%d", grainsWritten, idx) + } + mxl.SleepNs(mxl.NsUntilIndex(idx, rate)) + } +} + +func cancelGrain(grain *mxl.GrainWriteAccess, cause error) error { + if err := grain.Cancel(); err != nil { + return errors.Join(cause, fmt.Errorf("cancel video grain: %w", err)) + } + return cause +} + +func buildTextOverlay(cfg Config) (overlay *generator.TextOverlay, resultErr error) { + if cfg.Overlay.Text == "" { + return nil, nil + } + if cfg.Overlay.X < 0 || cfg.Overlay.X > int(cfg.Width()) || + cfg.Overlay.Y < 0 || cfg.Overlay.Y > int(cfg.Height()) { + return nil, fmt.Errorf("text overlay position (%d, %d) is outside the %dx%d video frame", + cfg.Overlay.X, cfg.Overlay.Y, cfg.Width(), cfg.Height()) + } + + face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48) + if err != nil { + return nil, fmt.Errorf("load text overlay font: %w", err) + } + defer func() { + if err := face.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close text overlay font: %w", err)) + } + }() + + overlay, err = generator.NewTextOverlay( + cfg.Overlay.Text, + int(cfg.Width()), + int(cfg.Height()), + cfg.Overlay.X, + cfg.Overlay.Y, + cfg.Overlay.Position, + face, + ) + if err != nil { + return nil, fmt.Errorf("create text overlay: %w", err) + } + return overlay, nil +}