From dea2e6a59f29815e132cf54e96e0de75d566ff5b Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Fri, 18 Sep 2026 10:11:07 +0300 Subject: [PATCH] alpha patterns --- cmd/mxl-pattern/main.go | 14 ++- cmd/mxl-pattern/main_test.go | 44 +++++++++ internal/generator/alpha.go | 92 ++++++++++++++++++ internal/generator/alpha_test.go | 161 +++++++++++++++++++++++++++++++ internal/generator/v210.go | 17 ++++ internal/generator/v210_test.go | 41 ++++++++ internal/generator/v210a.go | 63 ++++++++++++ internal/generator/v210a_test.go | 116 ++++++++++++++++++++++ internal/video/config.go | 4 + internal/video/generator.go | 26 ++++- internal/video/generator_test.go | 66 +++++++++++++ internal/video/runner.go | 2 +- 12 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 internal/generator/alpha.go create mode 100644 internal/generator/alpha_test.go create mode 100644 internal/generator/v210a.go create mode 100644 internal/generator/v210a_test.go diff --git a/cmd/mxl-pattern/main.go b/cmd/mxl-pattern/main.go index 65f6a14..ab7245c 100644 --- a/cmd/mxl-pattern/main.go +++ b/cmd/mxl-pattern/main.go @@ -48,6 +48,7 @@ type appArgs struct { videoFPS string videoUUID string noVideo bool + videoAlpha bool videoBackend string audioChannels uint8 @@ -173,6 +174,12 @@ func validateMediaSelection(args appArgs) error { 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 } @@ -287,6 +294,7 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) { fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS") fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided") fs.BoolVar(&args.noVideo, "no-video", false, "Disable video generation; audio must be enabled") + fs.BoolVar(&args.videoAlpha, "alpha", false, "Generate video/v210a with a moving transparent square") fs.StringVar(&args.videoBackend, "backend", string(video.BackendAuto), "Video generator backend: auto, gpu or cpu") // Audio pattern flags fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz") @@ -370,7 +378,11 @@ func buildVideoConfig(args appArgs) (*video.Config, error) { } var err error - definition, err = flowdef.NewV210Video( + newVideo := flowdef.NewV210Video + if args.videoAlpha { + newVideo = flowdef.NewV210AVideo + } + definition, err = newVideo( args.videoUUID, args.videoWidth, args.videoHeight, diff --git a/cmd/mxl-pattern/main_test.go b/cmd/mxl-pattern/main_test.go index 60b7453..8cdd7e5 100644 --- a/cmd/mxl-pattern/main_test.go +++ b/cmd/mxl-pattern/main_test.go @@ -10,8 +10,22 @@ import ( "mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/flowdef" "mxl-pattern-generator/internal/video" + + "github.com/spf13/pflag" ) +func TestAlphaFlag(t *testing.T) { + var args appArgs + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + addFlags(flags, &args) + if err := flags.Parse([]string{"--alpha"}); err != nil { + t.Fatalf("Parse: %v", err) + } + if !args.videoAlpha { + t.Fatal("videoAlpha = false, want true") + } +} + func TestParseArgsHelpStopsBeforeValidation(t *testing.T) { var stdout, stderr bytes.Buffer @@ -181,6 +195,16 @@ func TestValidateMediaSelection(t *testing.T) { 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 { @@ -233,6 +257,26 @@ func TestBuildVideoConfigFromArgs(t *testing.T) { } } +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) { diff --git a/internal/generator/alpha.go b/internal/generator/alpha.go new file mode 100644 index 0000000..1132b21 --- /dev/null +++ b/internal/generator/alpha.go @@ -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 +} diff --git a/internal/generator/alpha_test.go b/internal/generator/alpha_test.go new file mode 100644 index 0000000..f7690cc --- /dev/null +++ b/internal/generator/alpha_test.go @@ -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 +} diff --git a/internal/generator/v210.go b/internal/generator/v210.go index 059896d..27ab8f5 100644 --- a/internal/generator/v210.go +++ b/internal/generator/v210.go @@ -18,3 +18,20 @@ func V210LineSize(width int) int { 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) +} diff --git a/internal/generator/v210_test.go b/internal/generator/v210_test.go index 09affc3..8f34d8b 100644 --- a/internal/generator/v210_test.go +++ b/internal/generator/v210_test.go @@ -2,6 +2,7 @@ package generator import ( "encoding/binary" + "fmt" "testing" ) @@ -30,6 +31,46 @@ func TestV210Sizes(t *testing.T) { } } +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) { offset := y*V210LineSize(width) + x/6*16 w0 := binary.LittleEndian.Uint32(buf[offset:]) diff --git a/internal/generator/v210a.go b/internal/generator/v210a.go new file mode 100644 index 0000000..69070f5 --- /dev/null +++ b/internal/generator/v210a.go @@ -0,0 +1,63 @@ +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 + alphaBase []byte +} + +var _ FrameGenerator = (*V210AGenerator)(nil) + +func NewV210AGenerator( + fill FrameGenerator, + width, height uint, +) (*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) + } + 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 err := g.fill.GenerateFrame(dst[:g.fillSize], frameIndex); err != nil { + return fmt.Errorf("v210a: generate 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() +} diff --git a/internal/generator/v210a_test.go b/internal/generator/v210a_test.go new file mode 100644 index 0000000..72261a9 --- /dev/null +++ b/internal/generator/v210a_test.go @@ -0,0 +1,116 @@ +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) + 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) + 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 TestV210AGeneratorErrors(t *testing.T) { + if _, err := NewV210AGenerator(nil, 1920, 1080); err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("nil fill error = %v", err) + } + + fillErr := errors.New("fill failed") + g, err := NewV210AGenerator(&fakeFrameGenerator{generateErr: fillErr}, 100, 20) + 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) + 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") + } +} diff --git a/internal/video/config.go b/internal/video/config.go index d044d20..6ef9fe3 100644 --- a/internal/video/config.go +++ b/internal/video/config.go @@ -40,6 +40,10 @@ func (c Config) Height() uint { return c.Definition.FrameHeight } +func (c Config) HasAlpha() bool { + return c.Definition.MediaType == flowdef.MediaTypeV210A +} + func (c Config) Rate() mxl.Rational { return mxl.Rational{ Num: int64(c.Definition.GrainRate.Numerator), diff --git a/internal/video/generator.go b/internal/video/generator.go index f950cbf..f60d15e 100644 --- a/internal/video/generator.go +++ b/internal/video/generator.go @@ -17,7 +17,31 @@ func newFrameGenerator(cfg Config, pattern pattern) (generator.FrameGenerator, B newCPU := func() (generator.FrameGenerator, error) { return generator.NewCPUPatternGenerator(cfg.Width(), cfg.Height(), cfg.Pattern) } - return selectFrameGenerator(cfg.Backend, newGPU, newCPU) + fill, backend, err := selectFrameGenerator(cfg.Backend, newGPU, newCPU) + if err != nil { + return nil, "", err + } + gen, err := wrapAlphaGenerator(cfg, fill) + if err != nil { + return nil, "", err + } + return gen, backend, nil +} + +func wrapAlphaGenerator(cfg Config, fill generator.FrameGenerator) (generator.FrameGenerator, error) { + if !cfg.HasAlpha() { + return fill, nil + } + + gen, err := generator.NewV210AGenerator(fill, cfg.Width(), cfg.Height()) + if err != nil { + closeErr := fill.Close() + return nil, errors.Join( + fmt.Errorf("initialize v210a generator: %w", err), + closeErr, + ) + } + return gen, nil } func selectFrameGenerator( diff --git a/internal/video/generator_test.go b/internal/video/generator_test.go index cc1ffb9..cf1cc24 100644 --- a/internal/video/generator_test.go +++ b/internal/video/generator_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "mxl-pattern-generator/internal/flowdef" "mxl-pattern-generator/internal/generator" ) @@ -110,3 +111,68 @@ func TestSelectFrameGenerator(t *testing.T) { }) } } + +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) + 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) + } + } +} diff --git a/internal/video/runner.go b/internal/video/runner.go index aa3f5b5..7565cf0 100644 --- a/internal/video/runner.go +++ b/internal/video/runner.go @@ -36,7 +36,7 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) { } var staticFrame []byte - if !pattern.dynamic { + if !pattern.dynamic && !cfg.HasAlpha() { frameSize := generator.V210FrameSize(int(cfg.Width()), int(cfg.Height())) staticFrame = make([]byte, frameSize)