From efa958723af45a07fc9998341f78c6a3b9a044b4 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Fri, 18 Sep 2026 01:02:56 +0300 Subject: [PATCH] RP219 CPU pattern --- internal/generator/cpu_patterns.go | 127 +++++++++++++++++++++++- internal/generator/cpu_patterns_test.go | 79 ++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/internal/generator/cpu_patterns.go b/internal/generator/cpu_patterns.go index 8b45f88..25a6ccd 100644 --- a/internal/generator/cpu_patterns.go +++ b/internal/generator/cpu_patterns.go @@ -1,6 +1,8 @@ package generator -import "fmt" +import ( + "fmt" +) var ebu75Colors = [...]YCbCr10{ {Y: 721, Cb: 512, Cr: 512}, @@ -24,6 +26,16 @@ var ebu100Colors = [...]YCbCr10{ {Y: 64, Cb: 512, Cr: 512}, } +var smpte75Colors = [...]YCbCr10{ + {Y: 721, Cb: 512, Cr: 512}, + {Y: 674, Cb: 176, Cr: 543}, + {Y: 581, Cb: 589, Cr: 176}, + {Y: 534, Cb: 253, Cr: 207}, + {Y: 251, Cb: 771, Cr: 817}, + {Y: 204, Cb: 435, Cr: 848}, + {Y: 111, Cb: 848, Cr: 481}, +} + var grayBarsColors = [...]YCbCr10{ {Y: 64, Cb: 512, Cr: 512}, {Y: 137, Cb: 512, Cr: 512}, @@ -58,6 +70,16 @@ func NewCPUPatternGenerator(width, height uint, pattern string) (*CPUGenerator, baseColor = ebu100BaseColor case "ebu100-move": baseColor, dynamic = ebu100BaseColor, true + case "smpte": + if err := validateRP219Size(width, height); err != nil { + return nil, err + } + baseColor = rp219BaseColor + case "smpte-move": + if err := validateRP219Size(width, height); err != nil { + return nil, err + } + baseColor, dynamic = rp219BaseColor, true case "gray-bars": baseColor = grayBarsBaseColor case "gray-bars-move": @@ -117,3 +139,106 @@ func renderBasePattern(dst []byte, width, height int, baseColor baseColorFunc) e } return nil } + +var ( + gray40 = YCbCr10{Y: 414, Cb: 512, Cr: 512} + gray15 = YCbCr10{Y: 195, Cb: 512, Cr: 512} + black = YCbCr10{Y: 64, Cb: 512, Cr: 512} + white = YCbCr10{Y: 940, Cb: 512, Cr: 512} +) + +func rp219BaseColor(x, y, width, height int) YCbCr10 { + barsWidth := (height / 3) * 4 + barsStart := (width - barsWidth) / 2 + barsEnd := barsStart + barsWidth + oneBarWidth := barsWidth / 7 + + unitH := height / 12 + section1End := unitH * 7 + section2End := section1End + unitH + section3End := section2End + unitH + + rampStart := barsStart + oneBarWidth + rampEnd := barsEnd + rampWidth := rampEnd - rampStart + + switch { + case y < section1End: + if x < barsStart || x >= barsEnd { + return gray40 + } + + bar := min( + (x-barsStart)*len(smpte75Colors)/barsWidth, + len(smpte75Colors)-1, + ) + return smpte75Colors[bar] + + case y < section2End: + switch { + case x < barsStart: + return YCbCr10{Y: 754, Cb: 615, Cr: 64} // 100% cyan + case x < rampStart: + return YCbCr10{Y: 244, Cb: 612, Cr: 395} // -I + case x >= barsEnd: + return YCbCr10{Y: 127, Cb: 960, Cr: 471} // 100% blue + default: + return YCbCr10{Y: 721, Cb: 512, Cr: 512} // 75% white + } + + case y < section3End: + switch { + case x < barsStart: + return YCbCr10{Y: 877, Cb: 64, Cr: 553} // 100% yellow + case x < rampStart: + return YCbCr10{Y: 141, Cb: 697, Cr: 606} // +Q + case x >= barsEnd: + return YCbCr10{Y: 250, Cb: 409, Cr: 960} // 75% red + default: + rampY := 64 + ((x-rampStart)*876)/rampWidth + return YCbCr10{Y: uint32(rampY), Cb: 512, Cr: 512} + } + + default: + if x < barsStart || x >= barsEnd { + return gray15 + } + + bar := (x - barsStart) / oneBarWidth + switch { + case bar == 1: + return white + case bar == 3: + offset := (x - barsStart) % oneBarWidth + subBar := offset * 3 / oneBarWidth + + switch subBar { + case 0: + return YCbCr10{Y: 46, Cb: 512, Cr: 512} + case 1: + return black + default: + return YCbCr10{Y: 82, Cb: 512, Cr: 512} + } + + default: + return black + } + } +} + +func validateRP219Size(width, height uint) error { + if height < 12 { + return fmt.Errorf("cpu RP 219 requires frame height of at least 12, got %d", height) + } + barsWidth := (height / 3) * 4 + if barsWidth < 7 || width < barsWidth { + return fmt.Errorf( + "cpu RP 219 requires frame width %d to fit a 4:3 pattern area for height %d, got %d", + barsWidth, + height, + width, + ) + } + return nil +} diff --git a/internal/generator/cpu_patterns_test.go b/internal/generator/cpu_patterns_test.go index 1861889..1de22c8 100644 --- a/internal/generator/cpu_patterns_test.go +++ b/internal/generator/cpu_patterns_test.go @@ -116,10 +116,81 @@ func TestCPUMovingPatternUsesOwnBaseColor(t *testing.T) { } } -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 TestCPURP219Pattern(t *testing.T) { + const width, height = 1920, 1080 + g, err := NewCPUPatternGenerator(width, height, "smpte") + if err != nil { + t.Fatalf("NewCPUPatternGenerator: %v", err) + } + frame := make([]byte, width*height*8/3) + if err := g.GenerateFrame(frame, 0); err != nil { + t.Fatalf("GenerateFrame: %v", err) + } + + tests := []struct { + name string + x, y int + wantY, wantCb, wantCr uint32 + }{ + {"top left gray flank", 100, 100, 414, 512, 512}, + {"top white bar", 300, 100, 721, 512, 512}, + {"top green bar", 960, 100, 534, 253, 207}, + {"top right gray flank", 1800, 100, 414, 512, 512}, + {"section 2 cyan flank", 100, 650, 754, 615, 64}, + {"section 2 minus I", 300, 650, 244, 612, 395}, + {"section 2 white", 600, 650, 721, 512, 512}, + {"section 2 blue flank", 1800, 650, 127, 960, 471}, + {"section 3 yellow flank", 100, 750, 877, 64, 553}, + {"section 3 plus Q", 300, 750, 141, 697, 606}, + {"section 3 red flank", 1800, 750, 250, 409, 960}, + {"bottom gray flank", 100, 900, 195, 512, 512}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + y, cb, cr := sampleV210(frame, width, tc.x, tc.y) + if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr { + t.Fatalf("pixel (%d,%d): got %d/%d/%d, want %d/%d/%d", + tc.x, tc.y, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr) + } + }) + } +} + +func TestCPURP219MovingSquare(t *testing.T) { + const width, height = 1920, 1080 + g, err := NewCPUPatternGenerator(width, height, "smpte-move") + if err != nil { + t.Fatalf("NewCPUPatternGenerator: %v", err) + } + frame := make([]byte, width*height*8/3) + if err := g.GenerateFrame(frame, 0); err != nil { + t.Fatalf("GenerateFrame(0): %v", err) + } + if y, cb, cr := sampleV210(frame, width, 960, 540); y != 470 || cb != 771 || cr != 817 { + t.Fatalf("tick 0 center = %d/%d/%d, want inverted green 470/771/817", y, cb, cr) + } + if err := g.GenerateFrame(frame, 79); err != nil { + t.Fatalf("GenerateFrame(79): %v", err) + } + if y, cb, cr := sampleV210(frame, width, 1840, 540); y != 590 || cb != 512 || cr != 512 { + t.Fatalf("tick 79 shifted square = %d/%d/%d, want inverted gray 590/512/512", y, cb, cr) + } +} + +func TestCPURP219RejectsInvalidGeometry(t *testing.T) { + for _, tc := range []struct { + name string + width, height uint + }{ + {name: "height too small", width: 1920, height: 11}, + {name: "canvas too narrow", width: 600, height: 1080}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := NewCPUPatternGenerator(tc.width, tc.height, "smpte") + if err == nil || !strings.Contains(err.Error(), "RP 219") { + t.Fatalf("error = %v, want RP 219 geometry error", err) + } + }) } }