CPU patterns fallback

This commit is contained in:
Dmitry Sergeev
2026-09-18 00:41:38 +03:00
parent b85293b5d2
commit 723bef6342
8 changed files with 616 additions and 98 deletions
+39 -48
View File
@@ -11,79 +11,57 @@ type YCbCr10 struct {
Cr uint32
}
type PixelSampler func(x, y, width, height, frameIndex int) YCbCr10
// 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
sampler PixelSampler
base []byte
patch FrameRenderer
}
func NewCPUGenerator(
width, height uint,
sampler PixelSampler,
baseRenderer FrameRenderer,
patch FrameRenderer,
) (*CPUGenerator, error) {
if width == 0 || height == 0 {
return nil, fmt.Errorf(
"width and height must be greater than 0, got: %dx%d",
width,
height,
)
return nil, fmt.Errorf("cpu: width and height must be greater than zero, got %dx%d", width, height)
}
if width%6 != 0 {
return nil, fmt.Errorf(
"width must be divisible by 6, got: %d",
width,
)
return nil, fmt.Errorf("cpu: width must be divisible by 6, got %d", width)
}
if sampler == nil {
return nil, fmt.Errorf("pixel sampler is nil")
if baseRenderer == nil {
return nil, fmt.Errorf("cpu: base renderer is nil")
}
return &CPUGenerator{
g := &CPUGenerator{
width: int(width),
height: int(height),
sampler: sampler,
}, nil
base: make([]byte, int(width/6*height)*16),
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(dest []byte, frameIndex int) error {
frameSize := (g.width / 6) * g.height * 16
if len(dest) < frameSize {
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(dest),
frameSize,
len(dst),
len(g.base),
)
}
const componentMask uint32 = 0x3ff
offset := 0
for y := 0; y < g.height; y++ {
for x := 0; x < g.width; x += 6 {
var pixels [6]YCbCr10
for i := range pixels {
pixels[i] = g.sampler(x+i, y, g.width, g.height, frameIndex)
}
copy(dst, g.base)
word0 := pixels[0].Cb&componentMask |
(pixels[0].Y&componentMask)<<10 |
(pixels[0].Cr&componentMask)<<20
word1 := pixels[1].Y&componentMask |
(pixels[2].Cb&componentMask)<<10 |
(pixels[2].Y&componentMask)<<20
word2 := pixels[2].Cr&componentMask |
(pixels[3].Y&componentMask)<<10 |
(pixels[4].Cb&componentMask)<<20
word3 := pixels[4].Y&componentMask |
(pixels[4].Cr&componentMask)<<10 |
(pixels[5].Y&componentMask)<<20
binary.LittleEndian.PutUint32(dest[offset:], word0)
binary.LittleEndian.PutUint32(dest[offset+4:], word1)
binary.LittleEndian.PutUint32(dest[offset+8:], word2)
binary.LittleEndian.PutUint32(dest[offset+12:], word3)
offset += 16
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)
}
}
@@ -91,3 +69,16 @@ func (g *CPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
}
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)
}
+73
View File
@@ -0,0 +1,73 @@
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)))
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*width + 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,
}
}
+119
View File
@@ -0,0 +1,119 @@
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 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 "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 {
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*width + x) / 6 * 16
packV210Block(dst[offset:offset+16], pixels)
}
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
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 TestCPUSMPTEIsNotFakedByAnotherPattern(t *testing.T) {
_, err := NewCPUPatternGenerator(1920, 1080, "smpte")
if err == nil || !strings.Contains(err.Error(), "not implemented") {
t.Fatalf("error = %v, want not implemented 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)
}
}
+71 -40
View File
@@ -1,27 +1,37 @@
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) {
sampler := func(_, _, _, _, _ int) YCbCr10 { return YCbCr10{} }
renderer := fillFrame(0)
tests := []struct {
name string
width uint
height uint
sampler PixelSampler
renderer FrameRenderer
wantErrSub string
}{
{name: "zero width", height: 1, sampler: sampler, wantErrSub: "greater than 0"},
{name: "zero height", width: 6, sampler: sampler, wantErrSub: "greater than 0"},
{name: "unaligned width", width: 7, height: 1, sampler: sampler, wantErrSub: "divisible by 6"},
{name: "nil sampler", width: 6, height: 1, wantErrSub: "sampler is nil"},
{name: "zero width", height: 1, renderer: renderer, wantErrSub: "greater than zero"},
{name: "zero height", width: 6, renderer: renderer, wantErrSub: "greater than zero"},
{name: "unaligned width", width: 7, height: 1, renderer: renderer, wantErrSub: "divisible by 6"},
{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.sampler)
_, 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)
}
@@ -29,63 +39,84 @@ func TestNewCPUGeneratorValidation(t *testing.T) {
}
}
func TestCPUGeneratorGenerateFrame(t *testing.T) {
const width, height = 12, 2
const frameIndex = 7
g, err := NewCPUGenerator(width, height, func(x, y, _, _ int, tick int) YCbCr10 {
return YCbCr10{
Y: uint32(100 + x + 10*y + tick),
Cb: uint32(200 + x + 10*y + tick),
Cr: uint32(300 + x + 10*y + tick),
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, width*height*8/3)
if err := g.GenerateFrame(frame, frameIndex); err != nil {
t.Fatalf("GenerateFrame: %v", err)
frame := make([]byte, 32)
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])
}
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
gotY, gotCb, gotCr := sampleV210(frame, width, x, y)
chromaX := x - x%2
wantY := uint32(100 + x + 10*y + frameIndex)
wantCb := uint32(200 + chromaX + 10*y + frameIndex)
wantCr := uint32(300 + chromaX + 10*y + frameIndex)
if gotY != wantY || gotCb != wantCb || gotCr != wantCr {
t.Fatalf("pixel (%d,%d): got %d/%d/%d, want %d/%d/%d",
x, y, gotY, gotCb, gotCr, wantY, wantCb, wantCr)
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 TestCPUGeneratorMasksComponentsToTenBits(t *testing.T) {
g, err := NewCPUGenerator(6, 1, func(_, _, _, _, _ int) YCbCr10 {
return YCbCr10{Y: 0xC01, Cb: 0xC02, Cr: 0xC03}
})
if err != nil {
t.Fatalf("NewCPUGenerator: %v", err)
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)
if err := g.GenerateFrame(frame, 0); err != nil {
t.Fatalf("GenerateFrame: %v", err)
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 TestCPUGeneratorRejectsSmallDestination(t *testing.T) {
g, err := NewCPUGenerator(6, 1, func(_, _, _, _, _ int) YCbCr10 { return YCbCr10{} })
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, 15), 0); err == nil || !strings.Contains(err.Error(), "too small") {
t.Fatalf("error = %v, want destination size error", err)
t.Fatalf("small destination error = %v", err)
}
if err := g.GenerateFrame(make([]byte, 16), 4); !errors.Is(err, wantErr) {
t.Fatalf("patch error = %v, want wrapped %v", err, wantErr)
}
}
+61
View File
@@ -0,0 +1,61 @@
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)
}
return selectFrameGenerator(cfg.Backend, newGPU, newCPU)
}
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)
}
}
+112
View File
@@ -0,0 +1,112 @@
package video
import (
"errors"
"strings"
"testing"
"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)
}
}
})
}
}
+3 -3
View File
@@ -19,11 +19,11 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
}
// TODO: fall back to a CPU generator if GPU initialization fails.
gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelSource)
gen, backend, err := newFrameGenerator(cfg, pattern)
if err != nil {
return fmt.Errorf("initialize wgpu video generator: %w", err)
return err
}
log.Printf("video generator backend: %s", backend)
defer func() {
if err := gen.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err))