Compare commits

...

3 Commits

Author SHA1 Message Date
Dmitry Sergeev 1e804897c6 alpha perfomance fix 2026-09-18 10:25:35 +03:00
Dmitry Sergeev dea2e6a59f alpha patterns 2026-09-18 10:11:07 +03:00
Dmitry Sergeev 9b194b5591 v210A flowdef 2026-09-18 09:54:36 +03:00
15 changed files with 793 additions and 15 deletions
+14 -2
View File
@@ -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")
@@ -355,7 +363,7 @@ func buildVideoConfig(args appArgs) (*video.Config, error) {
)
}
definition, err = flowdef.ParseV210Video(data)
definition, err = flowdef.ParseVideo(data)
if err != nil {
return nil, fmt.Errorf(
"parse video flow definition %q: %w",
@@ -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,
+44
View File
@@ -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) {
+1 -1
View File
@@ -14,7 +14,7 @@ import (
const (
Name = "MXL pattern generator"
Version = "0.1.0"
Version = "0.2.0"
)
type Config struct {
+21 -5
View File
@@ -12,6 +12,7 @@ const (
FormatVideo = "urn:x-nmos:format:video"
FormatAudio = "urn:x-nmos:format:audio"
MediaTypeV210 = "video/v210"
MediaTypeV210A = "video/v210a"
MediaTypeFloat32 = "audio/float32"
InterlaceProgressive = "progressive"
@@ -75,7 +76,12 @@ type VideoComponent struct {
BitDepth uint `json:"bit_depth"`
}
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
func newVideo(
id string,
width, height uint,
rate Rational,
mediaType string,
) (Video, error) {
definition := Video{
Common: Common{
Description: "go-mxl-pattern-gen generated video",
@@ -86,7 +92,7 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
Format: FormatVideo,
Label: "go-mxl-pattern-gen generated video",
Parents: []string{},
MediaType: MediaTypeV210,
MediaType: mediaType,
},
GrainRate: rate,
FrameWidth: width,
@@ -105,7 +111,15 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
return definition, nil
}
func ParseV210Video(data []byte) (Video, error) {
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
return newVideo(id, width, height, rate, MediaTypeV210)
}
func NewV210AVideo(id string, width, height uint, rate Rational) (Video, error) {
return newVideo(id, width, height, rate, MediaTypeV210A)
}
func ParseVideo(data []byte) (Video, error) {
var definition Video
if err := json.Unmarshal(data, &definition); err != nil {
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
@@ -123,8 +137,10 @@ func (v Video) Validate() error {
if v.Format != FormatVideo {
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
}
if v.MediaType != MediaTypeV210 {
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, v.MediaType)
switch v.MediaType {
case MediaTypeV210, MediaTypeV210A:
default:
return fmt.Errorf("media_type must be %q or %q, got %q", MediaTypeV210, MediaTypeV210A, v.MediaType)
}
if v.InterlaceMode != InterlaceProgressive {
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
+58 -5
View File
@@ -28,6 +28,29 @@ func TestNewV210Video(t *testing.T) {
}
}
func TestNewV210AVideo(t *testing.T) {
definition, err := NewV210AVideo(testVideoID, 1280, 720, Rational{Numerator: 50, Denominator: 1})
if err != nil {
t.Fatalf("NewV210AVideo: %v", err)
}
if definition.MediaType != MediaTypeV210A {
t.Fatalf("media type = %q, want %q", definition.MediaType, MediaTypeV210A)
}
wantComponents := []VideoComponent{
{Name: "Y", Width: 1280, Height: 720, BitDepth: 10},
{Name: "Cb", Width: 640, Height: 720, BitDepth: 10},
{Name: "Cr", Width: 640, Height: 720, BitDepth: 10},
}
if len(definition.Components) != len(wantComponents) {
t.Fatalf("component count = %d, want %d", len(definition.Components), len(wantComponents))
}
for i, want := range wantComponents {
if definition.Components[i] != want {
t.Fatalf("component %d = %+v, want %+v", i, definition.Components[i], want)
}
}
}
func TestNewV210VideoRejectsOddWidth(t *testing.T) {
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
if err == nil || !strings.Contains(err.Error(), "even") {
@@ -35,7 +58,7 @@ func TestNewV210VideoRejectsOddWidth(t *testing.T) {
}
}
func TestParseV210Video(t *testing.T) {
func TestParseVideo(t *testing.T) {
want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
@@ -45,28 +68,58 @@ func TestParseV210Video(t *testing.T) {
t.Fatalf("json.Marshal: %v", err)
}
got, err := ParseV210Video(data)
got, err := ParseVideo(data)
if err != nil {
t.Fatalf("ParseV210Video: %v", err)
t.Fatalf("ParseVideo: %v", err)
}
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
t.Fatalf("parsed definition = %+v, want %+v", got, want)
}
}
func TestParseV210VideoRejectsAudio(t *testing.T) {
func TestParseVideoAcceptsV210A(t *testing.T) {
want, err := NewV210AVideo(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210AVideo: %v", err)
}
data, err := json.Marshal(want)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got, err := ParseVideo(data)
if err != nil {
t.Fatalf("ParseVideo: %v", err)
}
if got.MediaType != MediaTypeV210A {
t.Fatalf("media type = %q, want %q", got.MediaType, MediaTypeV210A)
}
}
func TestParseVideoRejectsAudio(t *testing.T) {
data := []byte(`{
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
"format":"urn:x-nmos:format:audio",
"media_type":"audio/float32"
}`)
_, err := ParseV210Video(data)
_, err := ParseVideo(data)
if err == nil || !strings.Contains(err.Error(), "format must be") {
t.Fatalf("error = %v, want video format error", err)
}
}
func TestVideoRejectsUnknownMediaType(t *testing.T) {
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
definition.MediaType = "video/unknown"
if err := definition.Validate(); err == nil || !strings.Contains(err.Error(), "media_type") {
t.Fatalf("error = %v, want media_type error", err)
}
}
func TestNewFloat32Audio(t *testing.T) {
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
if err != nil {
+92
View File
@@ -0,0 +1,92 @@
package generator
import (
"encoding/binary"
"fmt"
"math"
)
const (
alphaTransparent uint32 = 64
alphaOpaque uint32 = 940
)
func packAlphaBlock(dst []byte, samples [3]uint32) {
const mask uint32 = 0x3ff
word := samples[0]&mask |
(samples[1]&mask)<<10 |
(samples[2]&mask)<<20
binary.LittleEndian.PutUint32(dst, word)
}
func fillAlphaPlane(
dst []byte,
width, height int,
value uint32,
) error {
need := AlphaFrameSize(width, height)
if len(dst) < need {
return fmt.Errorf(
"alpha: destination is too small: got %d bytes, need %d",
len(dst),
need,
)
}
stride := AlphaLineSize(width)
for y := 0; y < height; y++ {
row := dst[y*stride : (y+1)*stride]
for x := 0; x < width; x += 3 {
var samples [3]uint32
for i := range samples {
if x+i < width {
samples[i] = value
}
}
packAlphaBlock(row[x/3*4:], samples)
}
}
return nil
}
func patchAlphaMovingSquare(dst []byte, width, height, frameIndex int) error {
need := AlphaFrameSize(width, height)
if len(dst) < need {
return fmt.Errorf(
"alpha: destination is too small: got %d bytes, need %d",
len(dst),
need,
)
}
bounds := movingSquareBounds(width, height, frameIndex)
firstPixelX := max(0, int(math.Floor(bounds.minX)))
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
firstBlockX := firstPixelX / 3 * 3
lastBlockX := min(width, (lastPixelX+2)/3*3)
firstY := max(0, int(math.Floor(bounds.minY)))
lastY := min(height, int(math.Ceil(bounds.maxY)))
stride := AlphaLineSize(width)
for y := firstY; y < lastY; y++ {
for blockX := firstBlockX; blockX < lastBlockX; blockX += 3 {
var samples [3]uint32
for i := range samples {
x := blockX + i
switch {
case x >= width:
samples[i] = 0
case bounds.contains(x, y):
samples[i] = alphaTransparent
default:
samples[i] = alphaOpaque
}
}
offset := y*stride + blockX/3*4
packAlphaBlock(dst[offset:], samples)
}
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package generator
import (
"encoding/binary"
"strings"
"testing"
)
func TestPackAlphaBlock(t *testing.T) {
var dst [4]byte
packAlphaBlock(dst[:], [3]uint32{64, 512, 940})
word := binary.LittleEndian.Uint32(dst[:])
if got := word & 0x3ff; got != 64 {
t.Errorf("sample 0 = %d, want 64", got)
}
if got := (word >> 10) & 0x3ff; got != 512 {
t.Errorf("sample 1 = %d, want 512", got)
}
if got := (word >> 20) & 0x3ff; got != 940 {
t.Errorf("sample 2 = %d, want 940", got)
}
if got := word >> 30; got != 0 {
t.Errorf("unused bits = %d, want 0", got)
}
}
func TestPackAlphaBlockMasksSamples(t *testing.T) {
var dst [4]byte
packAlphaBlock(dst[:], [3]uint32{0x401, 0x802, 0xc03})
word := binary.LittleEndian.Uint32(dst[:])
if got := word & 0x3ff; got != 1 {
t.Errorf("sample 0 = %d, want 1", got)
}
if got := (word >> 10) & 0x3ff; got != 2 {
t.Errorf("sample 1 = %d, want 2", got)
}
if got := (word >> 20) & 0x3ff; got != 3 {
t.Errorf("sample 2 = %d, want 3", got)
}
}
func TestFillAlphaPlaneCompleteBlocks(t *testing.T) {
const width, height = 6, 2
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
if got := sampleAlpha(dst, width, x, y); got != alphaOpaque {
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaOpaque)
}
}
}
}
func TestFillAlphaPlaneZerosPartialBlockPadding(t *testing.T) {
const width, height = 4, 2
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaTransparent); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
stride := AlphaLineSize(width)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
if got := sampleAlpha(dst, width, x, y); got != alphaTransparent {
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaTransparent)
}
}
lastWord := binary.LittleEndian.Uint32(dst[y*stride+4:])
if got := (lastWord >> 10) & 0x3ff; got != 0 {
t.Errorf("row %d padding sample 1 = %d, want 0", y, got)
}
if got := (lastWord >> 20) & 0x3ff; got != 0 {
t.Errorf("row %d padding sample 2 = %d, want 0", y, got)
}
if got := lastWord >> 30; got != 0 {
t.Errorf("row %d unused bits = %d, want 0", y, got)
}
}
}
func TestFillAlphaPlaneRejectsSmallDestination(t *testing.T) {
const width, height = 6, 2
dst := make([]byte, AlphaFrameSize(width, height)-1)
err := fillAlphaPlane(dst, width, height, alphaOpaque)
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
t.Fatalf("error = %v, want destination size error", err)
}
}
func TestPatchAlphaMovingSquare(t *testing.T) {
const width, height = 304, 200
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
t.Fatalf("patchAlphaMovingSquare: %v", err)
}
if got := sampleAlpha(dst, width, width/2, height/2); got != alphaTransparent {
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
}
if got := sampleAlpha(dst, width, 10, height/2); got != alphaOpaque {
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
}
// At frame zero the square begins at x=77. Its first three-sample word
// therefore contains two opaque samples followed by one transparent sample.
for x, want := range []uint32{alphaOpaque, alphaOpaque, alphaTransparent} {
if got := sampleAlpha(dst, width, 75+x, height/2); got != want {
t.Errorf("boundary sample x=%d = %d, want %d", 75+x, got, want)
}
}
}
func TestPatchAlphaMovingSquarePreservesPartialBlockPadding(t *testing.T) {
const width, height = 100, 200
dst := make([]byte, AlphaFrameSize(width, height))
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
t.Fatalf("fillAlphaPlane: %v", err)
}
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
t.Fatalf("patchAlphaMovingSquare: %v", err)
}
lastWordOffset := height/2*AlphaLineSize(width) + (width/3)*4
lastWord := binary.LittleEndian.Uint32(dst[lastWordOffset:])
if got := lastWord & 0x3ff; got != alphaTransparent {
t.Errorf("last visible sample = %d, want %d", got, alphaTransparent)
}
if got := lastWord >> 10; got != 0 {
t.Errorf("partial-block padding bits = %#x, want 0", got)
}
}
func TestPatchAlphaMovingSquareRejectsSmallDestination(t *testing.T) {
const width, height = 100, 200
err := patchAlphaMovingSquare(
make([]byte, AlphaFrameSize(width, height)-1),
width,
height,
0,
)
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
t.Fatalf("error = %v, want destination size error", err)
}
}
func sampleAlpha(buf []byte, width, x, y int) uint32 {
offset := y*AlphaLineSize(width) + x/3*4
word := binary.LittleEndian.Uint32(buf[offset:])
return (word >> uint(x%3*10)) & 0x3ff
}
+17
View File
@@ -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)
}
+41
View File
@@ -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:])
+73
View File
@@ -0,0 +1,73 @@
package generator
import "fmt"
// V210AGenerator combines a v210 fill generator with a packed 10-bit alpha
// plane. It owns the fill generator and closes it from Close.
type V210AGenerator struct {
fill FrameGenerator
width int
height int
fillSize int
fillBase []byte
alphaBase []byte
}
var _ FrameGenerator = (*V210AGenerator)(nil)
func NewV210AGenerator(
fill FrameGenerator,
width, height uint,
fillDynamic bool,
) (*V210AGenerator, error) {
if fill == nil {
return nil, fmt.Errorf("v210a: fill generator is nil")
}
if width == 0 || height == 0 {
return nil, fmt.Errorf("v210a: width and height must be greater than zero, got %dx%d", width, height)
}
if width%2 != 0 {
return nil, fmt.Errorf("v210a: width must be even for 4:2:2 video, got %d", width)
}
g := &V210AGenerator{
fill: fill,
width: int(width),
height: int(height),
fillSize: V210FrameSize(int(width), int(height)),
alphaBase: make([]byte, AlphaFrameSize(int(width), int(height))),
}
if err := fillAlphaPlane(g.alphaBase, g.width, g.height, alphaOpaque); err != nil {
return nil, fmt.Errorf("v210a: initialize alpha plane: %w", err)
}
if !fillDynamic {
g.fillBase = make([]byte, g.fillSize)
if err := fill.GenerateFrame(g.fillBase, 0); err != nil {
return nil, fmt.Errorf("v210a: initialize static fill: %w", err)
}
}
return g, nil
}
func (g *V210AGenerator) GenerateFrame(dst []byte, frameIndex int) error {
need := V210AFrameSize(g.width, g.height)
if len(dst) < need {
return fmt.Errorf("v210a: destination is too small: got %d bytes, need %d", len(dst), need)
}
if g.fillBase != nil {
copy(dst[:g.fillSize], g.fillBase)
} else if err := g.fill.GenerateFrame(dst[:g.fillSize], frameIndex); err != nil {
return fmt.Errorf("v210a: generate dynamic fill frame %d: %w", frameIndex, err)
}
alpha := dst[g.fillSize:need]
copy(alpha, g.alphaBase)
if err := patchAlphaMovingSquare(alpha, g.width, g.height, frameIndex); err != nil {
return fmt.Errorf("v210a: patch alpha frame %d: %w", frameIndex, err)
}
return nil
}
func (g *V210AGenerator) Close() error {
return g.fill.Close()
}
+171
View File
@@ -0,0 +1,171 @@
package generator
import (
"errors"
"strings"
"testing"
)
type fakeFrameGenerator struct {
generateErr error
closeErr error
closed bool
calls int
}
func (g *fakeFrameGenerator) GenerateFrame(dst []byte, frameIndex int) error {
g.calls++
if g.generateErr != nil {
return g.generateErr
}
for i := range dst {
dst[i] = byte(frameIndex)
}
return nil
}
func (g *fakeFrameGenerator) Close() error {
g.closed = true
return g.closeErr
}
func TestV210AGeneratorLayoutAndAlpha(t *testing.T) {
const width, height = 304, 200
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
dst := make([]byte, V210AFrameSize(width, height))
if err := g.GenerateFrame(dst, 7); err != nil {
t.Fatalf("GenerateFrame: %v", err)
}
fillSize := V210FrameSize(width, height)
for i, b := range dst[:fillSize] {
if b != 7 {
t.Fatalf("fill byte %d = %#x, want 0x07", i, b)
}
}
alpha := dst[fillSize:]
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaTransparent {
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
}
if got := sampleAlpha(alpha, width, 10, height/2); got != alphaOpaque {
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
}
}
func TestV210AGeneratorRestoresAlphaBase(t *testing.T) {
const width, height = 304, 200
g, err := NewV210AGenerator(&fakeFrameGenerator{}, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
dst := make([]byte, V210AFrameSize(width, height))
if err := g.GenerateFrame(dst, 0); err != nil {
t.Fatalf("GenerateFrame(0): %v", err)
}
alpha := dst[V210FrameSize(width, height):]
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaTransparent {
t.Fatalf("frame 0 center = %d, want transparent %d", got, alphaTransparent)
}
if err := g.GenerateFrame(dst, 79); err != nil {
t.Fatalf("GenerateFrame(79): %v", err)
}
if got := sampleAlpha(alpha, width, width/2, height/2); got != alphaOpaque {
t.Errorf("old square position = %d, want restored opaque %d", got, alphaOpaque)
}
if got := sampleAlpha(alpha, width, 250, height/2); got != alphaTransparent {
t.Errorf("new square position = %d, want transparent %d", got, alphaTransparent)
}
}
func TestV210AGeneratorCachesStaticFill(t *testing.T) {
const width, height = 100, 20
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, false)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if fill.calls != 1 {
t.Fatalf("fill calls after construction = %d, want 1", fill.calls)
}
dst := make([]byte, V210AFrameSize(width, height))
for _, frameIndex := range []int{7, 19} {
if err := g.GenerateFrame(dst, frameIndex); err != nil {
t.Fatalf("GenerateFrame(%d): %v", frameIndex, err)
}
}
if fill.calls != 1 {
t.Errorf("fill calls after two frames = %d, want 1", fill.calls)
}
for i, b := range dst[:V210FrameSize(width, height)] {
if b != 0 {
t.Fatalf("cached fill byte %d = %#x, want frame-zero value 0", i, b)
}
}
}
func TestV210AGeneratorRegeneratesDynamicFill(t *testing.T) {
const width, height = 100, 20
fill := &fakeFrameGenerator{}
g, err := NewV210AGenerator(fill, width, height, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if fill.calls != 0 {
t.Fatalf("fill calls after construction = %d, want 0", fill.calls)
}
dst := make([]byte, V210AFrameSize(width, height))
for _, frameIndex := range []int{7, 19} {
if err := g.GenerateFrame(dst, frameIndex); err != nil {
t.Fatalf("GenerateFrame(%d): %v", frameIndex, err)
}
}
if fill.calls != 2 {
t.Errorf("fill calls after two frames = %d, want 2", fill.calls)
}
if got := dst[0]; got != 19 {
t.Errorf("dynamic fill byte = %#x, want frame-index value 0x13", got)
}
}
func TestV210AGeneratorErrors(t *testing.T) {
if _, err := NewV210AGenerator(nil, 1920, 1080, false); err == nil || !strings.Contains(err.Error(), "nil") {
t.Fatalf("nil fill error = %v", err)
}
fillErr := errors.New("fill failed")
if _, err := NewV210AGenerator(&fakeFrameGenerator{generateErr: fillErr}, 100, 20, false); !errors.Is(err, fillErr) {
t.Fatalf("static fill initialization error = %v, want wrapped %v", err, fillErr)
}
g, err := NewV210AGenerator(&fakeFrameGenerator{generateErr: fillErr}, 100, 20, true)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if err := g.GenerateFrame(make([]byte, V210AFrameSize(100, 20)-1), 0); err == nil || !strings.Contains(err.Error(), "too small") {
t.Fatalf("small destination error = %v", err)
}
if err := g.GenerateFrame(make([]byte, V210AFrameSize(100, 20)), 3); !errors.Is(err, fillErr) {
t.Fatalf("fill error = %v, want wrapped %v", err, fillErr)
}
}
func TestV210AGeneratorClosesFill(t *testing.T) {
closeErr := errors.New("close failed")
fill := &fakeFrameGenerator{closeErr: closeErr}
g, err := NewV210AGenerator(fill, 100, 20, false)
if err != nil {
t.Fatalf("NewV210AGenerator: %v", err)
}
if err := g.Close(); !errors.Is(err, closeErr) {
t.Fatalf("Close = %v, want %v", err, closeErr)
}
if !fill.closed {
t.Fatal("wrapped fill generator was not closed")
}
}
+4
View File
@@ -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),
+29 -1
View File
@@ -17,7 +17,35 @@ 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, 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(
+66
View File
@@ -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, 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)
}
}
}
+1 -1
View File
@@ -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)