video config construction refactoring
This commit is contained in:
+8
-11
@@ -69,23 +69,20 @@ For `audio/float32`, the audio package may use `[]float32` internally and keep b
|
|||||||
| # | Issue | Status | Current observation |
|
| # | Issue | Status | Current observation |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | The validated `--domain` value was ignored in favor of `/dev/shm/mxl`. | **Fixed** | Logging, `mxl.NewInstance`, and reuse messages now use `args.domain`. |
|
| 1 | The validated `--domain` value was ignored in favor of `/dev/shm/mxl`. | **Fixed** | Logging, `mxl.NewInstance`, and reuse messages now use `args.domain`. |
|
||||||
| 2 | An external video flow definition does not populate runtime width, height, FPS, or UUID. | **Open** | The JSON is read as an opaque string while `vi` remains zero-valued. `NewWGPUGenerator` therefore receives `0, 0`. Parse and validate the definition before generator creation, or introduce a typed configuration source. |
|
| 2 | An external video flow definition does not populate runtime width, height, FPS, or UUID. | **Fixed** | External definitions are parsed and validated as `flowdef.Video`; `video.Config` supplies their dimensions, rate, and ID to the generator and writer. |
|
||||||
| 3 | `checkArgs` received `appArgs` by value, so generated UUIDs were discarded. | **Partially fixed** | `checkArgs` now receives `*appArgs`. However, the generated video UUID is still overwritten by a hard-coded UUID in `main`; remove that assignment before release. |
|
| 3 | `checkArgs` received `appArgs` by value, so generated UUIDs were discarded. | **Fixed** | Argument validation mutates the actual configuration, and the hard-coded video UUID has been removed. |
|
||||||
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
|
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
|
||||||
| 5 | `NewFlowDefJSON(TYPE_AUDIO, ...)` produces a video/v210 definition. | **Open** | The function accepts `TYPE_AUDIO`, but all emitted format and media fields remain video-specific. Create separate typed video and audio definition builders. |
|
| 5 | `NewFlowDefJSON(TYPE_AUDIO, ...)` produces a video/v210 definition. | **Fixed** | The generic discriminator-based builder was removed. Video and audio have separate schema types, and video uses the typed `NewV210Video` constructor. An audio constructor will be added with audio generation. |
|
||||||
| 6 | The wgpu path was described as zero-copy although it performs GPU readback and a CPU copy. | **Deferred — fix after audio** | Every frame is copied from GPU storage to a mapped host buffer and then copied into the MXL payload. The path is synchronous and serial. This may require substantial benchmarking and architectural work, so audio implementation takes priority. Update the documentation now, but defer optimization or redesign until audio is complete. |
|
| 6 | The wgpu path was described as zero-copy although it performs GPU readback and a CPU copy. | **Deferred — fix after audio** | Every frame is copied from GPU storage to a mapped host buffer and then copied into the MXL payload. The path is synchronous and serial. This may require substantial benchmarking and architectural work, so audio implementation takes priority. Update the documentation now, but defer optimization or redesign until audio is complete. |
|
||||||
| 7 | The test suite had three failures. | **Open** | `TestNewTextOverlay`, `TestWGPUMoveSquare`, and `TestWGPUGenerator` still fail. |
|
| 7 | The test suite had three failures. | **Open** | `TestNewTextOverlay`, `TestWGPUMoveSquare`, and `TestWGPUGenerator` still fail. |
|
||||||
| 8 | The Makefile clean target uses `fm -f` instead of `rm -f`. | **Open** | The typo remains in the `clean` target. |
|
| 8 | The Makefile clean target uses `fm -f` instead of `rm -f`. | **Fixed** | The clean target now uses `rm -f`. |
|
||||||
|
|
||||||
## Additional implementation priorities
|
## Additional implementation priorities
|
||||||
|
|
||||||
1. Fix the existing tests or update incorrect expectations after confirming the intended color values and overlay positioning.
|
1. Fix the existing tests or update incorrect expectations after confirming the intended color values and overlay positioning.
|
||||||
2. Split video and audio flow-definition types. Avoid an integer `feedType` accepted by a function that has a video-only parameter list.
|
2. Introduce a `run(...) error` orchestration function using shared cancellation and error propagation instead of calling `log.Fatalf` throughout the media loop.
|
||||||
3. Parse external flow definitions into typed configuration before creating generators. Validate format, media type, dimensions, rate, and channel count.
|
3. Add a typed audio flow-definition constructor, CPU audio generator, and continuous-flow writer loop using `OpenSamples`, `ChannelFragments`, and `Commit`.
|
||||||
4. Remove the hard-coded video UUID.
|
4. After audio is complete, measure end-to-end frame time and missed deadlines at 1080p50/60 and UHD. The current wgpu path may be adequate, but it is neither zero-copy nor asynchronous. Treat GPU readback optimization as a separate, potentially large task.
|
||||||
5. Introduce a `run(...) error` orchestration function using shared cancellation and error propagation instead of calling `log.Fatalf` throughout the media loop.
|
|
||||||
6. Add a CPU audio generator and continuous-flow writer loop using `OpenSamples`, `ChannelFragments`, and `Commit`.
|
|
||||||
7. After audio is complete, measure end-to-end frame time and missed deadlines at 1080p50/60 and UHD. The current wgpu path may be adequate, but it is neither zero-copy nor asynchronous. Treat GPU readback optimization as a separate, potentially large task.
|
|
||||||
|
|
||||||
## Suggested package layout
|
## Suggested package layout
|
||||||
|
|
||||||
@@ -130,4 +127,4 @@ Package compilation succeeds, but the generator package fails these tests:
|
|||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Keep the chosen Go + go-mxl + wgpu stack. Implement audio on the CPU in its own goroutine and give video and audio separate writers, indices, and pacing loops. Coordinate them through a shared context and the common MXL timebase, not through per-frame messages. The immediate blockers are external-flow configuration, audio flow-definition support, and the currently failing tests.
|
Keep the chosen Go + go-mxl + wgpu stack. Implement audio on the CPU in its own goroutine and give video and audio separate writers, indices, and pacing loops. Coordinate them through a shared context and the common MXL timebase, not through per-frame messages. Typed external-video configuration is now implemented; the next architectural work is extracting the video runner and adding typed audio construction and generation. The three rendering-test failures remain a separate correctness task.
|
||||||
|
|||||||
+94
-68
@@ -4,7 +4,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -18,8 +18,9 @@ import (
|
|||||||
"github.com/qvest-digital/go-mxl/mxl"
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
flowdef "mxl-pattern-generator/internal/flow-def"
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
"mxl-pattern-generator/internal/generator"
|
"mxl-pattern-generator/internal/generator"
|
||||||
|
"mxl-pattern-generator/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -128,6 +129,9 @@ func validateFlowDefPath(label, path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateVideoArgs(args *appArgs) error {
|
func validateVideoArgs(args *appArgs) error {
|
||||||
|
if _, exists := patterns[args.pattern]; !exists {
|
||||||
|
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
|
||||||
|
}
|
||||||
if args.videoFlowDefFile != "" {
|
if args.videoFlowDefFile != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -142,9 +146,6 @@ func validateVideoArgs(args *appArgs) error {
|
|||||||
return fmt.Errorf("unsupported video FPS %q (supported: %s); use a flow definition for other rates",
|
return fmt.Errorf("unsupported video FPS %q (supported: %s); use a flow definition for other rates",
|
||||||
args.videoFPS, sortedMapKeys(frameRates))
|
args.videoFPS, sortedMapKeys(frameRates))
|
||||||
}
|
}
|
||||||
if _, exists := patterns[args.pattern]; !exists {
|
|
||||||
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
|
|
||||||
}
|
|
||||||
if args.videoUUID == "" {
|
if args.videoUUID == "" {
|
||||||
args.videoUUID = uuid.NewString()
|
args.videoUUID = uuid.NewString()
|
||||||
return nil
|
return nil
|
||||||
@@ -356,6 +357,59 @@ func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
|
|||||||
return parseResult{args: args, shouldRun: true}, nil
|
return parseResult{args: args, shouldRun: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildVideoConfig(args appArgs) (video.Config, error) {
|
||||||
|
var definition flowdef.Video
|
||||||
|
if args.videoFlowDefFile != "" {
|
||||||
|
data, err := os.ReadFile(args.videoFlowDefFile)
|
||||||
|
if err != nil {
|
||||||
|
return video.Config{}, fmt.Errorf(
|
||||||
|
"read video flow definition %q: %w",
|
||||||
|
args.videoFlowDefFile,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition, err = flowdef.ParseV210Video(data)
|
||||||
|
if err != nil {
|
||||||
|
return video.Config{}, fmt.Errorf(
|
||||||
|
"parse video flow definition %q: %w",
|
||||||
|
args.videoFlowDefFile,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rate, ok := frameRates[args.videoFPS]
|
||||||
|
if !ok {
|
||||||
|
return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
definition, err = flowdef.NewV210Video(
|
||||||
|
args.videoUUID,
|
||||||
|
args.videoWidth,
|
||||||
|
args.videoHeight,
|
||||||
|
flowdef.Rational{
|
||||||
|
Numerator: uint(rate.Num),
|
||||||
|
Denominator: uint(rate.Den),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return video.Config{}, fmt.Errorf(
|
||||||
|
"build video flow definition: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := patterns[args.pattern]; !ok {
|
||||||
|
return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
return video.Config{
|
||||||
|
Definition: definition,
|
||||||
|
Pattern: args.pattern,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
|
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,50 +422,28 @@ func main() {
|
|||||||
}
|
}
|
||||||
args := parsed.args
|
args := parsed.args
|
||||||
|
|
||||||
type videoInfo struct {
|
videoCfg, err := buildVideoConfig(args)
|
||||||
uuid string
|
if err != nil {
|
||||||
width uint
|
log.Fatalf("video configuration: %v", err)
|
||||||
height uint
|
|
||||||
fps mxl.Rational
|
|
||||||
}
|
|
||||||
var vi videoInfo
|
|
||||||
var videoFlowDef string
|
|
||||||
if args.videoFlowDefFile == "" {
|
|
||||||
args.videoUUID = "8f1d2a4b-6c3e-4f5a-9b2c-1d7e8a3f0b5d" // TODO: remove before public release
|
|
||||||
vi = videoInfo{
|
|
||||||
uuid: args.videoUUID,
|
|
||||||
width: args.videoWidth,
|
|
||||||
height: args.videoHeight,
|
|
||||||
fps: frameRates[args.videoFPS],
|
|
||||||
}
|
|
||||||
flowDef, err := flowdef.NewFlowDefJSON(
|
|
||||||
flowdef.TYPE_VIDEO,
|
|
||||||
vi.uuid,
|
|
||||||
vi.width,
|
|
||||||
vi.height,
|
|
||||||
uint(vi.fps.Num),
|
|
||||||
uint(vi.fps.Den),
|
|
||||||
)
|
|
||||||
videoFlowDef = flowDef
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not create Flow Definition: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
flowDef, err := flowdef.ReadFlowDefFile(args.videoFlowDefFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Could not read video flow def .json: %s. Reason: %v", args.videoFlowDefFile, err)
|
|
||||||
}
|
|
||||||
videoFlowDef = flowDef
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("%s %s", APP_NAME, APP_VER)
|
log.Printf("%s %s", APP_NAME, APP_VER)
|
||||||
log.Printf("Domain: %s", args.domain)
|
log.Printf("Domain: %s", args.domain)
|
||||||
log.Printf("Video: %dx%d %d/%d", vi.width, vi.height, vi.fps.Num, vi.fps.Den)
|
log.Printf("Video: %dx%d %d/%d",
|
||||||
log.Printf("Video UUID: %s", vi.uuid)
|
videoCfg.Width(),
|
||||||
|
videoCfg.Height(),
|
||||||
|
videoCfg.Rate().Num,
|
||||||
|
videoCfg.Rate().Den,
|
||||||
|
)
|
||||||
|
log.Printf("Video ID: %s", videoCfg.ID())
|
||||||
|
|
||||||
// TODO: if init failed -> CPU generator
|
// TODO: if init failed -> CPU generator
|
||||||
videoPattern := patterns[args.pattern]
|
videoPattern := patterns[videoCfg.Pattern]
|
||||||
gen, err := generator.NewWGPUGenerator(vi.width, vi.height, videoPattern.kernelPath)
|
gen, err := generator.NewWGPUGenerator(
|
||||||
|
videoCfg.Width(),
|
||||||
|
videoCfg.Height(),
|
||||||
|
videoPattern.kernelPath,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("wgpu init failed: %v", err)
|
log.Fatalf("wgpu init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -427,15 +459,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer face.Close()
|
defer face.Close()
|
||||||
if args.overlayX < 0 ||
|
if args.overlayX < 0 ||
|
||||||
args.overlayX > int(vi.width) ||
|
args.overlayX > int(videoCfg.Width()) ||
|
||||||
args.overlayY < 0 ||
|
args.overlayY < 0 ||
|
||||||
args.overlayY > int(vi.height) {
|
args.overlayY > int(videoCfg.Height()) {
|
||||||
log.Fatalf("overlay X/Y pos can't be negative or greater, than video width/height")
|
log.Fatalf("text overlay position (%d, %d) is outside the %dx%d video frame",
|
||||||
|
args.overlayX, args.overlayY, videoCfg.Width(), videoCfg.Height())
|
||||||
}
|
}
|
||||||
overlay, err = generator.NewTextOverlay(
|
overlay, err = generator.NewTextOverlay(
|
||||||
args.textOverlay,
|
args.textOverlay,
|
||||||
int(vi.width),
|
int(videoCfg.Width()),
|
||||||
int(vi.height),
|
int(videoCfg.Height()),
|
||||||
args.overlayX,
|
args.overlayX,
|
||||||
args.overlayY,
|
args.overlayY,
|
||||||
args.overlayPos,
|
args.overlayPos,
|
||||||
@@ -448,16 +481,21 @@ func main() {
|
|||||||
|
|
||||||
inst, err := mxl.NewInstance(args.domain, "")
|
inst, err := mxl.NewInstance(args.domain, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("MXL Init Failed: %v", err)
|
log.Fatalf("initialize MXL domain %q: %v", args.domain, err)
|
||||||
}
|
}
|
||||||
defer inst.Close()
|
defer inst.Close()
|
||||||
|
|
||||||
writer, isCreated, err := inst.NewWriter(videoFlowDef)
|
flowJSON, err := json.Marshal(videoCfg.Definition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create MXL writer: %v", err)
|
log.Fatalf("marshal video flow definition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writer, isCreated, err := inst.NewWriter(string(flowJSON))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create video writer: %v", err)
|
||||||
}
|
}
|
||||||
if !isCreated {
|
if !isCreated {
|
||||||
log.Printf("reusing existing flow: %s, domain: %s", vi.uuid, args.domain)
|
log.Printf("reusing existing flow: %s, domain: %s", videoCfg.ID(), args.domain)
|
||||||
}
|
}
|
||||||
defer writer.Close()
|
defer writer.Close()
|
||||||
|
|
||||||
@@ -484,18 +522,18 @@ func main() {
|
|||||||
|
|
||||||
gwa, err := writer.OpenGrain(idx)
|
gwa, err := writer.OpenGrain(idx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("OpenGrain(%d): %v", idx, err)
|
log.Fatalf("open video grain %d: %v", idx, err)
|
||||||
}
|
}
|
||||||
if err := gen.GenerateFrame(gwa.Payload, int(tick)); err != nil {
|
if err := gen.GenerateFrame(gwa.Payload, int(tick)); err != nil {
|
||||||
log.Fatalf("GenerateFrame(%d): %v", idx, err)
|
log.Fatalf("generate frame for grain %d: %v", idx, err)
|
||||||
}
|
}
|
||||||
if overlay != nil {
|
if overlay != nil {
|
||||||
if err := overlay.ApplyV210(gwa.Payload); err != nil {
|
if err := overlay.ApplyV210(gwa.Payload); err != nil {
|
||||||
log.Fatalf("text overlay: %v", err)
|
log.Fatalf("apply text overlay to grain %d: %v", idx, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := gwa.Commit(gwa.TotalSlices, 0); err != nil {
|
if err := gwa.Commit(gwa.TotalSlices, 0); err != nil {
|
||||||
log.Fatalf("Commit(%d): %v", idx, err)
|
log.Fatalf("commit video grain %d: %v", idx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
grainsWritten++
|
grainsWritten++
|
||||||
@@ -508,15 +546,3 @@ func main() {
|
|||||||
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
|
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx context.Context, args []string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runVideo(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runAudio(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
|
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
|
||||||
@@ -99,3 +103,62 @@ func TestListPatternsIsSorted(t *testing.T) {
|
|||||||
t.Fatalf("patterns are not sorted: %q", text)
|
t.Fatalf("patterns are not sorted: %q", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildVideoConfigFromArgs(t *testing.T) {
|
||||||
|
args := appArgs{
|
||||||
|
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||||
|
videoWidth: 1920,
|
||||||
|
videoHeight: 1080,
|
||||||
|
videoFPS: "29.97",
|
||||||
|
pattern: "ebu75",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildVideoConfig(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVideoConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width() != 1920 || cfg.Height() != 1080 {
|
||||||
|
t.Fatalf("dimensions = %dx%d, want 1920x1080", cfg.Width(), cfg.Height())
|
||||||
|
}
|
||||||
|
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
|
||||||
|
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVideoConfigFromFile(t *testing.T) {
|
||||||
|
definition, err := flowdef.NewV210Video(
|
||||||
|
"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||||
|
3840,
|
||||||
|
2160,
|
||||||
|
flowdef.Rational{Numerator: 60000, Denominator: 1001},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewV210Video: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := t.TempDir() + "/video.json"
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildVideoConfig(appArgs{
|
||||||
|
videoFlowDefFile: path,
|
||||||
|
pattern: "smpte",
|
||||||
|
// These values must be ignored when a definition file is supplied.
|
||||||
|
videoWidth: 1920,
|
||||||
|
videoHeight: 1080,
|
||||||
|
videoFPS: "25",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVideoConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width() != 3840 || cfg.Height() != 2160 {
|
||||||
|
t.Fatalf("dimensions = %dx%d, want file values 3840x2160", cfg.Width(), cfg.Height())
|
||||||
|
}
|
||||||
|
if cfg.Rate().Num != 60000 || cfg.Rate().Den != 1001 {
|
||||||
|
t.Fatalf("rate = %d/%d, want file value 60000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
// MXL Flow Definition helper
|
|
||||||
package flowdef
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TYPE_VIDEO = iota
|
|
||||||
TYPE_VIDEO_ALPHA
|
|
||||||
TYPE_AUDIO
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUnknownFlowType = errors.New("Unknown flow type provided")
|
|
||||||
ErrIncorrectFlowUUID = errors.New("Incorrect flow uuid")
|
|
||||||
)
|
|
||||||
|
|
||||||
type flowDef 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"`
|
|
||||||
GrainRate grainRate `json:"grain_rate"`
|
|
||||||
FrameWidth uint `json:"frame_width"`
|
|
||||||
FrameHeight uint `json:"frame_height"`
|
|
||||||
InterlaceMode string `json:"interlace_mode"`
|
|
||||||
ColorSpace string `json:"colorspace"`
|
|
||||||
Components [3]videoComponent `json:"components"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type grainRate struct {
|
|
||||||
Numerator uint `json:"numerator"`
|
|
||||||
Denominator uint `json:"denominator"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type videoComponent struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Width uint `json:"width"`
|
|
||||||
Height uint `json:"height"`
|
|
||||||
BitDepth uint `json:"bit_depth"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFlowDefJSON(
|
|
||||||
feedType int,
|
|
||||||
uuid string,
|
|
||||||
width uint,
|
|
||||||
height uint,
|
|
||||||
fpsNum uint,
|
|
||||||
fpsDen uint,
|
|
||||||
) (string, error) {
|
|
||||||
if feedType != TYPE_VIDEO &&
|
|
||||||
feedType != TYPE_VIDEO_ALPHA &&
|
|
||||||
feedType != TYPE_AUDIO {
|
|
||||||
return "", ErrUnknownFlowType
|
|
||||||
}
|
|
||||||
if uuid == "" {
|
|
||||||
return "", ErrIncorrectFlowUUID
|
|
||||||
}
|
|
||||||
tags := map[string][]string{
|
|
||||||
"urn:x-nmos:tag:grouphint/v1.0": {
|
|
||||||
"___one day I will follow NMOS specs___:Video",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
flowDef := flowDef{
|
|
||||||
Description: "go-mxl-pattern-gen generated feed",
|
|
||||||
Id: uuid,
|
|
||||||
Tags: tags,
|
|
||||||
Format: "urn:x-nmos:format:video",
|
|
||||||
Label: "go-mxl-pattern-gen generated feed",
|
|
||||||
Parents: nil,
|
|
||||||
MediaType: "video/v210",
|
|
||||||
GrainRate: grainRate{
|
|
||||||
Numerator: fpsNum,
|
|
||||||
Denominator: fpsDen,
|
|
||||||
},
|
|
||||||
FrameWidth: width,
|
|
||||||
FrameHeight: height,
|
|
||||||
InterlaceMode: "progressive",
|
|
||||||
ColorSpace: "BT709",
|
|
||||||
Components: [3]videoComponent{
|
|
||||||
videoComponent{
|
|
||||||
Name: "Y",
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
BitDepth: 10,
|
|
||||||
},
|
|
||||||
videoComponent{
|
|
||||||
Name: "Cb",
|
|
||||||
Width: width / 2,
|
|
||||||
Height: height,
|
|
||||||
BitDepth: 10,
|
|
||||||
},
|
|
||||||
videoComponent{
|
|
||||||
Name: "Cr",
|
|
||||||
Width: width / 2,
|
|
||||||
Height: height,
|
|
||||||
BitDepth: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(flowDef)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadFlowDefFile(path string) (string, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Definition flowdef.Video
|
||||||
|
Pattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) ID() string {
|
||||||
|
return c.Definition.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Width() uint {
|
||||||
|
return c.Definition.FrameWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Height() uint {
|
||||||
|
return c.Definition.FrameHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Rate() mxl.Rational {
|
||||||
|
return mxl.Rational{
|
||||||
|
Num: int64(c.Definition.GrainRate.Numerator),
|
||||||
|
Den: int64(c.Definition.GrainRate.Denominator),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user