diff --git a/internal/generator/cpu.go b/internal/generator/cpu.go index 30973ed..a85222b 100644 --- a/internal/generator/cpu.go +++ b/internal/generator/cpu.go @@ -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 + width int + height int + 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{ - width: int(width), - height: int(height), - sampler: sampler, - }, nil + g := &CPUGenerator{ + width: int(width), + height: int(height), + 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) +} diff --git a/internal/generator/cpu_moving_square.go b/internal/generator/cpu_moving_square.go new file mode 100644 index 0000000..84c39b1 --- /dev/null +++ b/internal/generator/cpu_moving_square.go @@ -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, + } +} diff --git a/internal/generator/cpu_patterns.go b/internal/generator/cpu_patterns.go new file mode 100644 index 0000000..8b45f88 --- /dev/null +++ b/internal/generator/cpu_patterns.go @@ -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 +} diff --git a/internal/generator/cpu_patterns_test.go b/internal/generator/cpu_patterns_test.go new file mode 100644 index 0000000..1861889 --- /dev/null +++ b/internal/generator/cpu_patterns_test.go @@ -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) + } +} diff --git a/internal/generator/cpu_test.go b/internal/generator/cpu_test.go index c17722e..3f27962 100644 --- a/internal/generator/cpu_test.go +++ b/internal/generator/cpu_test.go @@ -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 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 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 TestPackV210BlockMasksComponents(t *testing.T) { + pixel := YCbCr10{Y: 0xC01, Cb: 0xC02, Cr: 0xC03} frame := make([]byte, 16) - if err := g.GenerateFrame(frame, 0); err != nil { - t.Fatalf("GenerateFrame: %v", err) - } + 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) } } diff --git a/internal/video/generator.go b/internal/video/generator.go new file mode 100644 index 0000000..f950cbf --- /dev/null +++ b/internal/video/generator.go @@ -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) + } +} diff --git a/internal/video/generator_test.go b/internal/video/generator_test.go new file mode 100644 index 0000000..cc1ffb9 --- /dev/null +++ b/internal/video/generator_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/video/runner.go b/internal/video/runner.go index 9355257..e5b77fa 100644 --- a/internal/video/runner.go +++ b/internal/video/runner.go @@ -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))