Compare commits
3 Commits
4d8035a434
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e804897c6 | |||
| dea2e6a59f | |||
| 9b194b5591 |
+14
-2
@@ -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,
|
||||
|
||||
@@ -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
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
Name = "MXL pattern generator"
|
||||
Version = "0.1.0"
|
||||
Version = "0.2.0"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user