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 ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -19,14 +18,15 @@ import (
"github.com/qvest-digital/go-mxl/mxl" "github.com/qvest-digital/go-mxl/mxl"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"mxl-pattern-generator/internal/app"
"mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef" "mxl-pattern-generator/internal/flowdef"
"mxl-pattern-generator/internal/video" "mxl-pattern-generator/internal/video"
) )
const ( const (
APP_NAME = "MXL pattern generator" APP_NAME = app.Name
APP_VER = "0.1.0" APP_VER = app.Version
) )
type appArgs struct { type appArgs struct {
@@ -43,10 +43,13 @@ type appArgs struct {
overlayY int overlayY int
overlayPos string overlayPos string
videoWidth uint videoWidth uint
videoHeight uint videoHeight uint
videoFPS string videoFPS string
videoUUID string videoUUID string
noVideo bool
videoAlpha bool
videoBackend string
audioChannels uint8 audioChannels uint8
audioSamplingFreq string audioSamplingFreq string
@@ -59,16 +62,6 @@ type parseResult struct {
shouldRun bool 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{ var frameRates = map[string]mxl.Rational{
"23.97": {Num: 24000, Den: 1001}, "23.97": {Num: 24000, Den: 1001},
"24": {Num: 24, Den: 1}, "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, "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, " 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, " [-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, " 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, "Video and audio feed parameters are ignored when a flow definition file is provided.")
fmt.Fprintln(w) fmt.Fprintln(w)
@@ -102,7 +96,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
} }
func printUsage(w io.Writer) { 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.") 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 { func validateVideoArgs(args *appArgs) error {
if args.noVideo {
return nil
}
if _, err := parseVideoBackend(args.videoBackend); err != nil {
return err
}
if !video.HasPattern(args.pattern) { if !video.HasPattern(args.pattern) {
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern) return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
} }
if args.videoFlowDefFile != "" { if args.videoFlowDefFile != "" {
return nil return nil
} }
if args.videoWidth == 0 || args.videoWidth%6 != 0 { if args.videoWidth == 0 || args.videoWidth%2 != 0 {
// v210 stores 6 pixels in each 16-byte block. return fmt.Errorf("video width must be greater than zero and even for 4:2:2 video")
return fmt.Errorf("video width must be greater than zero and divisible by 6")
} }
if args.videoHeight == 0 { if args.videoHeight == 0 {
return fmt.Errorf("video height must be greater than zero") return fmt.Errorf("video height must be greater than zero")
@@ -168,6 +167,22 @@ func validateVideoArgs(args *appArgs) error {
return nil 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 { func validateAudioArgs(args *appArgs) error {
if args.audioFlowDefFile == "" && args.audioChannels == 0 { if args.audioFlowDefFile == "" && args.audioChannels == 0 {
return nil return nil
@@ -199,6 +214,7 @@ func validateAudioArgs(args *appArgs) error {
func validateArgs(args *appArgs) error { func validateArgs(args *appArgs) error {
checks := []func() error{ checks := []func() error{
func() error { return validateDomain(args.domain) }, func() error { return validateDomain(args.domain) },
func() error { return validateMediaSelection(*args) },
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) }, func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) }, func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
func() error { return validateVideoArgs(args) }, func() error { return validateVideoArgs(args) },
@@ -273,10 +289,13 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) {
"br - bottom-right corner", "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.UintVar(&args.videoHeight, "height", 1080, "Video pattern height")
fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS") fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS")
fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided") 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 // Audio pattern flags
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz") fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz")
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz") fs.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 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 var definition flowdef.Video
if args.videoFlowDefFile != "" { if args.videoFlowDefFile != "" {
data, err := os.ReadFile(args.videoFlowDefFile) data, err := os.ReadFile(args.videoFlowDefFile)
if err != nil { if err != nil {
return video.Config{}, fmt.Errorf( return nil, fmt.Errorf(
"read video flow definition %q: %w", "read video flow definition %q: %w",
args.videoFlowDefFile, args.videoFlowDefFile,
err, err,
) )
} }
definition, err = flowdef.ParseV210Video(data) definition, err = flowdef.ParseVideo(data)
if err != nil { if err != nil {
return video.Config{}, fmt.Errorf( return nil, fmt.Errorf(
"parse video flow definition %q: %w", "parse video flow definition %q: %w",
args.videoFlowDefFile, args.videoFlowDefFile,
err, err,
@@ -335,11 +374,15 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
} else { } else {
rate, ok := frameRates[args.videoFPS] rate, ok := frameRates[args.videoFPS]
if !ok { 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 var err error
definition, err = flowdef.NewV210Video( newVideo := flowdef.NewV210Video
if args.videoAlpha {
newVideo = flowdef.NewV210AVideo
}
definition, err = newVideo(
args.videoUUID, args.videoUUID,
args.videoWidth, args.videoWidth,
args.videoHeight, args.videoHeight,
@@ -349,17 +392,22 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
}, },
) )
if err != nil { if err != nil {
return video.Config{}, fmt.Errorf( return nil, fmt.Errorf(
"build video flow definition: %w", "build video flow definition: %w",
err, err,
) )
} }
} }
if !video.HasPattern(args.pattern) { 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, Definition: definition,
Pattern: args.pattern, Pattern: args.pattern,
Overlay: video.OverlayConfig{ Overlay: video.OverlayConfig{
@@ -368,6 +416,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
Y: args.overlayY, Y: args.overlayY,
Position: args.overlayPos, Position: args.overlayPos,
}, },
Backend: backend,
}, nil }, 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) videoCfg, err := buildVideoConfig(args)
if err != nil { if err != nil {
return fmt.Errorf("video configuration: %w", err) 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) return fmt.Errorf("audio configuration: %w", err)
} }
log.Printf("%s %s", APP_NAME, APP_VER) return app.Run(ctx, app.Config{
log.Printf("Domain: %s", args.domain) Domain: args.domain,
log.Printf("Video: %dx%d %d/%d", Video: videoCfg,
videoCfg.Width(), videoCfg.Height(), videoCfg.Rate().Num, videoCfg.Rate().Den) Audio: audioCfg,
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
} }
+182 -67
View File
@@ -2,18 +2,30 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
"mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef" "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) { func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
var stdout, stderr bytes.Buffer 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) { func TestListPatternsIsSorted(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
listPatterns(&output) listPatterns(&output)
@@ -127,6 +252,60 @@ func TestBuildVideoConfigFromArgs(t *testing.T) {
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 { if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den) 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) { func TestBuildVideoConfigFromFile(t *testing.T) {
@@ -253,67 +432,3 @@ func TestValidateAudioArgsRejectsUnknownLevelForFlowDefinition(t *testing.T) {
t.Fatalf("error = %v, want unsupported audio level error", err) 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" FormatVideo = "urn:x-nmos:format:video"
FormatAudio = "urn:x-nmos:format:audio" FormatAudio = "urn:x-nmos:format:audio"
MediaTypeV210 = "video/v210" MediaTypeV210 = "video/v210"
MediaTypeV210A = "video/v210a"
MediaTypeFloat32 = "audio/float32" MediaTypeFloat32 = "audio/float32"
InterlaceProgressive = "progressive" InterlaceProgressive = "progressive"
@@ -75,7 +76,12 @@ type VideoComponent struct {
BitDepth uint `json:"bit_depth"` 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{ definition := Video{
Common: Common{ Common: Common{
Description: "go-mxl-pattern-gen generated video", Description: "go-mxl-pattern-gen generated video",
@@ -86,7 +92,7 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
Format: FormatVideo, Format: FormatVideo,
Label: "go-mxl-pattern-gen generated video", Label: "go-mxl-pattern-gen generated video",
Parents: []string{}, Parents: []string{},
MediaType: MediaTypeV210, MediaType: mediaType,
}, },
GrainRate: rate, GrainRate: rate,
FrameWidth: width, FrameWidth: width,
@@ -105,7 +111,15 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
return definition, nil 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 var definition Video
if err := json.Unmarshal(data, &definition); err != nil { if err := json.Unmarshal(data, &definition); err != nil {
return Video{}, fmt.Errorf("decode video flow definition: %w", err) return Video{}, fmt.Errorf("decode video flow definition: %w", err)
@@ -123,8 +137,10 @@ func (v Video) Validate() error {
if v.Format != FormatVideo { if v.Format != FormatVideo {
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format) return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
} }
if v.MediaType != MediaTypeV210 { switch v.MediaType {
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, 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 { if v.InterlaceMode != InterlaceProgressive {
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode) 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 { if v.ColorSpace != ColorSpaceBT709 {
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace) return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
} }
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 { if v.FrameWidth == 0 || v.FrameWidth%2 != 0 {
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth) 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 { if v.FrameHeight == 0 {
return fmt.Errorf("frame_height must be greater than zero") 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) { func TestNewV210AVideo(t *testing.T) {
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1}) definition, err := NewV210AVideo(testVideoID, 1280, 720, Rational{Numerator: 50, Denominator: 1})
if err == nil || !strings.Contains(err.Error(), "divisible by 6") { if err != nil {
t.Fatalf("error = %v, want width divisibility error", err) 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}) want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil { if err != nil {
t.Fatalf("NewV210Video: %v", err) t.Fatalf("NewV210Video: %v", err)
@@ -45,28 +68,58 @@ func TestParseV210Video(t *testing.T) {
t.Fatalf("json.Marshal: %v", err) t.Fatalf("json.Marshal: %v", err)
} }
got, err := ParseV210Video(data) got, err := ParseVideo(data)
if err != nil { 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 { if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
t.Fatalf("parsed definition = %+v, want %+v", got, want) 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(`{ data := []byte(`{
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed", "id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
"format":"urn:x-nmos:format:audio", "format":"urn:x-nmos:format:audio",
"media_type":"audio/float32" "media_type":"audio/float32"
}`) }`)
_, err := ParseV210Video(data) _, err := ParseVideo(data)
if err == nil || !strings.Contains(err.Error(), "format must be") { if err == nil || !strings.Contains(err.Error(), "format must be") {
t.Fatalf("error = %v, want video format error", err) 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) { func TestNewFloat32Audio(t *testing.T) {
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1}) definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
if err != nil { 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 { if err != nil {
return nil, fmt.Errorf("text: %w", err) 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) f, err := opentype.Parse(data)
if err != nil { 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{ face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size, Size: size,
@@ -43,7 +53,7 @@ func LoadFace(path string, size float64) (font.Face, error) {
Hinting: font.HintingFull, Hinting: font.HintingFull,
}) })
if err != nil { 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 return face, nil
} }
@@ -179,15 +189,16 @@ func (o *TextOverlay) pack() {
// ApplyV210 stamps the pre-packed text tile over a packed v210 frame. // 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. // The tile occupies whole 16-byte blocks, so this is a row-wise copy.
func (o *TextOverlay) ApplyV210(dest []byte) error { func (o *TextOverlay) ApplyV210(dest []byte) error {
lastPixel := (o.y+o.h-1)*o.frameW + o.x + o.w - 1 frameStride := V210LineSize(o.frameW)
if need := (lastPixel/6 + 1) * 16; len(dest) < need { 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) 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++ { 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] src := o.blocks[row*tileStride : (row+1)*tileStride]
copy(dest[frameBlock*16:frameBlock*16+tileStride], src) copy(dest[frameOffset:frameOffset+tileStride], src)
} }
return nil return nil
} }
+5 -5
View File
@@ -1,17 +1,17 @@
package generator package generator
import ( import (
"path/filepath"
"strings" "strings"
"testing" "testing"
"mxl-pattern-generator/assets"
"golang.org/x/image/font" "golang.org/x/image/font"
) )
func testFace(t *testing.T, size float64) font.Face { func testFace(t *testing.T, size float64) font.Face {
t.Helper() t.Helper()
face, err := LoadFace(filepath.Join("..", "..", "assets", "fonts", face, err := NewFace(assets.JetBrainsMono, size)
"JetBrainsMonoNLNerdFontMono-Regular.ttf"), size)
if err != nil { if err != nil {
t.Fatalf("LoadFace: %v", err) t.Fatalf("LoadFace: %v", err)
} }
@@ -163,7 +163,7 @@ func TestTextOverlayApplyV210(t *testing.T) {
t.Fatalf("NewTextOverlay: %v", err) t.Fatalf("NewTextOverlay: %v", err)
} }
frame := make([]byte, frameW*frameH*8/3) frame := make([]byte, V210FrameSize(frameW, frameH))
for i := range frame { for i := range frame {
frame[i] = 0x5A // marker: untouched regions must survive 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 {o.x + o.w, o.y + o.h}, // corner
{100, 1000}, // far away {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++ { for i := 0; i < 16; i++ {
if frame[off+i] != 0x5A { if frame[off+i] != 0x5A {
t.Fatalf("block at (%d,%d) modified outside the box", p[0], p[1]) 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 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) { func sampleV210(buf []byte, width, x, y int) (yc, cb, cr uint32) {
pixel := y*width + x offset := y*V210LineSize(width) + x/6*16
offset := (pixel / 6) * 16
w0 := binary.LittleEndian.Uint32(buf[offset:]) w0 := binary.LittleEndian.Uint32(buf[offset:])
w1 := binary.LittleEndian.Uint32(buf[offset+4:]) w1 := binary.LittleEndian.Uint32(buf[offset+4:])
w2 := binary.LittleEndian.Uint32(buf[offset+8:]) w2 := binary.LittleEndian.Uint32(buf[offset+8:])
w3 := binary.LittleEndian.Uint32(buf[offset+12:]) w3 := binary.LittleEndian.Uint32(buf[offset+12:])
switch pixel % 6 { switch x % 6 {
case 0: case 0:
return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
case 1: 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" "context"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"os"
"time" "time"
"github.com/gogpu/gputypes" "github.com/gogpu/gputypes"
@@ -23,23 +22,26 @@ const wgpuWorkgroupSize = 64
// buffer, the GPU DMAs it into a persistent host-visible buffer, and the // buffer, the GPU DMAs it into a persistent host-visible buffer, and the
// mapped contents are copied straight into the destination grain. // mapped contents are copied straight into the destination grain.
type WGPUGenerator struct { type WGPUGenerator struct {
instance *wgpu.Instance instance *wgpu.Instance
adapter *wgpu.Adapter adapter *wgpu.Adapter
device *wgpu.Device device *wgpu.Device
queue *wgpu.Queue queue *wgpu.Queue
shader *wgpu.ShaderModule shader *wgpu.ShaderModule
bgl *wgpu.BindGroupLayout bgl *wgpu.BindGroupLayout
bg *wgpu.BindGroup bg *wgpu.BindGroup
pl *wgpu.PipelineLayout pl *wgpu.PipelineLayout
pipeline *wgpu.ComputePipeline pipeline *wgpu.ComputePipeline
out *wgpu.Buffer out *wgpu.Buffer
host *wgpu.Buffer host *wgpu.Buffer
uniform *wgpu.Buffer uniform *wgpu.Buffer
params []byte params []byte
width int width int
height int height int
blocks int blocks int
frameSize uint64 activeLineSize int
lineSize int
compactFrameSize uint64
frameSize uint64
} }
var _ FrameGenerator = (*WGPUGenerator)(nil) var _ FrameGenerator = (*WGPUGenerator)(nil)
@@ -47,17 +49,20 @@ var _ FrameGenerator = (*WGPUGenerator)(nil)
// WGPUOption customizes NewWGPUGenerator. // WGPUOption customizes NewWGPUGenerator.
type WGPUOption func(*WGPUGenerator) 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{ g := &WGPUGenerator{
width: int(width), width: int(width),
height: int(height), height: int(height),
blocks: int(width*height) / 6, blocks: ((int(width) + 5) / 6) * int(height),
params: make([]byte, 16), activeLineSize: V210ActiveLineSize(int(width)),
lineSize: V210LineSize(int(width)),
params: make([]byte, 16),
} }
for _, opt := range opts { for _, opt := range opts {
opt(g) 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[0:], uint32(width))
binary.LittleEndian.PutUint32(g.params[4:], uint32(height)) 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() 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{ if g.shader, err = g.device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{
Label: "v210-shader", WGSL: string(wgsl), Label: "v210-shader", WGSL: wgsl,
}); err != nil { }); err != nil {
g.Close() g.Close()
return nil, fmt.Errorf("wgpu: shader: %w", err) return nil, fmt.Errorf("wgpu: shader: %w", err)
} }
if g.out, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{ 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, Usage: wgpu.BufferUsageStorage | wgpu.BufferUsageCopySrc,
}); err != nil { }); err != nil {
g.Close() g.Close()
return nil, fmt.Errorf("wgpu: out buffer: %w", err) return nil, fmt.Errorf("wgpu: out buffer: %w", err)
} }
if g.host, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{ 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, Usage: wgpu.BufferUsageCopyDst | wgpu.BufferUsageMapRead,
}); err != nil { }); err != nil {
g.Close() g.Close()
@@ -124,7 +124,7 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{ if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{
Label: "v210-bg", Layout: g.bgl, Label: "v210-bg", Layout: g.bgl,
Entries: []wgpu.BindGroupEntry{ 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))}, {Binding: 1, Buffer: g.uniform, Size: uint64(len(g.params))},
}, },
}); err != nil { }); err != nil {
@@ -170,7 +170,7 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
if err := pass.End(); err != nil { if err := pass.End(); err != nil {
return fmt.Errorf("wgpu: end pass: %w", err) 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() cmd, err := encoder.Finish()
if err != nil { if err != nil {
return fmt.Errorf("wgpu: finish: %w", err) 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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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) 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 { if err != nil {
_ = g.host.Unmap() _ = g.host.Unmap()
return fmt.Errorf("wgpu: mapped range: %w", err) 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() return g.host.Unmap()
} }
+7 -2
View File
@@ -1,13 +1,18 @@
package generator package generator
import ( import (
"path/filepath"
"testing" "testing"
"mxl-pattern-generator/kernels"
) )
func TestWGPUMoveSquare(t *testing.T) { func TestWGPUMoveSquare(t *testing.T) {
const width, height = 1920, 1080 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 { if err != nil {
t.Fatalf("init: %v", err) t.Fatalf("init: %v", err)
} }
+41 -2
View File
@@ -1,13 +1,18 @@
package generator package generator
import ( import (
"path/filepath"
"testing" "testing"
"mxl-pattern-generator/kernels"
) )
func TestWGPUSMPTEPattern(t *testing.T) { func TestWGPUSMPTEPattern(t *testing.T) {
const width, height = 1920, 1080 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 { if err != nil {
t.Fatalf("init: %v", err) 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" "github.com/qvest-digital/go-mxl/mxl"
) )
type Backend string
const (
BackendAuto Backend = "auto"
BackendGPU Backend = "gpu"
BackendCPU Backend = "cpu"
)
type Config struct { type Config struct {
Definition flowdef.Video Definition flowdef.Video
Pattern string Pattern string
Overlay OverlayConfig Overlay OverlayConfig
Backend Backend
} }
type OverlayConfig struct { type OverlayConfig struct {
@@ -31,6 +40,10 @@ func (c Config) Height() uint {
return c.Definition.FrameHeight return c.Definition.FrameHeight
} }
func (c Config) HasAlpha() bool {
return c.Definition.MediaType == flowdef.MediaTypeV210A
}
func (c Config) Rate() mxl.Rational { func (c Config) Rate() mxl.Rational {
return mxl.Rational{ return mxl.Rational{
Num: int64(c.Definition.GrainRate.Numerator), 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 package video
import "sort" import (
"fmt"
"sort"
"mxl-pattern-generator/kernels"
)
type PatternInfo struct { type PatternInfo struct {
Name string Name string
@@ -9,52 +14,66 @@ type PatternInfo struct {
type pattern struct { type pattern struct {
PatternInfo PatternInfo
kernelPath string kernelSource string
dynamic bool
} }
var patterns = map[string]pattern{ var patterns = map[string]pattern{
"ebu75": { "ebu75": {
PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"}, PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"},
kernelPath: "kernels/static/ebu75.wgsl", kernelSource: mustReadKernel("static/ebu75.wgsl"),
}, },
"ebu75-move": { "ebu75-move": {
PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"}, PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"},
kernelPath: "kernels/dynamic/ebu75.wgsl", kernelSource: mustReadKernel("dynamic/ebu75.wgsl"),
dynamic: true,
}, },
"ebu100": { "ebu100": {
PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"}, PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"},
kernelPath: "kernels/static/ebu100.wgsl", kernelSource: mustReadKernel("static/ebu100.wgsl"),
}, },
"ebu100-move": { "ebu100-move": {
PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"}, PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"},
kernelPath: "kernels/dynamic/ebu100.wgsl", kernelSource: mustReadKernel("dynamic/ebu100.wgsl"),
dynamic: true,
}, },
"smpte": { "smpte": {
PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"}, PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"},
kernelPath: "kernels/static/smpteBars.wgsl", kernelSource: mustReadKernel("static/smpteBars.wgsl"),
}, },
"smpte-move": { "smpte-move": {
PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"}, PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"},
kernelPath: "kernels/dynamic/smpteBars.wgsl", kernelSource: mustReadKernel("dynamic/smpteBars.wgsl"),
dynamic: true,
}, },
"gray-bars": { "gray-bars": {
PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"}, PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"},
kernelPath: "kernels/static/yBars.wgsl", kernelSource: mustReadKernel("static/yBars.wgsl"),
}, },
"gray-bars-move": { "gray-bars-move": {
PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"}, PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"},
kernelPath: "kernels/dynamic/yBars.wgsl", kernelSource: mustReadKernel("dynamic/yBars.wgsl"),
dynamic: true,
}, },
"gray-ramp": { "gray-ramp": {
PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"}, PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"},
kernelPath: "kernels/static/yRamp.wgsl", kernelSource: mustReadKernel("static/yRamp.wgsl"),
}, },
"gray-ramp-move": { "gray-ramp-move": {
PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"}, PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"},
kernelPath: "kernels/dynamic/yRamp.wgsl", 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 { func HasPattern(name string) bool {
_, ok := patterns[name] _, ok := patterns[name]
return ok 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" "errors"
"fmt" "fmt"
"log" "log"
"mxl-pattern-generator/assets"
"mxl-pattern-generator/internal/generator" "mxl-pattern-generator/internal/generator"
"github.com/qvest-digital/go-mxl/mxl" "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) return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
} }
// TODO: fall back to a CPU generator if GPU initialization fails. gen, backend, err := newFrameGenerator(cfg, pattern)
gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath)
if err != nil { if err != nil {
return fmt.Errorf("initialize wgpu video generator: %w", err) return err
} }
log.Printf("video generator backend: %s", backend)
defer func() { defer func() {
if err := gen.Close(); err != nil { if err := gen.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err)) 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 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) flowJSON, err := json.Marshal(cfg.Definition)
if err != nil { if err != nil {
return fmt.Errorf("marshal video flow definition: %w", err) 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 { if err != nil {
return fmt.Errorf("open video grain %d: %w", idx, err) return fmt.Errorf("open video grain %d: %w", idx, err)
} }
if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil { if staticFrame != nil {
return cancelGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err)) if len(grain.Payload) != len(staticFrame) {
} return cancelGrain(grain, fmt.Errorf(
if overlay != nil { "video grain payload size %d, expected %d",
if err := overlay.ApplyV210(grain.Payload); err != nil { len(grain.Payload),
return cancelGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err)) 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 { if err := grain.Commit(grain.TotalSlices, 0); err != nil {
return fmt.Errorf("commit video grain %d: %w", idx, err) 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++ grainsWritten++
idx++ idx++
tick++
if grainsWritten%100 == 0 { if grainsWritten%100 == 0 {
log.Printf("video grains written=%d, next index=%d", grainsWritten, idx) 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 == "" { if cfg.Overlay.Text == "" {
return nil, nil 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 { if err != nil {
return nil, fmt.Errorf("load text overlay font: %w", err) 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let x = (block * 6u) % params.width; let x = (block % blocks_per_row) * 6u;
let py = (block * 6u) / params.width; let py = block / blocks_per_row;
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black // 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); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let x = (block * 6u) % params.width; let x = (block % blocks_per_row) * 6u;
let py = (block * 6u) / params.width; let py = block / blocks_per_row;
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black // 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); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let x = (block * 6u) % params.width; let x = (block % blocks_per_row) * 6u;
let y_px = (block * 6u) / params.width; let y_px = block / blocks_per_row;
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue // 75% Color Table: white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u); 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 x = (block % blocks_per_row) * 6u;
let py = (block * 6u) / params.width; let py = block / blocks_per_row;
// Moving square: horizontal oscillation around screen center. // Moving square: horizontal oscillation around screen center.
// frame is a small tick; converting the huge raw grain index here // frame is a small tick; converting the huge raw grain index here
// would destroy f32 precision and freeze the motion. // 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let x = (block * 6u) % params.width; let x = (block % blocks_per_row) * 6u;
let py = (block * 6u) / params.width; let py = block / blocks_per_row;
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue // Bar order: 75% white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; 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 // 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); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; 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 // 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); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; return;
} }
let x = (block * 6u) % params.width; let x = (block % blocks_per_row) * 6u;
let y_px = (block * 6u) / params.width; let y_px = block / blocks_per_row;
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue // 75% Color Table: white, yellow, cyan, green, magenta, red, blue
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u); 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; 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); 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) // 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 y: array<u32, 6>;
var cb: array<u32, 6>; var cb: array<u32, 6>;
var cr: 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) @compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let block = gid.x; 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) { if (block >= total) {
return; 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 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 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 y: array<u32, 6>;
var cb: array<u32, 6>; var cb: array<u32, 6>;
var cr: array<u32, 6>; var cr: array<u32, 6>;