Compare commits
14 Commits
d341d22611
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e804897c6 | |||
| dea2e6a59f | |||
| 9b194b5591 | |||
| 4d8035a434 | |||
| efa958723a | |||
| 723bef6342 | |||
| b85293b5d2 | |||
| fa787b5ef1 | |||
| f5df9506ce | |||
| 4d9485220a | |||
| e30cb7a168 | |||
| de4dfcf101 | |||
| d26d9442a0 | |||
| d3f0b533e3 |
@@ -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
|
||||||
+81
-92
@@ -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 {
|
||||||
@@ -47,6 +47,9 @@ type appArgs struct {
|
|||||||
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
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-17
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gogpu/gputypes"
|
"github.com/gogpu/gputypes"
|
||||||
@@ -39,6 +38,9 @@ type WGPUGenerator struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
blocks int
|
blocks int
|
||||||
|
activeLineSize int
|
||||||
|
lineSize int
|
||||||
|
compactFrameSize uint64
|
||||||
frameSize uint64
|
frameSize uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
activeLineSize: V210ActiveLineSize(int(width)),
|
||||||
|
lineSize: V210LineSize(int(width)),
|
||||||
params: make([]byte, 16),
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-12
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-10
@@ -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,6 +85,16 @@ 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 staticFrame != nil {
|
||||||
|
if len(grain.Payload) != len(staticFrame) {
|
||||||
|
return cancelGrain(grain, fmt.Errorf(
|
||||||
|
"video grain payload size %d, expected %d",
|
||||||
|
len(grain.Payload),
|
||||||
|
len(staticFrame),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
copy(grain.Payload, staticFrame)
|
||||||
|
} else {
|
||||||
if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil {
|
if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil {
|
||||||
return cancelGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err))
|
return cancelGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err))
|
||||||
}
|
}
|
||||||
@@ -76,13 +103,14 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
|
|||||||
return cancelGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err))
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user