Compare commits

...

14 Commits

Author SHA1 Message Date
Dmitry Sergeev 1e804897c6 alpha perfomance fix 2026-09-18 10:25:35 +03:00
Dmitry Sergeev dea2e6a59f alpha patterns 2026-09-18 10:11:07 +03:00
Dmitry Sergeev 9b194b5591 v210A flowdef 2026-09-18 09:54:36 +03:00
Dmitry Sergeev 4d8035a434 V210 issue fix + correct packing 2026-09-18 01:30:11 +03:00
Dmitry Sergeev efa958723a RP219 CPU pattern 2026-09-18 01:02:56 +03:00
Dmitry Sergeev 723bef6342 CPU patterns fallback 2026-09-18 00:41:38 +03:00
Dmitry Sergeev b85293b5d2 CPU generator 2026-09-17 23:27:38 +03:00
Dmitry Sergeev fa787b5ef1 video backend CLI flag 2026-09-17 23:16:59 +03:00
Dmitry Sergeev f5df9506ce static pattern frame cache 2026-09-17 22:59:42 +03:00
Dmitry Sergeev 4d9485220a --no-video flag 2026-09-17 22:46:11 +03:00
Dmitry Sergeev e30cb7a168 refactoring finished 2026-09-17 20:42:38 +03:00
Dmitry Sergeev de4dfcf101 tests 2026-09-17 20:39:41 +03:00
Dmitry Sergeev d26d9442a0 app run in app.go 2026-09-17 20:37:37 +03:00
Dmitry Sergeev d3f0b533e3 runner.go 2026-09-17 20:30:00 +03:00
43 changed files with 2587 additions and 301 deletions
+8
View File
@@ -0,0 +1,8 @@
package assets
import _ "embed"
// JetBrainsMono contains the font used for video text overlays.
//
//go:embed fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf
var JetBrainsMono []byte
+86 -97
View File
@@ -5,7 +5,6 @@ package main
import (
"context"
"errors"
"fmt"
"io"
"log"
@@ -19,14 +18,15 @@ import (
"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 = "MXL pattern generator"
APP_VER = "0.1.0"
APP_NAME = app.Name
APP_VER = app.Version
)
type appArgs struct {
@@ -43,10 +43,13 @@ type appArgs struct {
overlayY int
overlayPos string
videoWidth uint
videoHeight uint
videoFPS string
videoUUID string
videoWidth uint
videoHeight uint
videoFPS string
videoUUID string
noVideo bool
videoAlpha bool
videoBackend string
audioChannels uint8
audioSamplingFreq string
@@ -59,16 +62,6 @@ type parseResult struct {
shouldRun bool
}
type namedRunner struct {
name string
run func(context.Context) error
}
type runnerResult struct {
name string
err error
}
var frameRates = map[string]mxl.Rational{
"23.97": {Num: 24000, Den: 1001},
"24": {Num: 24, Den: 1},
@@ -94,6 +87,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
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)
@@ -102,7 +96,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
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.")
}
@@ -141,15 +135,20 @@ func validateFlowDefPath(label, path string) error {
}
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%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.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")
@@ -168,6 +167,22 @@ 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")
}
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
@@ -199,6 +214,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) },
@@ -273,10 +289,13 @@ 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")
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")
@@ -312,21 +331,41 @@ 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 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 video.Config{}, fmt.Errorf(
return nil, fmt.Errorf(
"read video flow definition %q: %w",
args.videoFlowDefFile,
err,
)
}
definition, err = flowdef.ParseV210Video(data)
definition, err = flowdef.ParseVideo(data)
if err != nil {
return video.Config{}, fmt.Errorf(
return nil, fmt.Errorf(
"parse video flow definition %q: %w",
args.videoFlowDefFile,
err,
@@ -335,11 +374,15 @@ 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
definition, err = flowdef.NewV210Video(
newVideo := flowdef.NewV210Video
if args.videoAlpha {
newVideo = flowdef.NewV210AVideo
}
definition, err = newVideo(
args.videoUUID,
args.videoWidth,
args.videoHeight,
@@ -349,17 +392,22 @@ 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{
backend, err := parseVideoBackend(args.videoBackend)
if err != nil {
return nil, err
}
return &video.Config{
Definition: definition,
Pattern: args.pattern,
Overlay: video.OverlayConfig{
@@ -368,6 +416,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
Y: args.overlayY,
Position: args.overlayPos,
},
Backend: backend,
}, nil
}
@@ -433,7 +482,7 @@ func main() {
}
}
func run(ctx context.Context, args appArgs) (runErr error) {
func run(ctx context.Context, args appArgs) error {
videoCfg, err := buildVideoConfig(args)
if err != nil {
return fmt.Errorf("video configuration: %w", err)
@@ -443,69 +492,9 @@ func run(ctx context.Context, args appArgs) (runErr error) {
return fmt.Errorf("audio configuration: %w", err)
}
log.Printf("%s %s", APP_NAME, APP_VER)
log.Printf("Domain: %s", args.domain)
log.Printf("Video: %dx%d %d/%d",
videoCfg.Width(), videoCfg.Height(), videoCfg.Rate().Num, videoCfg.Rate().Den)
log.Printf("Video ID: %s", videoCfg.ID())
if audioCfg != nil {
log.Printf("Audio: %d channels %d/%d Hz %.0f dBFS",
audioCfg.Channels(), audioCfg.Rate().Num, audioCfg.Rate().Den, audioCfg.LevelDBFS)
log.Printf("Audio ID: %s", audioCfg.ID())
}
inst, err := mxl.NewInstance(args.domain, "")
if err != nil {
return fmt.Errorf("initialize MXL domain %q: %w", args.domain, err)
}
defer func() {
if err := inst.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
}
}()
runners := []namedRunner{
{
name: "video",
run: func(ctx context.Context) error {
return video.Run(ctx, inst, videoCfg)
},
},
}
if audioCfg != nil {
runners = append(runners, namedRunner{
name: "audio",
run: func(ctx context.Context) error {
return audio.Run(ctx, inst, *audioCfg)
},
})
}
return runConcurrent(ctx, runners...)
}
func runConcurrent(ctx context.Context, runners ...namedRunner) error {
if len(runners) == 0 {
return nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan runnerResult, len(runners))
for _, runner := range runners {
runner := runner
go func() {
results <- runnerResult{name: runner.name, err: runner.run(ctx)}
}()
}
var resultErr error
for range runners {
result := <-results
if result.err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s flow: %w", result.name, result.err))
cancel()
}
}
return resultErr
return app.Run(ctx, app.Config{
Domain: args.domain,
Video: videoCfg,
Audio: audioCfg,
})
}
+182 -67
View File
@@ -2,18 +2,30 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef"
"mxl-pattern-generator/internal/video"
"github.com/spf13/pflag"
)
func TestAlphaFlag(t *testing.T) {
var args appArgs
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
addFlags(flags, &args)
if err := flags.Parse([]string{"--alpha"}); err != nil {
t.Fatalf("Parse: %v", err)
}
if !args.videoAlpha {
t.Fatal("videoAlpha = false, want true")
}
}
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
var stdout, stderr bytes.Buffer
@@ -98,6 +110,119 @@ 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 TestParseVideoBackend(t *testing.T) {
tests := []struct {
name string
value string
want video.Backend
wantErr bool
}{
{name: "zero value defaults to auto", want: video.BackendAuto},
{name: "auto", value: "auto", want: video.BackendAuto},
{name: "gpu", value: "gpu", want: video.BackendGPU},
{name: "cpu", value: "cpu", want: video.BackendCPU},
{name: "unknown", value: "other", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseVideoBackend(tc.value)
if tc.wantErr {
if err == nil || !strings.Contains(err.Error(), "unsupported video backend") {
t.Fatalf("error = %v, want unsupported backend error", err)
}
return
}
if err != nil {
t.Fatalf("parseVideoBackend: %v", err)
}
if got != tc.want {
t.Fatalf("backend = %q, want %q", got, tc.want)
}
})
}
}
func TestValidateVideoArgsRejectsUnknownBackend(t *testing.T) {
args := appArgs{videoBackend: "other"}
if err := validateVideoArgs(&args); err == nil || !strings.Contains(err.Error(), "unsupported video backend") {
t.Fatalf("error = %v, want unsupported backend error", err)
}
}
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{},
},
{
name: "alpha without video",
args: appArgs{noVideo: true, videoAlpha: true, audioChannels: 2},
wantErrSub: "cannot be used with --no-video",
},
{
name: "alpha with custom video definition",
args: appArgs{videoAlpha: true, videoFlowDefFile: "video.json"},
wantErrSub: "cannot be used with --video",
},
}
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)
@@ -127,6 +252,60 @@ func TestBuildVideoConfigFromArgs(t *testing.T) {
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den)
}
if cfg.Backend != video.BackendAuto {
t.Fatalf("backend = %q, want %q", cfg.Backend, video.BackendAuto)
}
}
func TestBuildVideoConfigWithAlpha(t *testing.T) {
cfg, err := buildVideoConfig(appArgs{
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
videoWidth: 1280,
videoHeight: 720,
videoFPS: "50",
pattern: "gray-ramp",
videoAlpha: true,
})
if err != nil {
t.Fatalf("buildVideoConfig: %v", err)
}
if cfg.Definition.MediaType != flowdef.MediaTypeV210A {
t.Fatalf("media type = %q, want %q", cfg.Definition.MediaType, flowdef.MediaTypeV210A)
}
if !cfg.HasAlpha() {
t.Fatal("HasAlpha() = false, want true")
}
}
func TestBuildVideoConfigBackends(t *testing.T) {
for _, backend := range []video.Backend{video.BackendGPU, video.BackendCPU} {
t.Run(string(backend), func(t *testing.T) {
cfg, err := buildVideoConfig(appArgs{
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
videoWidth: 1920,
videoHeight: 1080,
videoFPS: "25",
pattern: "ebu75",
videoBackend: string(backend),
})
if err != nil {
t.Fatalf("buildVideoConfig: %v", err)
}
if cfg.Backend != backend {
t.Fatalf("backend = %q, want %q", cfg.Backend, backend)
}
})
}
}
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) {
@@ -253,67 +432,3 @@ func TestValidateAudioArgsRejectsUnknownLevelForFlowDefinition(t *testing.T) {
t.Fatalf("error = %v, want unsupported audio level error", err)
}
}
func TestRunConcurrentCancelsSiblingAndWaitsForCleanup(t *testing.T) {
wantErr := errors.New("writer failed")
peerStarted := make(chan struct{})
peerStopped := make(chan struct{})
err := runConcurrent(context.Background(),
namedRunner{
name: "video",
run: func(ctx context.Context) error {
<-peerStarted
return wantErr
},
},
namedRunner{
name: "audio",
run: func(ctx context.Context) error {
close(peerStarted)
<-ctx.Done()
close(peerStopped)
return nil
},
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
}
if !strings.Contains(err.Error(), "video flow") {
t.Fatalf("error = %q, want runner name", err)
}
select {
case <-peerStopped:
default:
t.Fatal("runConcurrent returned before the sibling completed cleanup")
}
}
func TestRunConcurrentParentCancellationIsGraceful(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- runConcurrent(ctx, namedRunner{
name: "video",
run: func(ctx context.Context) error {
close(started)
<-ctx.Done()
return nil
},
})
}()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runConcurrent: %v", err)
}
case <-time.After(time.Second):
t.Fatal("runConcurrent did not stop after parent cancellation")
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"context"
"errors"
"fmt"
"log"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/video"
"github.com/qvest-digital/go-mxl/mxl"
)
const (
Name = "MXL pattern generator"
Version = "0.2.0"
)
type Config struct {
Domain string
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)
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)
}
defer func() {
if err := inst.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
}
}()
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)
},
})
}
if cfg.Audio != nil {
runners = append(runners, Runner{
Name: "audio",
Run: func(ctx context.Context) error {
return audio.Run(ctx, inst, *cfg.Audio)
},
})
}
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
}
+15
View File
@@ -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)
}
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"context"
"errors"
"fmt"
)
type Runner struct {
Name string
Run func(context.Context) error
}
type runnerResult struct {
name string
err error
}
func RunConcurrent(ctx context.Context, runners ...Runner) error {
if len(runners) == 0 {
return nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan runnerResult, len(runners))
for _, runner := range runners {
runner := runner
go func() {
results <- runnerResult{name: runner.Name, err: runner.Run(ctx)}
}()
}
var resultErr error
for range runners {
result := <-results
if result.err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s flow: %w", result.name, result.err))
cancel()
}
}
return resultErr
}
+74
View File
@@ -0,0 +1,74 @@
package app_test
import (
"context"
"errors"
"mxl-pattern-generator/internal/app"
"strings"
"testing"
"time"
)
func TestRunConcurrentCancelsSiblingAndWaitsForCleanup(t *testing.T) {
wantErr := errors.New("writer failed")
peerStarted := make(chan struct{})
peerStopped := make(chan struct{})
err := app.RunConcurrent(context.Background(),
app.Runner{
Name: "video",
Run: func(ctx context.Context) error {
<-peerStarted
return wantErr
},
},
app.Runner{
Name: "audio",
Run: func(ctx context.Context) error {
close(peerStarted)
<-ctx.Done()
close(peerStopped)
return nil
},
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
}
if !strings.Contains(err.Error(), "video flow") {
t.Fatalf("error = %q, want runner name", err)
}
select {
case <-peerStopped:
default:
t.Fatal("runConcurrent returned before the sibling completed cleanup")
}
}
func TestRunConcurrentParentCancellationIsGraceful(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- app.RunConcurrent(ctx, app.Runner{
Name: "video",
Run: func(ctx context.Context) error {
close(started)
<-ctx.Done()
return nil
},
})
}()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runConcurrent: %v", err)
}
case <-time.After(time.Second):
t.Fatal("runConcurrent did not stop after parent cancellation")
}
}
+23 -7
View File
@@ -12,6 +12,7 @@ const (
FormatVideo = "urn:x-nmos:format:video"
FormatAudio = "urn:x-nmos:format:audio"
MediaTypeV210 = "video/v210"
MediaTypeV210A = "video/v210a"
MediaTypeFloat32 = "audio/float32"
InterlaceProgressive = "progressive"
@@ -75,7 +76,12 @@ type VideoComponent struct {
BitDepth uint `json:"bit_depth"`
}
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
func newVideo(
id string,
width, height uint,
rate Rational,
mediaType string,
) (Video, error) {
definition := Video{
Common: Common{
Description: "go-mxl-pattern-gen generated video",
@@ -86,7 +92,7 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
Format: FormatVideo,
Label: "go-mxl-pattern-gen generated video",
Parents: []string{},
MediaType: MediaTypeV210,
MediaType: mediaType,
},
GrainRate: rate,
FrameWidth: width,
@@ -105,7 +111,15 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
return definition, nil
}
func ParseV210Video(data []byte) (Video, error) {
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
return newVideo(id, width, height, rate, MediaTypeV210)
}
func NewV210AVideo(id string, width, height uint, rate Rational) (Video, error) {
return newVideo(id, width, height, rate, MediaTypeV210A)
}
func ParseVideo(data []byte) (Video, error) {
var definition Video
if err := json.Unmarshal(data, &definition); err != nil {
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
@@ -123,8 +137,10 @@ func (v Video) Validate() error {
if v.Format != FormatVideo {
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
}
if v.MediaType != MediaTypeV210 {
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, v.MediaType)
switch v.MediaType {
case MediaTypeV210, MediaTypeV210A:
default:
return fmt.Errorf("media_type must be %q or %q, got %q", MediaTypeV210, MediaTypeV210A, v.MediaType)
}
if v.InterlaceMode != InterlaceProgressive {
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
@@ -132,8 +148,8 @@ func (v Video) Validate() error {
if v.ColorSpace != ColorSpaceBT709 {
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
}
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 {
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth)
if v.FrameWidth == 0 || v.FrameWidth%2 != 0 {
return fmt.Errorf("frame_width must be greater than zero and even for 4:2:2 video, got %d", v.FrameWidth)
}
if v.FrameHeight == 0 {
return fmt.Errorf("frame_height must be greater than zero")
+62 -9
View File
@@ -28,14 +28,37 @@ func TestNewV210Video(t *testing.T) {
}
}
func TestNewV210VideoRejectsInvalidWidth(t *testing.T) {
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
if err == nil || !strings.Contains(err.Error(), "divisible by 6") {
t.Fatalf("error = %v, want width divisibility error", err)
func TestNewV210AVideo(t *testing.T) {
definition, err := NewV210AVideo(testVideoID, 1280, 720, Rational{Numerator: 50, Denominator: 1})
if err != nil {
t.Fatalf("NewV210AVideo: %v", err)
}
if definition.MediaType != MediaTypeV210A {
t.Fatalf("media type = %q, want %q", definition.MediaType, MediaTypeV210A)
}
wantComponents := []VideoComponent{
{Name: "Y", Width: 1280, Height: 720, BitDepth: 10},
{Name: "Cb", Width: 640, Height: 720, BitDepth: 10},
{Name: "Cr", Width: 640, Height: 720, BitDepth: 10},
}
if len(definition.Components) != len(wantComponents) {
t.Fatalf("component count = %d, want %d", len(definition.Components), len(wantComponents))
}
for i, want := range wantComponents {
if definition.Components[i] != want {
t.Fatalf("component %d = %+v, want %+v", i, definition.Components[i], want)
}
}
}
func TestParseV210Video(t *testing.T) {
func TestNewV210VideoRejectsOddWidth(t *testing.T) {
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
if err == nil || !strings.Contains(err.Error(), "even") {
t.Fatalf("error = %v, want even-width error", err)
}
}
func TestParseVideo(t *testing.T) {
want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
@@ -45,28 +68,58 @@ func TestParseV210Video(t *testing.T) {
t.Fatalf("json.Marshal: %v", err)
}
got, err := ParseV210Video(data)
got, err := ParseVideo(data)
if err != nil {
t.Fatalf("ParseV210Video: %v", err)
t.Fatalf("ParseVideo: %v", err)
}
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
t.Fatalf("parsed definition = %+v, want %+v", got, want)
}
}
func TestParseV210VideoRejectsAudio(t *testing.T) {
func TestParseVideoAcceptsV210A(t *testing.T) {
want, err := NewV210AVideo(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210AVideo: %v", err)
}
data, err := json.Marshal(want)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got, err := ParseVideo(data)
if err != nil {
t.Fatalf("ParseVideo: %v", err)
}
if got.MediaType != MediaTypeV210A {
t.Fatalf("media type = %q, want %q", got.MediaType, MediaTypeV210A)
}
}
func TestParseVideoRejectsAudio(t *testing.T) {
data := []byte(`{
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
"format":"urn:x-nmos:format:audio",
"media_type":"audio/float32"
}`)
_, err := ParseV210Video(data)
_, err := ParseVideo(data)
if err == nil || !strings.Contains(err.Error(), "format must be") {
t.Fatalf("error = %v, want video format error", err)
}
}
func TestVideoRejectsUnknownMediaType(t *testing.T) {
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
definition.MediaType = "video/unknown"
if err := definition.Validate(); err == nil || !strings.Contains(err.Error(), "media_type") {
t.Fatalf("error = %v, want media_type error", err)
}
}
func TestNewFloat32Audio(t *testing.T) {
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
if err != nil {
+92
View File
@@ -0,0 +1,92 @@
package generator
import (
"encoding/binary"
"fmt"
"math"
)
const (
alphaTransparent uint32 = 64
alphaOpaque uint32 = 940
)
func packAlphaBlock(dst []byte, samples [3]uint32) {
const mask uint32 = 0x3ff
word := samples[0]&mask |
(samples[1]&mask)<<10 |
(samples[2]&mask)<<20
binary.LittleEndian.PutUint32(dst, word)
}
func fillAlphaPlane(
dst []byte,
width, height int,
value uint32,
) error {
need := AlphaFrameSize(width, height)
if len(dst) < need {
return fmt.Errorf(
"alpha: destination is too small: got %d bytes, need %d",
len(dst),
need,
)
}
stride := AlphaLineSize(width)
for y := 0; y < height; y++ {
row := dst[y*stride : (y+1)*stride]
for x := 0; x < width; x += 3 {
var samples [3]uint32
for i := range samples {
if x+i < width {
samples[i] = value
}
}
packAlphaBlock(row[x/3*4:], samples)
}
}
return nil
}
func patchAlphaMovingSquare(dst []byte, width, height, frameIndex int) error {
need := AlphaFrameSize(width, height)
if len(dst) < need {
return fmt.Errorf(
"alpha: destination is too small: got %d bytes, need %d",
len(dst),
need,
)
}
bounds := movingSquareBounds(width, height, frameIndex)
firstPixelX := max(0, int(math.Floor(bounds.minX)))
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
firstBlockX := firstPixelX / 3 * 3
lastBlockX := min(width, (lastPixelX+2)/3*3)
firstY := max(0, int(math.Floor(bounds.minY)))
lastY := min(height, int(math.Ceil(bounds.maxY)))
stride := AlphaLineSize(width)
for y := firstY; y < lastY; y++ {
for blockX := firstBlockX; blockX < lastBlockX; blockX += 3 {
var samples [3]uint32
for i := range samples {
x := blockX + i
switch {
case x >= width:
samples[i] = 0
case bounds.contains(x, y):
samples[i] = alphaTransparent
default:
samples[i] = alphaOpaque
}
}
offset := y*stride + blockX/3*4
packAlphaBlock(dst[offset:], samples)
}
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package generator
import (
"encoding/binary"
"strings"
"testing"
)
func TestPackAlphaBlock(t *testing.T) {
var dst [4]byte
packAlphaBlock(dst[:], [3]uint32{64, 512, 940})
word := binary.LittleEndian.Uint32(dst[:])
if got := word & 0x3ff; got != 64 {
t.Errorf("sample 0 = %d, want 64", got)
}
if got := (word >> 10) & 0x3ff; got != 512 {
t.Errorf("sample 1 = %d, want 512", got)
}
if got := (word >> 20) & 0x3ff; got != 940 {
t.Errorf("sample 2 = %d, want 940", got)
}
if got := word >> 30; got != 0 {
t.Errorf("unused bits = %d, want 0", got)
}
}
func TestPackAlphaBlockMasksSamples(t *testing.T) {
var dst [4]byte
packAlphaBlock(dst[:], [3]uint32{0x401, 0x802, 0xc03})
word := binary.LittleEndian.Uint32(dst[:])
if got := word & 0x3ff; got != 1 {
t.Errorf("sample 0 = %d, want 1", got)
}
if got := (word >> 10) & 0x3ff; got != 2 {
t.Errorf("sample 1 = %d, want 2", got)
}
if got := (word >> 20) & 0x3ff; got != 3 {
t.Errorf("sample 2 = %d, want 3", got)
}
}
func TestFillAlphaPlaneCompleteBlocks(t *testing.T) {
const width, height = 6, 2
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
if got := sampleAlpha(dst, width, x, y); got != alphaOpaque {
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaOpaque)
}
}
}
}
func TestFillAlphaPlaneZerosPartialBlockPadding(t *testing.T) {
const width, height = 4, 2
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaTransparent); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
stride := AlphaLineSize(width)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
if got := sampleAlpha(dst, width, x, y); got != alphaTransparent {
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaTransparent)
}
}
lastWord := binary.LittleEndian.Uint32(dst[y*stride+4:])
if got := (lastWord >> 10) & 0x3ff; got != 0 {
t.Errorf("row %d padding sample 1 = %d, want 0", y, got)
}
if got := (lastWord >> 20) & 0x3ff; got != 0 {
t.Errorf("row %d padding sample 2 = %d, want 0", y, got)
}
if got := lastWord >> 30; got != 0 {
t.Errorf("row %d unused bits = %d, want 0", y, got)
}
}
}
func TestFillAlphaPlaneRejectsSmallDestination(t *testing.T) {
const width, height = 6, 2
dst := make([]byte, AlphaFrameSize(width, height)-1)
err := fillAlphaPlane(dst, width, height, alphaOpaque)
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
t.Fatalf("error = %v, want destination size error", err)
}
}
func TestPatchAlphaMovingSquare(t *testing.T) {
const width, height = 304, 200
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
t.Fatalf("patchAlphaMovingSquare: %v", err)
}
if got := sampleAlpha(dst, width, width/2, height/2); got != alphaTransparent {
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
}
if got := sampleAlpha(dst, width, 10, height/2); got != alphaOpaque {
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
}
// At frame zero the square begins at x=77. Its first three-sample word
// therefore contains two opaque samples followed by one transparent sample.
for x, want := range []uint32{alphaOpaque, alphaOpaque, alphaTransparent} {
if got := sampleAlpha(dst, width, 75+x, height/2); got != want {
t.Errorf("boundary sample x=%d = %d, want %d", 75+x, got, want)
}
}
}
func TestPatchAlphaMovingSquarePreservesPartialBlockPadding(t *testing.T) {
const width, height = 100, 200
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
t.Fatalf("patchAlphaMovingSquare: %v", err)
}
lastWordOffset := height/2*AlphaLineSize(width) + (width/3)*4
lastWord := binary.LittleEndian.Uint32(dst[lastWordOffset:])
if got := lastWord & 0x3ff; got != alphaTransparent {
t.Errorf("last visible sample = %d, want %d", got, alphaTransparent)
}
if got := lastWord >> 10; got != 0 {
t.Errorf("partial-block padding bits = %#x, want 0", got)
}
}
func TestPatchAlphaMovingSquareRejectsSmallDestination(t *testing.T) {
const width, height = 100, 200
err := patchAlphaMovingSquare(
make([]byte, AlphaFrameSize(width, height)-1),
width,
height,
0,
)
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
t.Fatalf("error = %v, want destination size error", err)
}
}
func sampleAlpha(buf []byte, width, x, y int) uint32 {
offset := y*AlphaLineSize(width) + x/3*4
word := binary.LittleEndian.Uint32(buf[offset:])
return (word >> uint(x%3*10)) & 0x3ff
}
+84
View File
@@ -0,0 +1,84 @@
package generator
import (
"encoding/binary"
"fmt"
)
type YCbCr10 struct {
Y uint32
Cb uint32
Cr uint32
}
// FrameRenderer writes a complete frame or patches part of an existing frame.
type FrameRenderer func(dst []byte, width, height, frameIndex int) error
type CPUGenerator struct {
width int
height int
base []byte
patch FrameRenderer
}
func NewCPUGenerator(
width, height uint,
baseRenderer FrameRenderer,
patch FrameRenderer,
) (*CPUGenerator, error) {
if width == 0 || height == 0 {
return nil, fmt.Errorf("cpu: width and height must be greater than zero, got %dx%d", width, height)
}
if width%2 != 0 {
return nil, fmt.Errorf("cpu: width must be even for 4:2:2 video, got %d", width)
}
if baseRenderer == nil {
return nil, fmt.Errorf("cpu: base renderer is nil")
}
g := &CPUGenerator{
width: int(width),
height: int(height),
base: make([]byte, V210FrameSize(int(width), int(height))),
patch: patch,
}
if err := baseRenderer(g.base, g.width, g.height, 0); err != nil {
return nil, fmt.Errorf("cpu: render base frame: %w", err)
}
return g, nil
}
func (g *CPUGenerator) GenerateFrame(dst []byte, frameIndex int) error {
if len(dst) < len(g.base) {
return fmt.Errorf(
"cpu: destination is too small: got %d bytes, need %d",
len(dst),
len(g.base),
)
}
copy(dst, g.base)
if g.patch != nil {
if err := g.patch(dst[:len(g.base)], g.width, g.height, frameIndex); err != nil {
return fmt.Errorf("cpu: patch frame %d: %w", frameIndex, err)
}
}
return nil
}
func (g *CPUGenerator) Close() error { return nil }
func packV210Block(dst []byte, pixels [6]YCbCr10) {
const mask uint32 = 0x3ff
word0 := pixels[0].Cb&mask | (pixels[0].Y&mask)<<10 | (pixels[0].Cr&mask)<<20
word1 := pixels[1].Y&mask | (pixels[2].Cb&mask)<<10 | (pixels[2].Y&mask)<<20
word2 := pixels[2].Cr&mask | (pixels[3].Y&mask)<<10 | (pixels[4].Cb&mask)<<20
word3 := pixels[4].Y&mask | (pixels[4].Cr&mask)<<10 | (pixels[5].Y&mask)<<20
binary.LittleEndian.PutUint32(dst, word0)
binary.LittleEndian.PutUint32(dst[4:], word1)
binary.LittleEndian.PutUint32(dst[8:], word2)
binary.LittleEndian.PutUint32(dst[12:], word3)
}
+74
View File
@@ -0,0 +1,74 @@
package generator
import "math"
const movingSquareSize = 150
type baseColorFunc func(x, y, width, height int) YCbCr10
type squareBounds struct {
minX float64
maxX float64
minY float64
maxY float64
}
func movingSquareBounds(width, height, frameIndex int) squareBounds {
const half = float64(movingSquareSize) / 2
centerX := float64(width) / 2
centerY := float64(height) / 2
time := float64(frameIndex) / 25.0
offset := math.Sin(time*0.5) * (centerX - half)
return squareBounds{
minX: centerX - half + offset,
maxX: centerX + half + offset,
minY: centerY - half,
maxY: centerY + half,
}
}
func (b squareBounds) contains(x, y int) bool {
return float64(x) >= b.minX && float64(x) < b.maxX &&
float64(y) >= b.minY && float64(y) < b.maxY
}
func patchMovingSquare(
dst []byte,
width, height, frameIndex int,
baseColor baseColorFunc,
) error {
bounds := movingSquareBounds(width, height, frameIndex)
firstPixelX := max(0, int(math.Floor(bounds.minX)))
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
firstBlockX := firstPixelX / 6 * 6
lastBlockX := min(width, (lastPixelX+5)/6*6)
firstY := max(0, int(math.Floor(bounds.minY)))
lastY := min(height, int(math.Ceil(bounds.maxY)))
stride := V210LineSize(width)
for y := firstY; y < lastY; y++ {
for x := firstBlockX; x < lastBlockX; x += 6 {
var pixels [6]YCbCr10
for i := range pixels {
px := x + i
color := baseColor(px, y, width, height)
if bounds.contains(px, y) {
color = invertStudioRange(color)
}
pixels[i] = color
}
offset := y*stride + x/6*16
packV210Block(dst[offset:offset+16], pixels)
}
}
return nil
}
func invertStudioRange(color YCbCr10) YCbCr10 {
return YCbCr10{
Y: 1004 - color.Y,
Cb: 1024 - color.Cb,
Cr: 1024 - color.Cr,
}
}
+245
View File
@@ -0,0 +1,245 @@
package generator
import (
"fmt"
)
var ebu75Colors = [...]YCbCr10{
{Y: 721, Cb: 512, Cr: 512},
{Y: 674, Cb: 176, Cr: 543},
{Y: 581, Cb: 589, Cr: 176},
{Y: 534, Cb: 253, Cr: 207},
{Y: 251, Cb: 771, Cr: 817},
{Y: 204, Cb: 435, Cr: 848},
{Y: 111, Cb: 848, Cr: 481},
{Y: 64, Cb: 512, Cr: 512},
}
var ebu100Colors = [...]YCbCr10{
{Y: 940, Cb: 512, Cr: 512},
{Y: 877, Cb: 64, Cr: 553},
{Y: 754, Cb: 615, Cr: 64},
{Y: 691, Cb: 167, Cr: 105},
{Y: 313, Cb: 857, Cr: 919},
{Y: 250, Cb: 409, Cr: 960},
{Y: 127, Cb: 960, Cr: 471},
{Y: 64, Cb: 512, Cr: 512},
}
var smpte75Colors = [...]YCbCr10{
{Y: 721, Cb: 512, Cr: 512},
{Y: 674, Cb: 176, Cr: 543},
{Y: 581, Cb: 589, Cr: 176},
{Y: 534, Cb: 253, Cr: 207},
{Y: 251, Cb: 771, Cr: 817},
{Y: 204, Cb: 435, Cr: 848},
{Y: 111, Cb: 848, Cr: 481},
}
var grayBarsColors = [...]YCbCr10{
{Y: 64, Cb: 512, Cr: 512},
{Y: 137, Cb: 512, Cr: 512},
{Y: 210, Cb: 512, Cr: 512},
{Y: 283, Cb: 512, Cr: 512},
{Y: 356, Cb: 512, Cr: 512},
{Y: 429, Cb: 512, Cr: 512},
{Y: 502, Cb: 512, Cr: 512},
{Y: 575, Cb: 512, Cr: 512},
{Y: 648, Cb: 512, Cr: 512},
{Y: 721, Cb: 512, Cr: 512},
{Y: 794, Cb: 512, Cr: 512},
{Y: 867, Cb: 512, Cr: 512},
{Y: 940, Cb: 512, Cr: 512},
}
var (
ebu75BaseColor = colorBars(ebu75Colors[:])
ebu100BaseColor = colorBars(ebu100Colors[:])
grayBarsBaseColor = colorBars(grayBarsColors[:])
)
func NewCPUPatternGenerator(width, height uint, pattern string) (*CPUGenerator, error) {
var baseColor baseColorFunc
var dynamic bool
switch pattern {
case "ebu75":
baseColor = ebu75BaseColor
case "ebu75-move":
baseColor, dynamic = ebu75BaseColor, true
case "ebu100":
baseColor = ebu100BaseColor
case "ebu100-move":
baseColor, dynamic = ebu100BaseColor, true
case "smpte":
if err := validateRP219Size(width, height); err != nil {
return nil, err
}
baseColor = rp219BaseColor
case "smpte-move":
if err := validateRP219Size(width, height); err != nil {
return nil, err
}
baseColor, dynamic = rp219BaseColor, true
case "gray-bars":
baseColor = grayBarsBaseColor
case "gray-bars-move":
baseColor, dynamic = grayBarsBaseColor, true
case "gray-ramp":
baseColor = grayRampBaseColor
case "gray-ramp-move":
baseColor, dynamic = grayRampBaseColor, true
default:
return nil, fmt.Errorf("cpu pattern %q is not implemented", pattern)
}
var patch FrameRenderer
if dynamic {
patch = movingSquarePatch(baseColor)
}
return NewCPUGenerator(width, height, baseRenderer(baseColor), patch)
}
func grayRampBaseColor(x, _, width, _ int) YCbCr10 {
return YCbCr10{
Y: uint32(64 + (x*876)/width),
Cb: 512,
Cr: 512,
}
}
func colorBars(colors []YCbCr10) baseColorFunc {
return func(x, _, width, _ int) YCbCr10 {
bar := min(x*len(colors)/width, len(colors)-1)
return colors[bar]
}
}
func baseRenderer(baseColor baseColorFunc) FrameRenderer {
return func(dst []byte, width, height, _ int) error {
return renderBasePattern(dst, width, height, baseColor)
}
}
func movingSquarePatch(baseColor baseColorFunc) FrameRenderer {
return func(dst []byte, width, height, frameIndex int) error {
return patchMovingSquare(dst, width, height, frameIndex, baseColor)
}
}
func renderBasePattern(dst []byte, width, height int, baseColor baseColorFunc) error {
stride := V210LineSize(width)
for y := 0; y < height; y++ {
for x := 0; x < width; x += 6 {
var pixels [6]YCbCr10
for i := range pixels {
pixels[i] = baseColor(x+i, y, width, height)
}
offset := y*stride + x/6*16
packV210Block(dst[offset:offset+16], pixels)
}
}
return nil
}
var (
gray40 = YCbCr10{Y: 414, Cb: 512, Cr: 512}
gray15 = YCbCr10{Y: 195, Cb: 512, Cr: 512}
black = YCbCr10{Y: 64, Cb: 512, Cr: 512}
white = YCbCr10{Y: 940, Cb: 512, Cr: 512}
)
func rp219BaseColor(x, y, width, height int) YCbCr10 {
barsWidth := (height / 3) * 4
barsStart := (width - barsWidth) / 2
barsEnd := barsStart + barsWidth
oneBarWidth := barsWidth / 7
unitH := height / 12
section1End := unitH * 7
section2End := section1End + unitH
section3End := section2End + unitH
rampStart := barsStart + oneBarWidth
rampEnd := barsEnd
rampWidth := rampEnd - rampStart
switch {
case y < section1End:
if x < barsStart || x >= barsEnd {
return gray40
}
bar := min(
(x-barsStart)*len(smpte75Colors)/barsWidth,
len(smpte75Colors)-1,
)
return smpte75Colors[bar]
case y < section2End:
switch {
case x < barsStart:
return YCbCr10{Y: 754, Cb: 615, Cr: 64} // 100% cyan
case x < rampStart:
return YCbCr10{Y: 244, Cb: 612, Cr: 395} // -I
case x >= barsEnd:
return YCbCr10{Y: 127, Cb: 960, Cr: 471} // 100% blue
default:
return YCbCr10{Y: 721, Cb: 512, Cr: 512} // 75% white
}
case y < section3End:
switch {
case x < barsStart:
return YCbCr10{Y: 877, Cb: 64, Cr: 553} // 100% yellow
case x < rampStart:
return YCbCr10{Y: 141, Cb: 697, Cr: 606} // +Q
case x >= barsEnd:
return YCbCr10{Y: 250, Cb: 409, Cr: 960} // 75% red
default:
rampY := 64 + ((x-rampStart)*876)/rampWidth
return YCbCr10{Y: uint32(rampY), Cb: 512, Cr: 512}
}
default:
if x < barsStart || x >= barsEnd {
return gray15
}
bar := (x - barsStart) / oneBarWidth
switch {
case bar == 1:
return white
case bar == 3:
offset := (x - barsStart) % oneBarWidth
subBar := offset * 3 / oneBarWidth
switch subBar {
case 0:
return YCbCr10{Y: 46, Cb: 512, Cr: 512}
case 1:
return black
default:
return YCbCr10{Y: 82, Cb: 512, Cr: 512}
}
default:
return black
}
}
}
func validateRP219Size(width, height uint) error {
if height < 12 {
return fmt.Errorf("cpu RP 219 requires frame height of at least 12, got %d", height)
}
barsWidth := (height / 3) * 4
if barsWidth < 7 || width < barsWidth {
return fmt.Errorf(
"cpu RP 219 requires frame width %d to fit a 4:3 pattern area for height %d, got %d",
barsWidth,
height,
width,
)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package generator
import (
"strings"
"testing"
)
func TestCPUEBU75Static(t *testing.T) {
const width, height = 1920, 1080
g, err := NewCPUPatternGenerator(width, height, "ebu75")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 99); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
for _, tc := range []struct {
x int
wantY, wantCb, wantCr uint32
}{
{x: 0, wantY: 721, wantCb: 512, wantCr: 512},
{x: 300, wantY: 674, wantCb: 176, wantCr: 543},
{x: 960, wantY: 251, wantCb: 771, wantCr: 817},
{x: 1800, wantY: 64, wantCb: 512, wantCr: 512},
} {
y, cb, cr := sampleV210(frame, width, tc.x, 100)
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
t.Fatalf("x=%d: got %d/%d/%d, want %d/%d/%d",
tc.x, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
}
}
}
func TestCPUEBU75MovingSquare(t *testing.T) {
const width, height = 1920, 1080
g, err := NewCPUPatternGenerator(width, height, "ebu75-move")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame(0): %v", err)
}
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 753 || cb != 253 || cr != 207 {
t.Fatalf("tick 0 center = %d/%d/%d, want inverted magenta 753/253/207", y, cb, cr)
}
if err := g.GenerateFrame(frame, 79); err != nil {
t.Fatalf("GenerateFrame(79): %v", err)
}
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 251 || cb != 771 || cr != 817 {
t.Fatalf("old square position = %d/%d/%d, want restored magenta 251/771/817", y, cb, cr)
}
if y, cb, cr := sampleV210(frame, width, 1840, 540); y != 940 || cb != 512 || cr != 512 {
t.Fatalf("shifted square = %d/%d/%d, want inverted black 940/512/512", y, cb, cr)
}
}
func TestCPUStaticPatterns(t *testing.T) {
const width, height = 1920, 1080
tests := []struct {
name string
x int
wantY, wantCb, wantCr uint32
}{
{name: "ebu100", x: 0, wantY: 940, wantCb: 512, wantCr: 512},
{name: "ebu100", x: 300, wantY: 877, wantCb: 64, wantCr: 553},
{name: "ebu100", x: 960, wantY: 313, wantCb: 857, wantCr: 919},
{name: "gray-bars", x: 0, wantY: 64, wantCb: 512, wantCr: 512},
{name: "gray-bars", x: 960, wantY: 502, wantCb: 512, wantCr: 512},
{name: "gray-bars", x: 1800, wantY: 940, wantCb: 512, wantCr: 512},
{name: "gray-ramp", x: 0, wantY: 64, wantCb: 512, wantCr: 512},
{name: "gray-ramp", x: 6, wantY: 66, wantCb: 512, wantCr: 512},
{name: "gray-ramp", x: 960, wantY: 502, wantCb: 512, wantCr: 512},
{name: "gray-ramp", x: 1918, wantY: 939, wantCb: 512, wantCr: 512},
}
frames := make(map[string][]byte)
for _, tc := range tests {
frame, ok := frames[tc.name]
if !ok {
g, err := NewCPUPatternGenerator(width, height, tc.name)
if err != nil {
t.Fatalf("NewCPUPatternGenerator(%q): %v", tc.name, err)
}
frame = make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame(%q): %v", tc.name, err)
}
frames[tc.name] = frame
}
y, cb, cr := sampleV210(frame, width, tc.x, 100)
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
t.Errorf("%s x=%d: got %d/%d/%d, want %d/%d/%d",
tc.name, tc.x, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
}
}
}
func TestCPUMovingPatternUsesOwnBaseColor(t *testing.T) {
const width, height = 1920, 1080
g, err := NewCPUPatternGenerator(width, height, "ebu100-move")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 691 || cb != 167 || cr != 105 {
t.Fatalf("center = %d/%d/%d, want inverted EBU100 magenta 691/167/105", y, cb, cr)
}
}
func TestCPURP219Pattern(t *testing.T) {
const width, height = 1920, 1080
g, err := NewCPUPatternGenerator(width, height, "smpte")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
tests := []struct {
name string
x, y int
wantY, wantCb, wantCr uint32
}{
{"top left gray flank", 100, 100, 414, 512, 512},
{"top white bar", 300, 100, 721, 512, 512},
{"top green bar", 960, 100, 534, 253, 207},
{"top right gray flank", 1800, 100, 414, 512, 512},
{"section 2 cyan flank", 100, 650, 754, 615, 64},
{"section 2 minus I", 300, 650, 244, 612, 395},
{"section 2 white", 600, 650, 721, 512, 512},
{"section 2 blue flank", 1800, 650, 127, 960, 471},
{"section 3 yellow flank", 100, 750, 877, 64, 553},
{"section 3 plus Q", 300, 750, 141, 697, 606},
{"section 3 red flank", 1800, 750, 250, 409, 960},
{"bottom gray flank", 100, 900, 195, 512, 512},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
y, cb, cr := sampleV210(frame, width, tc.x, tc.y)
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
t.Fatalf("pixel (%d,%d): got %d/%d/%d, want %d/%d/%d",
tc.x, tc.y, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
}
})
}
}
func TestCPURP219MovingSquare(t *testing.T) {
const width, height = 1920, 1080
g, err := NewCPUPatternGenerator(width, height, "smpte-move")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, width*height*8/3)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame(0): %v", err)
}
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 470 || cb != 771 || cr != 817 {
t.Fatalf("tick 0 center = %d/%d/%d, want inverted green 470/771/817", y, cb, cr)
}
if err := g.GenerateFrame(frame, 79); err != nil {
t.Fatalf("GenerateFrame(79): %v", err)
}
if y, cb, cr := sampleV210(frame, width, 1840, 540); y != 590 || cb != 512 || cr != 512 {
t.Fatalf("tick 79 shifted square = %d/%d/%d, want inverted gray 590/512/512", y, cb, cr)
}
}
func TestCPURP219RejectsInvalidGeometry(t *testing.T) {
for _, tc := range []struct {
name string
width, height uint
}{
{name: "height too small", width: 1920, height: 11},
{name: "canvas too narrow", width: 600, height: 1080},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := NewCPUPatternGenerator(tc.width, tc.height, "smpte")
if err == nil || !strings.Contains(err.Error(), "RP 219") {
t.Fatalf("error = %v, want RP 219 geometry error", err)
}
})
}
}
func TestNewCPUPatternGeneratorRejectsUnknownPattern(t *testing.T) {
_, err := NewCPUPatternGenerator(1920, 1080, "unknown")
if err == nil || !strings.Contains(err.Error(), "not implemented") {
t.Fatalf("error = %v, want unsupported pattern error", err)
}
}
+147
View File
@@ -0,0 +1,147 @@
package generator
import (
"errors"
"strings"
"testing"
)
func fillFrame(value byte) FrameRenderer {
return func(dst []byte, _, _, _ int) error {
for i := range dst {
dst[i] = value
}
return nil
}
}
func TestNewCPUGeneratorValidation(t *testing.T) {
renderer := fillFrame(0)
tests := []struct {
name string
width uint
height uint
renderer FrameRenderer
wantErrSub string
}{
{name: "zero width", height: 1, renderer: renderer, wantErrSub: "greater than zero"},
{name: "zero height", width: 6, renderer: renderer, wantErrSub: "greater than zero"},
{name: "odd width", width: 7, height: 1, renderer: renderer, wantErrSub: "even"},
{name: "nil renderer", width: 6, height: 1, wantErrSub: "renderer is nil"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewCPUGenerator(tc.width, tc.height, tc.renderer, nil)
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
}
})
}
}
func TestCPUGeneratorCopiesBaseAndRestoresBeforePatch(t *testing.T) {
patch := func(dst []byte, _, _, frameIndex int) error {
switch frameIndex {
case 1:
dst[0] = 0x22
case 2:
dst[16] = 0x33
}
return nil
}
g, err := NewCPUGenerator(12, 1, fillFrame(0x11), patch)
if err != nil {
t.Fatalf("NewCPUGenerator: %v", err)
}
frame := make([]byte, V210FrameSize(12, 1))
if err := g.GenerateFrame(frame, 1); err != nil {
t.Fatalf("GenerateFrame(1): %v", err)
}
if frame[0] != 0x22 {
t.Fatalf("frame 1 patch byte = %#x, want 0x22", frame[0])
}
if err := g.GenerateFrame(frame, 2); err != nil {
t.Fatalf("GenerateFrame(2): %v", err)
}
if frame[0] != 0x11 {
t.Fatalf("old patch byte = %#x, want restored base 0x11", frame[0])
}
if frame[16] != 0x33 {
t.Fatalf("frame 2 patch byte = %#x, want 0x33", frame[16])
}
}
func TestPackV210Block(t *testing.T) {
var pixels [6]YCbCr10
for i := range pixels {
pixels[i] = YCbCr10{
Y: uint32(100 + i),
Cb: uint32(200 + i),
Cr: uint32(300 + i),
}
}
frame := make([]byte, 16)
packV210Block(frame, pixels)
for x := range pixels {
y, cb, cr := sampleV210(frame, 6, x, 0)
chromaX := x - x%2
if y != pixels[x].Y || cb != pixels[chromaX].Cb || cr != pixels[chromaX].Cr {
t.Fatalf("pixel %d: got %d/%d/%d, want %d/%d/%d",
x, y, cb, cr, pixels[x].Y, pixels[chromaX].Cb, pixels[chromaX].Cr)
}
}
}
func TestPackV210BlockMasksComponents(t *testing.T) {
pixel := YCbCr10{Y: 0xC01, Cb: 0xC02, Cr: 0xC03}
frame := make([]byte, 16)
packV210Block(frame, [6]YCbCr10{pixel, pixel, pixel, pixel, pixel, pixel})
y, cb, cr := sampleV210(frame, 6, 0, 0)
if y != 1 || cb != 2 || cr != 3 {
t.Fatalf("masked components = %d/%d/%d, want 1/2/3", y, cb, cr)
}
}
func TestCPUGeneratorErrors(t *testing.T) {
wantErr := errors.New("patch failed")
g, err := NewCPUGenerator(6, 1, fillFrame(0), func([]byte, int, int, int) error {
return wantErr
})
if err != nil {
t.Fatalf("NewCPUGenerator: %v", err)
}
if err := g.GenerateFrame(make([]byte, V210FrameSize(6, 1)-1), 0); err == nil || !strings.Contains(err.Error(), "too small") {
t.Fatalf("small destination error = %v", err)
}
if err := g.GenerateFrame(make([]byte, V210FrameSize(6, 1)), 4); !errors.Is(err, wantErr) {
t.Fatalf("patch error = %v, want wrapped %v", err, wantErr)
}
}
func TestCPUGeneratorUsesPaddedV210Rows(t *testing.T) {
const width, height = 100, 2
g, err := NewCPUPatternGenerator(width, height, "gray-ramp")
if err != nil {
t.Fatalf("NewCPUPatternGenerator: %v", err)
}
frame := make([]byte, V210FrameSize(width, height))
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
for y := 0; y < height; y++ {
gotY, gotCb, gotCr := sampleV210(frame, width, 0, y)
if gotY != 64 || gotCb != 512 || gotCr != 512 {
t.Fatalf("row %d first pixel = %d/%d/%d, want 64/512/512", y, gotY, gotCb, gotCr)
}
padding := frame[y*V210LineSize(width)+V210ActiveLineSize(width) : (y+1)*V210LineSize(width)]
for i, b := range padding {
if b != 0 {
t.Fatalf("row %d padding byte %d = %#x, want 0", y, i, b)
}
}
}
}
+18 -7
View File
@@ -33,9 +33,19 @@ func LoadFace(path string, size float64) (font.Face, error) {
if err != nil {
return nil, fmt.Errorf("text: %w", err)
}
face, err := NewFace(data, size)
if err != nil {
return nil, fmt.Errorf("text: load %s: %w", path, err)
}
return face, nil
}
// NewFace parses TTF/OTF data and builds a render-ready face at the given
// pixel size (DPI 72, full hinting for crisp video text).
func NewFace(data []byte, size float64) (font.Face, error) {
f, err := opentype.Parse(data)
if err != nil {
return nil, fmt.Errorf("text: parse %s: %w", path, err)
return nil, fmt.Errorf("parse font: %w", err)
}
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
@@ -43,7 +53,7 @@ func LoadFace(path string, size float64) (font.Face, error) {
Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("text: face %s: %w", path, err)
return nil, fmt.Errorf("create font face: %w", err)
}
return face, nil
}
@@ -179,15 +189,16 @@ func (o *TextOverlay) pack() {
// ApplyV210 stamps the pre-packed text tile over a packed v210 frame.
// The tile occupies whole 16-byte blocks, so this is a row-wise copy.
func (o *TextOverlay) ApplyV210(dest []byte) error {
lastPixel := (o.y+o.h-1)*o.frameW + o.x + o.w - 1
if need := (lastPixel/6 + 1) * 16; len(dest) < need {
frameStride := V210LineSize(o.frameW)
tileStride := (o.w / 6) * 16
need := (o.y+o.h-1)*frameStride + o.x/6*16 + tileStride
if len(dest) < need {
return fmt.Errorf("text: dest %d bytes too small, need %d", len(dest), need)
}
tileStride := (o.w / 6) * 16
for row := 0; row < o.h; row++ {
frameBlock := ((o.y+row)*o.frameW + o.x) / 6
frameOffset := (o.y+row)*frameStride + o.x/6*16
src := o.blocks[row*tileStride : (row+1)*tileStride]
copy(dest[frameBlock*16:frameBlock*16+tileStride], src)
copy(dest[frameOffset:frameOffset+tileStride], src)
}
return nil
}
+5 -5
View File
@@ -1,17 +1,17 @@
package generator
import (
"path/filepath"
"strings"
"testing"
"mxl-pattern-generator/assets"
"golang.org/x/image/font"
)
func testFace(t *testing.T, size float64) font.Face {
t.Helper()
face, err := LoadFace(filepath.Join("..", "..", "assets", "fonts",
"JetBrainsMonoNLNerdFontMono-Regular.ttf"), size)
face, err := NewFace(assets.JetBrainsMono, size)
if err != nil {
t.Fatalf("LoadFace: %v", err)
}
@@ -163,7 +163,7 @@ func TestTextOverlayApplyV210(t *testing.T) {
t.Fatalf("NewTextOverlay: %v", err)
}
frame := make([]byte, frameW*frameH*8/3)
frame := make([]byte, V210FrameSize(frameW, frameH))
for i := range frame {
frame[i] = 0x5A // marker: untouched regions must survive
}
@@ -192,7 +192,7 @@ func TestTextOverlayApplyV210(t *testing.T) {
{o.x + o.w, o.y + o.h}, // corner
{100, 1000}, // far away
} {
off := ((p[1]*frameW + p[0]) / 6) * 16
off := p[1]*V210LineSize(frameW) + p[0]/6*16
for i := 0; i < 16; i++ {
if frame[off+i] != 0x5A {
t.Fatalf("block at (%d,%d) modified outside the box", p[0], p[1])
+37
View File
@@ -0,0 +1,37 @@
package generator
const v210RowAlignment = 128
// V210ActiveLineSize returns the number of bytes containing pixel data in one
// v210 row, including the final partial six-pixel block when needed.
func V210ActiveLineSize(width int) int {
return (width + 5) / 6 * 16
}
// V210LineSize returns the MXL v210 row stride. MXL stores every row at a
// 128-byte boundary, equivalent to rounding the width up to 48 pixels.
func V210LineSize(width int) int {
return ((width + 47) / 48) * v210RowAlignment
}
// V210FrameSize returns the complete MXL payload size for a v210 frame.
func V210FrameSize(width, height int) int {
return V210LineSize(width) * height
}
// AlphaLineSize returns the byte stride of one packed 10-bit alpha row. Each
// little-endian 32-bit word contains three alpha samples and two unused bits.
func AlphaLineSize(width int) int {
return ((width + 2) / 3) * 4
}
// AlphaFrameSize returns the size of the alpha plane in a v210a frame.
func AlphaFrameSize(width, height int) int {
return AlphaLineSize(width) * height
}
// V210AFrameSize returns the total size of a v210a payload: the complete v210
// fill plane followed by the complete packed 10-bit alpha plane.
func V210AFrameSize(width, height int) int {
return V210FrameSize(width, height) + AlphaFrameSize(width, height)
}
+72 -4
View File
@@ -1,16 +1,84 @@
package generator
import "encoding/binary"
import (
"encoding/binary"
"fmt"
"testing"
)
func TestV210Sizes(t *testing.T) {
tests := []struct {
width int
activeLine int
line int
}{
{width: 1920, activeLine: 5120, line: 5120},
{width: 1280, activeLine: 3424, line: 3456},
{width: 100, activeLine: 272, line: 384},
{width: 54, activeLine: 144, line: 256},
{width: 48, activeLine: 128, line: 128},
}
for _, tc := range tests {
if got := V210ActiveLineSize(tc.width); got != tc.activeLine {
t.Errorf("V210ActiveLineSize(%d) = %d, want %d", tc.width, got, tc.activeLine)
}
if got := V210LineSize(tc.width); got != tc.line {
t.Errorf("V210LineSize(%d) = %d, want %d", tc.width, got, tc.line)
}
if got := V210FrameSize(tc.width, 2); got != tc.line*2 {
t.Errorf("V210FrameSize(%d, 2) = %d, want %d", tc.width, got, tc.line*2)
}
}
}
func TestAlphaSizes(t *testing.T) {
tests := []struct {
width int
height int
lineSize int
frameSize int
v210aSize int
}{
{
width: 1920, height: 1080,
lineSize: 2560, frameSize: 2_764_800, v210aSize: 8_294_400,
},
{
width: 1280, height: 720,
lineSize: 1708, frameSize: 1_229_760, v210aSize: 3_718_080,
},
{
width: 100, height: 2,
lineSize: 136, frameSize: 272, v210aSize: 1040,
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) {
if got := AlphaLineSize(tc.width); got != tc.lineSize {
t.Errorf("AlphaLineSize(%d) = %d, want %d", tc.width, got, tc.lineSize)
}
if got := AlphaFrameSize(tc.width, tc.height); got != tc.frameSize {
t.Errorf("AlphaFrameSize(%d, %d) = %d, want %d", tc.width, tc.height, got, tc.frameSize)
}
if got := V210AFrameSize(tc.width, tc.height); got != tc.v210aSize {
t.Errorf("V210AFrameSize(%d, %d) = %d, want %d", tc.width, tc.height, got, tc.v210aSize)
}
if got := V210AFrameSize(tc.width, tc.height) - AlphaFrameSize(tc.width, tc.height); got != V210FrameSize(tc.width, tc.height) {
t.Errorf("alpha plane starts at byte %d, want %d", got, V210FrameSize(tc.width, tc.height))
}
})
}
}
func sampleV210(buf []byte, width, x, y int) (yc, cb, cr uint32) {
pixel := y*width + x
offset := (pixel / 6) * 16
offset := y*V210LineSize(width) + x/6*16
w0 := binary.LittleEndian.Uint32(buf[offset:])
w1 := binary.LittleEndian.Uint32(buf[offset+4:])
w2 := binary.LittleEndian.Uint32(buf[offset+8:])
w3 := binary.LittleEndian.Uint32(buf[offset+12:])
switch pixel % 6 {
switch x % 6 {
case 0:
return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
case 1:
+73
View File
@@ -0,0 +1,73 @@
package generator
import "fmt"
// V210AGenerator combines a v210 fill generator with a packed 10-bit alpha
// plane. It owns the fill generator and closes it from Close.
type V210AGenerator struct {
fill FrameGenerator
width int
height int
fillSize int
fillBase []byte
alphaBase []byte
}
var _ FrameGenerator = (*V210AGenerator)(nil)
func NewV210AGenerator(
fill FrameGenerator,
width, height uint,
fillDynamic bool,
) (*V210AGenerator, error) {
if fill == nil {
return nil, fmt.Errorf("v210a: fill generator is nil")
}
if width == 0 || height == 0 {
return nil, fmt.Errorf("v210a: width and height must be greater than zero, got %dx%d", width, height)
}
if width%2 != 0 {
return nil, fmt.Errorf("v210a: width must be even for 4:2:2 video, got %d", width)
}
g := &V210AGenerator{
fill: fill,
width: int(width),
height: int(height),
fillSize: V210FrameSize(int(width), int(height)),
alphaBase: make([]byte, AlphaFrameSize(int(width), int(height))),
}
if err := fillAlphaPlane(g.alphaBase, g.width, g.height, alphaOpaque); err != nil {
return nil, fmt.Errorf("v210a: initialize alpha plane: %w", err)
}
if !fillDynamic {
g.fillBase = make([]byte, g.fillSize)
if err := fill.GenerateFrame(g.fillBase, 0); err != nil {
return nil, fmt.Errorf("v210a: initialize static fill: %w", err)
}
}
return g, nil
}
func (g *V210AGenerator) GenerateFrame(dst []byte, frameIndex int) error {
need := V210AFrameSize(g.width, g.height)
if len(dst) < need {
return fmt.Errorf("v210a: destination is too small: got %d bytes, need %d", len(dst), need)
}
if g.fillBase != nil {
copy(dst[:g.fillSize], g.fillBase)
} else if err := g.fill.GenerateFrame(dst[:g.fillSize], frameIndex); err != nil {
return fmt.Errorf("v210a: generate dynamic fill frame %d: %w", frameIndex, err)
}
alpha := dst[g.fillSize:need]
copy(alpha, g.alphaBase)
if err := patchAlphaMovingSquare(alpha, g.width, g.height, frameIndex); err != nil {
return fmt.Errorf("v210a: patch alpha frame %d: %w", frameIndex, err)
}
return nil
}
func (g *V210AGenerator) Close() error {
return g.fill.Close()
}
+171
View File
@@ -0,0 +1,171 @@
package generator
import (
"errors"
"strings"
"testing"
)
type fakeFrameGenerator struct {
generateErr error
closeErr error
closed bool
calls int
}
func (g *fakeFrameGenerator) GenerateFrame(dst []byte, frameIndex int) error {
g.calls++
if g.generateErr != nil {
return g.generateErr
}
for i := range dst {
dst[i] = byte(frameIndex)
}
return nil
}
func (g *fakeFrameGenerator) Close() error {
g.closed = true
return g.closeErr
}
func TestV210AGeneratorLayoutAndAlpha(t *testing.T) {
const width, height = 304, 200
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
dst := make([]byte, V210AFrameSize(width, height))
if err := g.GenerateFrame(dst, 7); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
fillSize := V210FrameSize(width, height)
for i, b := range dst[:fillSize] {
if b != 7 {
t.Fatalf("fill byte %d = %#x, want 0x07", i, b)
}
}
alpha := dst[fillSize:]
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaTransparent {
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
}
if got := sampleAlpha(alpha, width, 10, height/2); got != alphaOpaque {
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
}
}
func TestV210AGeneratorRestoresAlphaBase(t *testing.T) {
const width, height = 304, 200
g, err := NewV210AGenerator(&fakeFrameGenerator{}, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
dst := make([]byte, V210AFrameSize(width, height))
if err := g.GenerateFrame(dst, 0); err != nil {
t.Fatalf("GenerateFrame(0): %v", err)
}
alpha := dst[V210FrameSize(width, height):]
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaTransparent {
t.Fatalf("frame 0 center = %d, want transparent %d", got, alphaTransparent)
}
if err := g.GenerateFrame(dst, 79); err != nil {
t.Fatalf("GenerateFrame(79): %v", err)
}
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaOpaque {
t.Errorf("old square position = %d, want restored opaque %d", got, alphaOpaque)
}
if got := sampleAlpha(alpha, width, 250, height/2); got != alphaTransparent {
t.Errorf("new square position = %d, want transparent %d", got, alphaTransparent)
}
}
func TestV210AGeneratorCachesStaticFill(t *testing.T) {
const width, height = 100, 20
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, false)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if fill.calls != 1 {
t.Fatalf("fill calls after construction = %d, want 1", fill.calls)
}
dst := make([]byte, V210AFrameSize(width, height))
for _, frameIndex := range []int{7, 19} {
if err := g.GenerateFrame(dst, frameIndex); err != nil {
t.Fatalf("GenerateFrame(%d): %v", frameIndex, err)
}
}
if fill.calls != 1 {
t.Errorf("fill calls after two frames = %d, want 1", fill.calls)
}
for i, b := range dst[:V210FrameSize(width, height)] {
if b != 0 {
t.Fatalf("cached fill byte %d = %#x, want frame-zero value 0", i, b)
}
}
}
func TestV210AGeneratorRegeneratesDynamicFill(t *testing.T) {
const width, height = 100, 20
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if fill.calls != 0 {
t.Fatalf("fill calls after construction = %d, want 0", fill.calls)
}
dst := make([]byte, V210AFrameSize(width, height))
for _, frameIndex := range []int{7, 19} {
if err := g.GenerateFrame(dst, frameIndex); err != nil {
t.Fatalf("GenerateFrame(%d): %v", frameIndex, err)
}
}
if fill.calls != 2 {
t.Errorf("fill calls after two frames = %d, want 2", fill.calls)
}
if got := dst[0]; got != 19 {
t.Errorf("dynamic fill byte = %#x, want frame-index value 0x13", got)
}
}
func TestV210AGeneratorErrors(t *testing.T) {
if _, err := NewV210AGenerator(nil, 1920, 1080, false); err == nil || !strings.Contains(err.Error(), "nil") {
t.Fatalf("nil fill error = %v", err)
}
fillErr := errors.New("fill failed")
if _, err := NewV210AGenerator(&fakeFrameGenerator{generateErr: fillErr}, 100, 20, false); !errors.Is(err, fillErr) {
t.Fatalf("static fill initialization error = %v, want wrapped %v", err, fillErr)
}
g, err := NewV210AGenerator(&fakeFrameGenerator{generateErr: fillErr}, 100, 20, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if err := g.GenerateFrame(make([]byte, V210AFrameSize(100, 20)-1), 0); err == nil || !strings.Contains(err.Error(), "too small") {
t.Fatalf("small destination error = %v", err)
}
if err := g.GenerateFrame(make([]byte, V210AFrameSize(100, 20)), 3); !errors.Is(err, fillErr) {
t.Fatalf("fill error = %v, want wrapped %v", err, fillErr)
}
}
func TestV210AGeneratorClosesFill(t *testing.T) {
closeErr := errors.New("close failed")
fill := &fakeFrameGenerator{closeErr: closeErr}
g, err := NewV210AGenerator(fill, 100, 20, false)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if err := g.Close(); !errors.Is(err, closeErr) {
t.Fatalf("Close = %v, want %v", err, closeErr)
}
if !fill.closed {
t.Fatal("wrapped fill generator was not closed")
}
}
+47 -37
View File
@@ -6,7 +6,6 @@ import (
"context"
"encoding/binary"
"fmt"
"os"
"time"
"github.com/gogpu/gputypes"
@@ -23,23 +22,26 @@ const wgpuWorkgroupSize = 64
// buffer, the GPU DMAs it into a persistent host-visible buffer, and the
// mapped contents are copied straight into the destination grain.
type WGPUGenerator struct {
instance *wgpu.Instance
adapter *wgpu.Adapter
device *wgpu.Device
queue *wgpu.Queue
shader *wgpu.ShaderModule
bgl *wgpu.BindGroupLayout
bg *wgpu.BindGroup
pl *wgpu.PipelineLayout
pipeline *wgpu.ComputePipeline
out *wgpu.Buffer
host *wgpu.Buffer
uniform *wgpu.Buffer
params []byte
width int
height int
blocks int
frameSize uint64
instance *wgpu.Instance
adapter *wgpu.Adapter
device *wgpu.Device
queue *wgpu.Queue
shader *wgpu.ShaderModule
bgl *wgpu.BindGroupLayout
bg *wgpu.BindGroup
pl *wgpu.PipelineLayout
pipeline *wgpu.ComputePipeline
out *wgpu.Buffer
host *wgpu.Buffer
uniform *wgpu.Buffer
params []byte
width int
height int
blocks int
activeLineSize int
lineSize int
compactFrameSize uint64
frameSize uint64
}
var _ FrameGenerator = (*WGPUGenerator)(nil)
@@ -47,17 +49,20 @@ var _ FrameGenerator = (*WGPUGenerator)(nil)
// WGPUOption customizes NewWGPUGenerator.
type WGPUOption func(*WGPUGenerator)
func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption) (*WGPUGenerator, error) {
func NewWGPUGenerator(width, height uint, wgsl string, opts ...WGPUOption) (*WGPUGenerator, error) {
g := &WGPUGenerator{
width: int(width),
height: int(height),
blocks: int(width*height) / 6,
params: make([]byte, 16),
width: int(width),
height: int(height),
blocks: ((int(width) + 5) / 6) * int(height),
activeLineSize: V210ActiveLineSize(int(width)),
lineSize: V210LineSize(int(width)),
params: make([]byte, 16),
}
for _, opt := range opts {
opt(g)
}
g.frameSize = uint64(g.blocks) * 16
g.compactFrameSize = uint64(g.activeLineSize * g.height)
g.frameSize = uint64(g.lineSize * g.height)
binary.LittleEndian.PutUint32(g.params[0:], uint32(width))
binary.LittleEndian.PutUint32(g.params[4:], uint32(height))
@@ -75,26 +80,21 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
}
g.queue = g.device.Queue()
wgsl, err := os.ReadFile(kernelPath)
if err != nil {
g.Close()
return nil, fmt.Errorf("wgpu: read kernel: %w", err)
}
if g.shader, err = g.device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{
Label: "v210-shader", WGSL: string(wgsl),
Label: "v210-shader", WGSL: wgsl,
}); err != nil {
g.Close()
return nil, fmt.Errorf("wgpu: shader: %w", err)
}
if g.out, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
Label: "v210-out", Size: g.frameSize,
Label: "v210-out", Size: g.compactFrameSize,
Usage: wgpu.BufferUsageStorage | wgpu.BufferUsageCopySrc,
}); err != nil {
g.Close()
return nil, fmt.Errorf("wgpu: out buffer: %w", err)
}
if g.host, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
Label: "v210-host", Size: g.frameSize,
Label: "v210-host", Size: g.compactFrameSize,
Usage: wgpu.BufferUsageCopyDst | wgpu.BufferUsageMapRead,
}); err != nil {
g.Close()
@@ -124,7 +124,7 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{
Label: "v210-bg", Layout: g.bgl,
Entries: []wgpu.BindGroupEntry{
{Binding: 0, Buffer: g.out, Size: g.frameSize},
{Binding: 0, Buffer: g.out, Size: g.compactFrameSize},
{Binding: 1, Buffer: g.uniform, Size: uint64(len(g.params))},
},
}); err != nil {
@@ -170,7 +170,7 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
if err := pass.End(); err != nil {
return fmt.Errorf("wgpu: end pass: %w", err)
}
encoder.CopyBufferToBuffer(g.out, 0, g.host, 0, g.frameSize)
encoder.CopyBufferToBuffer(g.out, 0, g.host, 0, g.compactFrameSize)
cmd, err := encoder.Finish()
if err != nil {
return fmt.Errorf("wgpu: finish: %w", err)
@@ -181,15 +181,25 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := g.host.Map(ctx, wgpu.MapModeRead, 0, g.frameSize); err != nil {
if err := g.host.Map(ctx, wgpu.MapModeRead, 0, g.compactFrameSize); err != nil {
return fmt.Errorf("wgpu: map: %w", err)
}
rng, err := g.host.MappedRange(0, g.frameSize)
rng, err := g.host.MappedRange(0, g.compactFrameSize)
if err != nil {
_ = g.host.Unmap()
return fmt.Errorf("wgpu: mapped range: %w", err)
}
copy(dest, rng.Bytes())
mapped := rng.Bytes()
if g.activeLineSize == g.lineSize {
copy(dest[:g.frameSize], mapped)
} else {
for y := 0; y < g.height; y++ {
src := mapped[y*g.activeLineSize : (y+1)*g.activeLineSize]
dst := dest[y*g.lineSize : (y+1)*g.lineSize]
copy(dst, src)
clear(dst[g.activeLineSize:])
}
}
return g.host.Unmap()
}
+7 -2
View File
@@ -1,13 +1,18 @@
package generator
import (
"path/filepath"
"testing"
"mxl-pattern-generator/kernels"
)
func TestWGPUMoveSquare(t *testing.T) {
const width, height = 1920, 1080
g, err := NewWGPUGenerator(width, height, filepath.Join("..", "..", "kernels", "dynamic", "smpteBars.wgsl"))
shader, err := kernels.Read("dynamic/smpteBars.wgsl")
if err != nil {
t.Fatalf("read shader: %v", err)
}
g, err := NewWGPUGenerator(width, height, string(shader))
if err != nil {
t.Fatalf("init: %v", err)
}
+41 -2
View File
@@ -1,13 +1,18 @@
package generator
import (
"path/filepath"
"testing"
"mxl-pattern-generator/kernels"
)
func TestWGPUSMPTEPattern(t *testing.T) {
const width, height = 1920, 1080
g, err := NewWGPUGenerator(width, height, filepath.Join("..", "..", "kernels", "static", "smpteBars.wgsl"))
shader, err := kernels.Read("static/smpteBars.wgsl")
if err != nil {
t.Fatalf("read shader: %v", err)
}
g, err := NewWGPUGenerator(width, height, string(shader))
if err != nil {
t.Fatalf("init: %v", err)
}
@@ -49,3 +54,37 @@ func TestWGPUSMPTEPattern(t *testing.T) {
})
}
}
func TestWGPUGeneratorUsesPaddedV210Rows(t *testing.T) {
const width, height = 100, 2
shader, err := kernels.Read("static/ebu75.wgsl")
if err != nil {
t.Fatalf("read shader: %v", err)
}
g, err := NewWGPUGenerator(width, height, string(shader))
if err != nil {
t.Fatalf("init: %v", err)
}
defer g.Close()
buf := make([]byte, V210FrameSize(width, height))
for i := range buf {
buf[i] = 0xff
}
if err := g.GenerateFrame(buf, 0); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
for y := 0; y < height; y++ {
gotY, gotCb, gotCr := sampleV210(buf, width, 0, y)
if gotY != 721 || gotCb != 512 || gotCr != 512 {
t.Fatalf("row %d first pixel = %d/%d/%d, want 721/512/512", y, gotY, gotCb, gotCr)
}
padding := buf[y*V210LineSize(width)+V210ActiveLineSize(width) : (y+1)*V210LineSize(width)]
for i, b := range padding {
if b != 0 {
t.Fatalf("row %d padding byte %d = %#x, want 0", y, i, b)
}
}
}
}
+13
View File
@@ -6,10 +6,19 @@ import (
"github.com/qvest-digital/go-mxl/mxl"
)
type Backend string
const (
BackendAuto Backend = "auto"
BackendGPU Backend = "gpu"
BackendCPU Backend = "cpu"
)
type Config struct {
Definition flowdef.Video
Pattern string
Overlay OverlayConfig
Backend Backend
}
type OverlayConfig struct {
@@ -31,6 +40,10 @@ func (c Config) Height() uint {
return c.Definition.FrameHeight
}
func (c Config) HasAlpha() bool {
return c.Definition.MediaType == flowdef.MediaTypeV210A
}
func (c Config) Rate() mxl.Rational {
return mxl.Rational{
Num: int64(c.Definition.GrainRate.Numerator),
+89
View File
@@ -0,0 +1,89 @@
package video
import (
"errors"
"fmt"
"log"
"mxl-pattern-generator/internal/generator"
)
type generatorFactory func() (generator.FrameGenerator, error)
func newFrameGenerator(cfg Config, pattern pattern) (generator.FrameGenerator, Backend, error) {
newGPU := func() (generator.FrameGenerator, error) {
return generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelSource)
}
newCPU := func() (generator.FrameGenerator, error) {
return generator.NewCPUPatternGenerator(cfg.Width(), cfg.Height(), cfg.Pattern)
}
fill, backend, err := selectFrameGenerator(cfg.Backend, newGPU, newCPU)
if err != nil {
return nil, "", err
}
gen, err := wrapAlphaGenerator(cfg, fill, pattern.dynamic)
if err != nil {
return nil, "", err
}
return gen, backend, nil
}
func wrapAlphaGenerator(
cfg Config,
fill generator.FrameGenerator,
fillDynamic bool,
) (generator.FrameGenerator, error) {
if !cfg.HasAlpha() {
return fill, nil
}
gen, err := generator.NewV210AGenerator(fill, cfg.Width(), cfg.Height(), fillDynamic)
if err != nil {
closeErr := fill.Close()
return nil, errors.Join(
fmt.Errorf("initialize v210a generator: %w", err),
closeErr,
)
}
return gen, nil
}
func selectFrameGenerator(
backend Backend,
newGPU, newCPU generatorFactory,
) (generator.FrameGenerator, Backend, error) {
switch backend {
case BackendGPU:
gen, err := newGPU()
if err != nil {
return nil, "", fmt.Errorf("initialize GPU video generator: %w", err)
}
return gen, BackendGPU, nil
case BackendCPU:
gen, err := newCPU()
if err != nil {
return nil, "", fmt.Errorf("initialize CPU video generator: %w", err)
}
return gen, BackendCPU, nil
case "", BackendAuto:
gpuGen, gpuErr := newGPU()
if gpuErr == nil {
return gpuGen, BackendGPU, nil
}
log.Printf("GPU video generator unavailable, falling back to CPU: %v", gpuErr)
cpuGen, cpuErr := newCPU()
if cpuErr == nil {
return cpuGen, BackendCPU, nil
}
return nil, "", errors.Join(
fmt.Errorf("initialize GPU video generator: %w", gpuErr),
fmt.Errorf("initialize CPU video generator: %w", cpuErr),
)
default:
return nil, "", fmt.Errorf("unsupported video backend %q", backend)
}
}
+178
View File
@@ -0,0 +1,178 @@
package video
import (
"errors"
"strings"
"testing"
"mxl-pattern-generator/internal/flowdef"
"mxl-pattern-generator/internal/generator"
)
type stubFrameGenerator struct{}
func (*stubFrameGenerator) GenerateFrame([]byte, int) error { return nil }
func (*stubFrameGenerator) Close() error { return nil }
func TestSelectFrameGenerator(t *testing.T) {
gpuErr := errors.New("no GPU")
cpuErr := errors.New("no CPU pattern")
tests := []struct {
name string
backend Backend
gpuErr error
cpuErr error
wantBackend Backend
wantGPUCalls int
wantCPUCalls int
wantErrSubstr []string
}{
{name: "explicit GPU", backend: BackendGPU, wantBackend: BackendGPU, wantGPUCalls: 1},
{name: "explicit CPU", backend: BackendCPU, wantBackend: BackendCPU, wantCPUCalls: 1},
{name: "auto prefers GPU", backend: BackendAuto, wantBackend: BackendGPU, wantGPUCalls: 1},
{name: "zero value is auto", wantBackend: BackendGPU, wantGPUCalls: 1},
{
name: "auto falls back to CPU",
backend: BackendAuto,
gpuErr: gpuErr,
wantBackend: BackendCPU,
wantGPUCalls: 1,
wantCPUCalls: 1,
},
{
name: "auto reports both failures",
backend: BackendAuto,
gpuErr: gpuErr,
cpuErr: cpuErr,
wantGPUCalls: 1,
wantCPUCalls: 1,
wantErrSubstr: []string{"GPU video generator", "CPU video generator"},
},
{
name: "explicit GPU does not fall back",
backend: BackendGPU,
gpuErr: gpuErr,
wantGPUCalls: 1,
wantErrSubstr: []string{"GPU video generator"},
},
{
name: "explicit CPU does not try GPU",
backend: BackendCPU,
cpuErr: cpuErr,
wantCPUCalls: 1,
wantErrSubstr: []string{"CPU video generator"},
},
{
name: "invalid backend",
backend: Backend("invalid"),
wantErrSubstr: []string{"unsupported video backend"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gpuCalls, cpuCalls := 0, 0
factory := func(calls *int, err error) generatorFactory {
return func() (generator.FrameGenerator, error) {
*calls++
if err != nil {
return nil, err
}
return &stubFrameGenerator{}, nil
}
}
gen, backend, err := selectFrameGenerator(
tc.backend,
factory(&gpuCalls, tc.gpuErr),
factory(&cpuCalls, tc.cpuErr),
)
if gpuCalls != tc.wantGPUCalls || cpuCalls != tc.wantCPUCalls {
t.Fatalf("factory calls GPU/CPU = %d/%d, want %d/%d",
gpuCalls, cpuCalls, tc.wantGPUCalls, tc.wantCPUCalls)
}
if len(tc.wantErrSubstr) == 0 {
if err != nil {
t.Fatalf("selectFrameGenerator: %v", err)
}
if gen == nil || backend != tc.wantBackend {
t.Fatalf("generator/backend = %v/%q, want non-nil/%q", gen, backend, tc.wantBackend)
}
return
}
if err == nil {
t.Fatal("selectFrameGenerator returned nil error")
}
for _, substring := range tc.wantErrSubstr {
if !strings.Contains(err.Error(), substring) {
t.Errorf("error = %q, want substring %q", err, substring)
}
}
})
}
}
func TestWrapAlphaGenerator(t *testing.T) {
const id = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
rate := flowdef.Rational{Numerator: 25, Denominator: 1}
tests := []struct {
name string
alpha bool
wantAlpha bool
}{
{name: "v210"},
{name: "v210a", alpha: true, wantAlpha: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
definition flowdef.Video
err error
)
if tc.alpha {
definition, err = flowdef.NewV210AVideo(id, 100, 200, rate)
} else {
definition, err = flowdef.NewV210Video(id, 100, 200, rate)
}
if err != nil {
t.Fatalf("create flow definition: %v", err)
}
cfg := Config{
Definition: definition,
Pattern: "gray-ramp",
Backend: BackendCPU,
}
fill := &stubFrameGenerator{}
gen, err := wrapAlphaGenerator(cfg, fill, false)
if err != nil {
t.Fatalf("wrapAlphaGenerator: %v", err)
}
defer gen.Close()
_, gotAlpha := gen.(*generator.V210AGenerator)
if gotAlpha != tc.wantAlpha {
t.Errorf("v210a wrapper present = %v, want %v", gotAlpha, tc.wantAlpha)
}
if !tc.wantAlpha && gen != fill {
t.Error("v210 generator was unexpectedly replaced")
}
})
}
}
func TestConfigHasAlpha(t *testing.T) {
for _, tc := range []struct {
mediaType string
want bool
}{
{mediaType: flowdef.MediaTypeV210},
{mediaType: flowdef.MediaTypeV210A, want: true},
{mediaType: "video/unknown"},
} {
cfg := Config{Definition: flowdef.Video{Common: flowdef.Common{MediaType: tc.mediaType}}}
if got := cfg.HasAlpha(); got != tc.want {
t.Errorf("HasAlpha() for %q = %v, want %v", tc.mediaType, got, tc.want)
}
}
}
+41 -22
View File
@@ -1,6 +1,11 @@
package video
import "sort"
import (
"fmt"
"sort"
"mxl-pattern-generator/kernels"
)
type PatternInfo struct {
Name string
@@ -9,52 +14,66 @@ type PatternInfo struct {
type pattern struct {
PatternInfo
kernelPath string
kernelSource string
dynamic bool
}
var patterns = map[string]pattern{
"ebu75": {
PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"},
kernelPath: "kernels/static/ebu75.wgsl",
PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"},
kernelSource: mustReadKernel("static/ebu75.wgsl"),
},
"ebu75-move": {
PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"},
kernelPath: "kernels/dynamic/ebu75.wgsl",
PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"},
kernelSource: mustReadKernel("dynamic/ebu75.wgsl"),
dynamic: true,
},
"ebu100": {
PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"},
kernelPath: "kernels/static/ebu100.wgsl",
PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"},
kernelSource: mustReadKernel("static/ebu100.wgsl"),
},
"ebu100-move": {
PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"},
kernelPath: "kernels/dynamic/ebu100.wgsl",
PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"},
kernelSource: mustReadKernel("dynamic/ebu100.wgsl"),
dynamic: true,
},
"smpte": {
PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"},
kernelPath: "kernels/static/smpteBars.wgsl",
PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"},
kernelSource: mustReadKernel("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",
PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"},
kernelSource: mustReadKernel("dynamic/smpteBars.wgsl"),
dynamic: true,
},
"gray-bars": {
PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"},
kernelPath: "kernels/static/yBars.wgsl",
PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"},
kernelSource: mustReadKernel("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",
PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"},
kernelSource: mustReadKernel("dynamic/yBars.wgsl"),
dynamic: true,
},
"gray-ramp": {
PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"},
kernelPath: "kernels/static/yRamp.wgsl",
PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"},
kernelSource: mustReadKernel("static/yRamp.wgsl"),
},
"gray-ramp-move": {
PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"},
kernelPath: "kernels/dynamic/yRamp.wgsl",
PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"},
kernelSource: mustReadKernel("dynamic/yRamp.wgsl"),
dynamic: true,
},
}
func mustReadKernel(name string) string {
source, err := kernels.Read(name)
if err != nil {
panic(fmt.Sprintf("read embedded video kernel %q: %v", name, err))
}
return string(source)
}
func HasPattern(name string) bool {
_, ok := patterns[name]
return ok
+54
View File
@@ -0,0 +1,54 @@
package video
import (
"strings"
"testing"
)
func TestPatterns(t *testing.T) {
got := Patterns()
if len(got) == 0 {
t.Fatal("Patterns returned no video patterns")
}
seen := make(map[string]struct{}, len(got))
for i, pattern := range got {
if pattern.Name == "" {
t.Fatalf("pattern %d has an empty name", i)
}
if pattern.Description == "" {
t.Fatalf("pattern %q has an empty description", pattern.Name)
}
if !HasPattern(pattern.Name) {
t.Fatalf("Patterns returned %q, but HasPattern rejected it", pattern.Name)
}
if _, exists := seen[pattern.Name]; exists {
t.Fatalf("duplicate pattern name %q", pattern.Name)
}
seen[pattern.Name] = struct{}{}
if i > 0 && got[i-1].Name >= pattern.Name {
t.Fatalf("patterns are not sorted: %q appears before %q", got[i-1].Name, pattern.Name)
}
}
}
func TestHasPatternRejectsUnknownName(t *testing.T) {
if HasPattern("not-a-pattern") {
t.Fatal("HasPattern accepted an unknown pattern")
}
}
func TestPatternRegistryKeysMatchNames(t *testing.T) {
for name, pattern := range patterns {
if pattern.Name != name {
t.Errorf("pattern map key %q does not match pattern name %q", name, pattern.Name)
}
if pattern.kernelSource == "" {
t.Errorf("pattern %q has empty kernel source", name)
}
wantDynamic := strings.HasSuffix(name, "-move")
if pattern.dynamic != wantDynamic {
t.Errorf("pattern %q dynamic = %t, want %t", name, pattern.dynamic, wantDynamic)
}
}
}
+39 -16
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"log"
"mxl-pattern-generator/assets"
"mxl-pattern-generator/internal/generator"
"github.com/qvest-digital/go-mxl/mxl"
@@ -17,11 +19,11 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
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)
gen, backend, err := newFrameGenerator(cfg, pattern)
if err != nil {
return fmt.Errorf("initialize wgpu video generator: %w", err)
return err
}
log.Printf("video generator backend: %s", backend)
defer func() {
if err := gen.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err))
@@ -33,6 +35,21 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
return err
}
var staticFrame []byte
if !pattern.dynamic && !cfg.HasAlpha() {
frameSize := generator.V210FrameSize(int(cfg.Width()), int(cfg.Height()))
staticFrame = make([]byte, frameSize)
if err := gen.GenerateFrame(staticFrame, 0); err != nil {
return fmt.Errorf("generate static frame: %w", err)
}
if overlay != nil {
if err := overlay.ApplyV210(staticFrame); err != nil {
return fmt.Errorf("apply text overlay to static frame: %w", err)
}
}
}
flowJSON, err := json.Marshal(cfg.Definition)
if err != nil {
return fmt.Errorf("marshal video flow definition: %w", err)
@@ -68,13 +85,25 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
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 staticFrame != nil {
if len(grain.Payload) != len(staticFrame) {
return cancelGrain(grain, fmt.Errorf(
"video grain payload size %d, expected %d",
len(grain.Payload),
len(staticFrame),
))
}
copy(grain.Payload, staticFrame)
} else {
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))
}
}
tick++
}
if err := grain.Commit(grain.TotalSlices, 0); err != nil {
return fmt.Errorf("commit video grain %d: %w", idx, err)
@@ -82,7 +111,6 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
grainsWritten++
idx++
tick++
if grainsWritten%100 == 0 {
log.Printf("video grains written=%d, next index=%d", grainsWritten, idx)
}
@@ -101,13 +129,8 @@ func buildTextOverlay(cfg Config) (overlay *generator.TextOverlay, resultErr err
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)
face, err := generator.NewFace(assets.JetBrainsMono, 48)
if err != nil {
return nil, fmt.Errorf("load text overlay font: %w", err)
}
+77
View File
@@ -0,0 +1,77 @@
package video
import (
"strings"
"testing"
"mxl-pattern-generator/internal/flowdef"
)
const testVideoID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
func testConfig(t *testing.T, overlay OverlayConfig) Config {
t.Helper()
definition, err := flowdef.NewV210Video(
testVideoID,
1920,
1080,
flowdef.Rational{Numerator: 50, Denominator: 1},
)
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
return Config{Definition: definition, Pattern: "ebu75", Overlay: overlay}
}
func TestBuildTextOverlayDisabled(t *testing.T) {
overlay, err := buildTextOverlay(testConfig(t, OverlayConfig{}))
if err != nil {
t.Fatalf("buildTextOverlay: %v", err)
}
if overlay != nil {
t.Fatal("buildTextOverlay returned an overlay for empty text")
}
}
func TestBuildTextOverlayPositioning(t *testing.T) {
tests := []struct {
name string
overlay OverlayConfig
}{
{name: "explicit", overlay: OverlayConfig{Text: "MXL", X: 120, Y: 48}},
{name: "preset", overlay: OverlayConfig{Text: "MXL", Position: "cc"}},
{name: "preset ignores explicit coordinates", overlay: OverlayConfig{Text: "MXL", X: -1, Y: -1, Position: "cc"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
overlay, err := buildTextOverlay(testConfig(t, tc.overlay))
if err != nil {
t.Fatalf("buildTextOverlay: %v", err)
}
if overlay == nil {
t.Fatal("buildTextOverlay returned nil")
}
})
}
}
func TestBuildTextOverlayRejectsInvalidPosition(t *testing.T) {
tests := []struct {
name string
overlay OverlayConfig
wantErrSub string
}{
{name: "negative", overlay: OverlayConfig{Text: "MXL", X: -6}, wantErrSub: "must not be negative"},
{name: "unaligned x", overlay: OverlayConfig{Text: "MXL", X: 7}, wantErrSub: "divisible by 6"},
{name: "right overflow", overlay: OverlayConfig{Text: "MXL", X: 1902}, wantErrSub: "does not fit"},
{name: "bottom overflow", overlay: OverlayConfig{Text: "MXL", Y: 1070}, wantErrSub: "does not fit"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := buildTextOverlay(testConfig(t, tc.overlay))
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
}
})
}
}
+4 -3
View File
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let py = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let py = block / blocks_per_row;
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
+4 -3
View File
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let py = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let py = block / blocks_per_row;
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
+4 -3
View File
@@ -18,13 +18,14 @@ fn bar_index(px: u32, bars_75_width: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let y_px = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let y_px = block / blocks_per_row;
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
+4 -3
View File
@@ -19,15 +19,16 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
let x = (block * 6u) % params.width;
let py = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let py = block / blocks_per_row;
// Moving square: horizontal oscillation around screen center.
// frame is a small tick; converting the huge raw grain index here
// would destroy f32 precision and freeze the motion.
+4 -3
View File
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let py = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let py = block / blocks_per_row;
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
+12
View File
@@ -0,0 +1,12 @@
package kernels
import "embed"
// files contains every built-in video pattern shader.
//
//go:embed static/*.wgsl dynamic/*.wgsl
var files embed.FS
func Read(name string) ([]byte, error) {
return files.ReadFile(name)
}
+3 -2
View File
@@ -18,12 +18,13 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let x = (block % blocks_per_row) * 6u;
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
+3 -2
View File
@@ -18,12 +18,13 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let x = (block % blocks_per_row) * 6u;
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
+4 -3
View File
@@ -18,13 +18,14 @@ fn bar_index(px: u32, bars_75_width: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
let x = (block * 6u) % params.width;
let y_px = (block * 6u) / params.width;
let x = (block % blocks_per_row) * 6u;
let y_px = block / blocks_per_row;
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
+3 -2
View File
@@ -19,7 +19,8 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
@@ -27,7 +28,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
// 6 = pixels per v210 block (NOT the bar count)
let x = (block * 6u) % params.width;
let x = (block % blocks_per_row) * 6u;
var y: array<u32, 6>;
var cb: array<u32, 6>;
var cr: array<u32, 6>;
+3 -2
View File
@@ -18,7 +18,8 @@ fn bar_index(px: u32) -> u32 {
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x;
let total = (params.width * params.height) / 6u;
let blocks_per_row = (params.width + 5u) / 6u;
let total = blocks_per_row * params.height;
if (block >= total) {
return;
}
@@ -28,7 +29,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let cb_tab = array<u32, 7>(512u, 176u, 589u, 253u, 771u, 435u, 848u);
let cr_tab = array<u32, 7>(512u, 543u, 176u, 207u, 817u, 848u, 481u);
let x = (block * 6u) % params.width;
let x = (block % blocks_per_row) * 6u;
var y: array<u32, 6>;
var cb: array<u32, 6>;
var cr: array<u32, 6>;