video config construction refactoring
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Package flowdef models and validates MXL flow-definition JSON.
|
||||
package flowdef
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatVideo = "urn:x-nmos:format:video"
|
||||
FormatAudio = "urn:x-nmos:format:audio"
|
||||
MediaTypeV210 = "video/v210"
|
||||
|
||||
InterlaceProgressive = "progressive"
|
||||
ColorSpaceBT709 = "BT709"
|
||||
)
|
||||
|
||||
type Common struct {
|
||||
Description string `json:"description"`
|
||||
ID string `json:"id"`
|
||||
Tags map[string][]string `json:"tags"`
|
||||
Format string `json:"format"`
|
||||
Label string `json:"label"`
|
||||
Parents []string `json:"parents"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
type Rational struct {
|
||||
Numerator uint `json:"numerator"`
|
||||
Denominator uint `json:"denominator"`
|
||||
}
|
||||
|
||||
type Video struct {
|
||||
Common
|
||||
|
||||
GrainRate Rational `json:"grain_rate"`
|
||||
FrameWidth uint `json:"frame_width"`
|
||||
FrameHeight uint `json:"frame_height"`
|
||||
InterlaceMode string `json:"interlace_mode"`
|
||||
ColorSpace string `json:"colorspace"`
|
||||
Components []VideoComponent `json:"components"`
|
||||
}
|
||||
|
||||
type Audio struct {
|
||||
Common
|
||||
|
||||
SampleRate Rational `json:"sample_rate"`
|
||||
ChannelCount uint `json:"channel_count"`
|
||||
BitDepth uint `json:"bit_depth"`
|
||||
}
|
||||
|
||||
type VideoComponent struct {
|
||||
Name string `json:"name"`
|
||||
Width uint `json:"width"`
|
||||
Height uint `json:"height"`
|
||||
BitDepth uint `json:"bit_depth"`
|
||||
}
|
||||
|
||||
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||
definition := Video{
|
||||
Common: Common{
|
||||
Description: "go-mxl-pattern-gen generated video",
|
||||
ID: id,
|
||||
Tags: map[string][]string{
|
||||
"urn:x-nmos:tag:grouphint/v1.0": {"go-mxl-pattern-gen:Video"},
|
||||
},
|
||||
Format: FormatVideo,
|
||||
Label: "go-mxl-pattern-gen generated video",
|
||||
Parents: []string{},
|
||||
MediaType: MediaTypeV210,
|
||||
},
|
||||
GrainRate: rate,
|
||||
FrameWidth: width,
|
||||
FrameHeight: height,
|
||||
InterlaceMode: InterlaceProgressive,
|
||||
ColorSpace: ColorSpaceBT709,
|
||||
Components: []VideoComponent{
|
||||
{Name: "Y", Width: width, Height: height, BitDepth: 10},
|
||||
{Name: "Cb", Width: width / 2, Height: height, BitDepth: 10},
|
||||
{Name: "Cr", Width: width / 2, Height: height, BitDepth: 10},
|
||||
},
|
||||
}
|
||||
if err := definition.Validate(); err != nil {
|
||||
return Video{}, err
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func ParseV210Video(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)
|
||||
}
|
||||
if err := definition.Validate(); err != nil {
|
||||
return Video{}, fmt.Errorf("invalid video flow definition: %w", err)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (v Video) Validate() error {
|
||||
if err := uuid.Validate(v.ID); err != nil {
|
||||
return fmt.Errorf("invalid id %q: %w", v.ID, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if v.InterlaceMode != InterlaceProgressive {
|
||||
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
|
||||
}
|
||||
if v.ColorSpace != ColorSpaceBT709 {
|
||||
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
|
||||
}
|
||||
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 {
|
||||
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth)
|
||||
}
|
||||
if v.FrameHeight == 0 {
|
||||
return fmt.Errorf("frame_height must be greater than zero")
|
||||
}
|
||||
if v.GrainRate.Numerator == 0 || v.GrainRate.Denominator == 0 {
|
||||
return fmt.Errorf("grain_rate numerator and denominator must be greater than zero, got %d/%d",
|
||||
v.GrainRate.Numerator, v.GrainRate.Denominator)
|
||||
}
|
||||
|
||||
want := []VideoComponent{
|
||||
{Name: "Y", Width: v.FrameWidth, Height: v.FrameHeight, BitDepth: 10},
|
||||
{Name: "Cb", Width: v.FrameWidth / 2, Height: v.FrameHeight, BitDepth: 10},
|
||||
{Name: "Cr", Width: v.FrameWidth / 2, Height: v.FrameHeight, BitDepth: 10},
|
||||
}
|
||||
if len(v.Components) != len(want) {
|
||||
return fmt.Errorf("v210 requires %d components, got %d", len(want), len(v.Components))
|
||||
}
|
||||
for i := range want {
|
||||
if v.Components[i] != want[i] {
|
||||
return fmt.Errorf("component %d must be %+v, got %+v", i, want[i], v.Components[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package flowdef
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
||||
|
||||
func TestNewV210Video(t *testing.T) {
|
||||
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 30000, Denominator: 1001})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210Video: %v", err)
|
||||
}
|
||||
if definition.ID != testVideoID {
|
||||
t.Fatalf("ID = %q, want %q", definition.ID, testVideoID)
|
||||
}
|
||||
if definition.Format != FormatVideo || definition.MediaType != MediaTypeV210 {
|
||||
t.Fatalf("format/media type = %q/%q", definition.Format, definition.MediaType)
|
||||
}
|
||||
if len(definition.Components) != 3 {
|
||||
t.Fatalf("component count = %d, want 3", len(definition.Components))
|
||||
}
|
||||
if definition.Parents == nil {
|
||||
t.Fatal("Parents is nil; want an empty JSON array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewV210VideoRejectsInvalidWidth(t *testing.T) {
|
||||
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "divisible by 6") {
|
||||
t.Fatalf("error = %v, want width divisibility error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseV210Video(t *testing.T) {
|
||||
want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210Video: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
got, err := ParseV210Video(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseV210Video: %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) {
|
||||
data := []byte(`{
|
||||
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||
"format":"urn:x-nmos:format:audio",
|
||||
"media_type":"audio/float32"
|
||||
}`)
|
||||
|
||||
_, err := ParseV210Video(data)
|
||||
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||
t.Fatalf("error = %v, want video format error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user