Compare commits
28 Commits
f37f480925
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e804897c6 | |||
| dea2e6a59f | |||
| 9b194b5591 | |||
| 4d8035a434 | |||
| efa958723a | |||
| 723bef6342 | |||
| b85293b5d2 | |||
| fa787b5ef1 | |||
| f5df9506ce | |||
| 4d9485220a | |||
| e30cb7a168 | |||
| de4dfcf101 | |||
| d26d9442a0 | |||
| d3f0b533e3 | |||
| d341d22611 | |||
| 07769859f7 | |||
| eeb13c3bd4 | |||
| 6fa3dea391 | |||
| 128d6fe63e | |||
| 72decea866 | |||
| b5c3c82b1e | |||
| 70c966dbad | |||
| ce547451a0 | |||
| ce00305c25 | |||
| 1a16436688 | |||
| c9bdfec3b0 | |||
| 765aa8d3e1 | |||
| 326a8d6890 |
@@ -0,0 +1,124 @@
|
|||||||
|
# MXL Pattern Generator Architecture Review
|
||||||
|
|
||||||
|
Reviewed on 2026-09-16 against commit `f37f480`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The current stack is a good fit for a test-pattern generator:
|
||||||
|
|
||||||
|
- Go is well suited to CLI handling, MXL lifecycle management, timing, and synthetic audio generation.
|
||||||
|
- `go-mxl` provides the correct data model: discrete grains for video and continuous samples for audio.
|
||||||
|
- wgpu/WGSL is a reasonable portable GPU abstraction for producing v210 video.
|
||||||
|
- CPU-rendered text is appropriate because text changes infrequently and font rendering does not need to be implemented in a shader.
|
||||||
|
|
||||||
|
The project is still prototype-quality in parts of its orchestration and configuration layers, but flow-definition handling, lifecycle management, audio generation, and the previously failing rendering tests have now been addressed.
|
||||||
|
|
||||||
|
## Recommended audio architecture
|
||||||
|
|
||||||
|
Video and audio should run in separate goroutines. Each goroutine should own its own MXL writer and timing loop because video and audio have different units and rates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
run
|
||||||
|
|-- shared context, signal handling, and error propagation
|
||||||
|
|-- video goroutine
|
||||||
|
| OpenGrain -> render -> overlay -> Commit
|
||||||
|
`-- audio goroutine
|
||||||
|
OpenSamples -> synthesize -> Commit
|
||||||
|
```
|
||||||
|
|
||||||
|
This is also consistent with `go-mxl`: a writer is not safe for concurrent use, so a writer should belong to exactly one goroutine.
|
||||||
|
|
||||||
|
The goroutines should not synchronize by sending a notification for every video frame. That would make audio timing depend on GPU latency and Go scheduler jitter. Instead, both loops should use the MXL clock independently:
|
||||||
|
|
||||||
|
```go
|
||||||
|
videoIndex := mxl.CurrentIndex(videoRate)
|
||||||
|
audioIndex := mxl.CurrentIndex(audioRate)
|
||||||
|
```
|
||||||
|
|
||||||
|
After committing data, each loop advances its own index and uses `mxl.NsUntilIndex` for pacing. Both loops should share a `context.Context`; an error in either flow should cancel the other flow and be returned to the caller.
|
||||||
|
|
||||||
|
For audio, start with CPU generation and approximately 10 ms batches (480 samples at 48 kHz). A sine wave is inexpensive to calculate, so GPU audio generation would add complexity without a useful performance benefit.
|
||||||
|
|
||||||
|
Audio phase should be derived from the absolute sample index:
|
||||||
|
|
||||||
|
```go
|
||||||
|
phase := 2 * math.Pi * frequency *
|
||||||
|
float64(firstSample+i) / sampleRate
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves continuity across batches and after a timing resynchronization.
|
||||||
|
|
||||||
|
Video and audio should expose different interfaces because a video frame and a range of audio samples are fundamentally different units:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type VideoGenerator interface {
|
||||||
|
GenerateFrame(dst []byte, frameIndex uint64) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioGenerator interface {
|
||||||
|
GenerateSamples(channels [][]byte, firstSample, sampleCount uint64) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For `audio/float32`, the audio package may use `[]float32` internally and keep byte encoding at the MXL boundary.
|
||||||
|
|
||||||
|
## Issue status
|
||||||
|
|
||||||
|
| # | 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`. |
|
||||||
|
| 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. | **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. |
|
||||||
|
| 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 typed `NewV210Video` and `NewFloat32Audio` constructors. |
|
||||||
|
| 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. | **Fixed** | Text positioning tests now match the explicit-position API and reject invalid bounds/alignment. WGPU tests now verify the actual RP 219 geometry and dynamic overlay behavior. |
|
||||||
|
| 8 | The Makefile clean target uses `fm -f` instead of `rm -f`. | **Fixed** | The clean target now uses `rm -f`. |
|
||||||
|
|
||||||
|
## Additional implementation priorities
|
||||||
|
|
||||||
|
1. Add integration coverage for simultaneous video/audio startup, cancellation, and error propagation against an MXL instance.
|
||||||
|
2. 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
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/mxl-pattern/
|
||||||
|
main.go
|
||||||
|
|
||||||
|
internal/config/
|
||||||
|
config.go
|
||||||
|
flow.go
|
||||||
|
|
||||||
|
internal/video/
|
||||||
|
generator.go
|
||||||
|
wgpu.go
|
||||||
|
overlay.go
|
||||||
|
runner.go
|
||||||
|
|
||||||
|
internal/audio/
|
||||||
|
generator.go
|
||||||
|
sine.go
|
||||||
|
runner.go
|
||||||
|
|
||||||
|
internal/app/
|
||||||
|
run.go
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact directory names are less important than keeping CLI parsing, typed configuration, media generation, and MXL writing as separate responsibilities.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The following command was used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOCACHE=/tmp/go-mxl-gen-cache go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
All packages pass. `go vet ./...`, the application build, and `git diff --check` also succeed.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Keep the chosen Go + go-mxl + wgpu stack. Audio belongs on the CPU in its own goroutine, with video and audio using separate writers, indices, and pacing loops. Coordinate them through a shared context and the common MXL timebase, not through per-frame messages. Typed configuration, media runners, and rendering correctness coverage are now in place; GPU readback optimization remains deferred until after audio work.
|
||||||
@@ -17,4 +17,4 @@ test:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean
|
go clean
|
||||||
fm -f ./build/mxl-gen
|
rm -f ./build/mxl-gen
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// JetBrainsMono contains the font used for video text overlays.
|
||||||
|
//
|
||||||
|
//go:embed fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf
|
||||||
|
var JetBrainsMono []byte
|
||||||
+318
-290
@@ -4,23 +4,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"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/app"
|
||||||
"mxl-pattern-generator/internal/generator"
|
"mxl-pattern-generator/internal/audio"
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
"mxl-pattern-generator/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
APP_NAME = "MXL pattern generator"
|
APP_NAME = app.Name
|
||||||
APP_VER = "0.1.0"
|
APP_VER = app.Version
|
||||||
)
|
)
|
||||||
|
|
||||||
type appArgs struct {
|
type appArgs struct {
|
||||||
@@ -41,12 +47,21 @@ type appArgs struct {
|
|||||||
videoHeight uint
|
videoHeight uint
|
||||||
videoFPS string
|
videoFPS string
|
||||||
videoUUID string
|
videoUUID string
|
||||||
|
noVideo bool
|
||||||
|
videoAlpha bool
|
||||||
|
videoBackend string
|
||||||
|
|
||||||
audioChannels uint8
|
audioChannels uint8
|
||||||
audioSamplingFreq string
|
audioSamplingFreq string
|
||||||
|
audioLevel string
|
||||||
audioUUID string
|
audioUUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type parseResult struct {
|
||||||
|
args appArgs
|
||||||
|
shouldRun bool
|
||||||
|
}
|
||||||
|
|
||||||
var frameRates = map[string]mxl.Rational{
|
var frameRates = map[string]mxl.Rational{
|
||||||
"23.97": {Num: 24000, Den: 1001},
|
"23.97": {Num: 24000, Den: 1001},
|
||||||
"24": {Num: 24, Den: 1},
|
"24": {Num: 24, Den: 1},
|
||||||
@@ -67,212 +82,188 @@ var samplingRates = map[string]mxl.Rational{
|
|||||||
"192": {Num: 192000, Den: 1},
|
"192": {Num: 192000, Den: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
func printHelp(fs *pflag.FlagSet) {
|
func printHelp(w io.Writer, fs *pflag.FlagSet) {
|
||||||
fmt.Printf("%s %s\n", APP_NAME, APP_VER)
|
fmt.Fprintf(w, "%s %s\n", APP_NAME, APP_VER)
|
||||||
fmt.Println("Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
|
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
|
||||||
fmt.Println(" or: mxl-gen -d <domain> [--with <width px>] [--height <height px>] [--fps <framerate>] \\")
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> [--width <width px>] [--height <height px>] [--fps <framerate>] \\")
|
||||||
fmt.Println(" [-c <channels amount>] [-f <sample rate>]")
|
fmt.Fprintln(w, " [-c <channels amount>] [-f <sample rate>]")
|
||||||
fmt.Println(" or: mxl-gen -d <domain> with default params")
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> --no-video (-c <channels amount> | -a <flowDef.json>)")
|
||||||
fmt.Println("Video and audio feeds params will be ignored, if flow definition file provided.")
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> with default params")
|
||||||
fmt.Println()
|
fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.")
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
fs.SetOutput(w)
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func printUsage() {
|
func printUsage(w io.Writer) {
|
||||||
fmt.Fprintln(os.Stderr, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
|
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [--no-video] [-v <flowDef.json>] [-a <flowDef.json>] [options]")
|
||||||
fmt.Fprintln(os.Stderr, "Try 'mxl-gen -h' for more information.")
|
fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkArgs(args *appArgs) {
|
func validateDomain(domain string) error {
|
||||||
printUsageAndExit := func() {
|
if domain == "" {
|
||||||
printUsage()
|
return fmt.Errorf("domain is required")
|
||||||
os.Exit(2)
|
|
||||||
}
|
}
|
||||||
// domain
|
fi, err := os.Stat(domain)
|
||||||
if args.domain == "" {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Domain is required\n")
|
return fmt.Errorf("invalid MXL domain %q: %w", domain, err)
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
fi, err := os.Stat(args.domain)
|
if !fi.IsDir() {
|
||||||
if err != nil || !fi.IsDir() {
|
return fmt.Errorf("invalid MXL domain %q: not a directory", domain)
|
||||||
fmt.Fprintf(os.Stderr, "Invalid MXL domain: %s\n", args.domain)
|
|
||||||
fmt.Fprintf(os.Stderr, "Reason: %v\n", err)
|
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
if ok, err := mxl.IsTmpFs(args.domain); err != nil || !ok {
|
if ok, err := mxl.IsTmpFs(domain); err != nil || !ok {
|
||||||
fmt.Fprintf(os.Stderr, "Invalid MXL domain: %s\n", args.domain)
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "Domain must be directory in tmps.")
|
return fmt.Errorf("check MXL domain %q: %w", domain, err)
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
// FlowDef
|
return fmt.Errorf("invalid MXL domain %q: directory must be on tmpfs", domain)
|
||||||
checkFlowDef := func(label, flowDef string) {
|
|
||||||
fi, err := os.Stat(flowDef)
|
|
||||||
if err != nil || fi.IsDir() {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s flow definition .json file is not accesible\n", label)
|
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFlowDefPath(label, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s flow definition %q is not accessible: %w", label, path, err)
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
return fmt.Errorf("%s flow definition %q is a directory", label, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateVideoArgs(args *appArgs) error {
|
||||||
|
if args.noVideo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := parseVideoBackend(args.videoBackend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !video.HasPattern(args.pattern) {
|
||||||
|
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
|
||||||
}
|
}
|
||||||
videoFlowDefProvided, audioFlowDefProvided := false, false
|
|
||||||
if args.videoFlowDefFile != "" {
|
if args.videoFlowDefFile != "" {
|
||||||
checkFlowDef("Video", args.videoFlowDefFile)
|
return nil
|
||||||
videoFlowDefProvided = true
|
|
||||||
}
|
}
|
||||||
if args.audioFlowDefFile != "" {
|
if args.videoWidth == 0 || args.videoWidth%2 != 0 {
|
||||||
checkFlowDef("Audio", args.audioFlowDefFile)
|
return fmt.Errorf("video width must be greater than zero and even for 4:2:2 video")
|
||||||
audioFlowDefProvided = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !videoFlowDefProvided {
|
|
||||||
if args.videoWidth == 0 || args.videoWidth%6 != 0 {
|
|
||||||
// width%6 == 0 - because of v210 (6 pixels per 16-byte block)
|
|
||||||
fmt.Fprintf(os.Stderr, "Video width must be > 0 and divisible by 6\n")
|
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
if args.videoHeight == 0 {
|
if args.videoHeight == 0 {
|
||||||
fmt.Fprintf(os.Stderr, "Video height must be > 0\n")
|
return fmt.Errorf("video height must be greater than zero")
|
||||||
printUsageAndExit()
|
|
||||||
}
|
}
|
||||||
if _, exists := frameRates[args.videoFPS]; !exists {
|
if _, exists := frameRates[args.videoFPS]; !exists {
|
||||||
fmt.Fprintf(os.Stderr, "FPS %s is not in available list.\n", args.videoFPS)
|
return fmt.Errorf("unsupported video FPS %q (supported: %s); use a flow definition for other rates",
|
||||||
fmt.Fprintln(os.Stderr, "If you need more complex solution, use flow definition .json instead.")
|
args.videoFPS, sortedMapKeys(frameRates))
|
||||||
fmt.Fprintln(os.Stderr, "Available list:")
|
|
||||||
for key, _ := range frameRates {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s\n", key)
|
|
||||||
}
|
}
|
||||||
printUsageAndExit()
|
if args.videoUUID == "" {
|
||||||
}
|
|
||||||
if args.videoUUID != "" {
|
|
||||||
if _, err := uuid.Parse(args.videoUUID); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Video UUID %s is not valid.\n", args.videoUUID)
|
|
||||||
printUsageAndExit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args.videoUUID = uuid.NewString()
|
args.videoUUID = uuid.NewString()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if args.pattern != "" {
|
if _, err := uuid.Parse(args.videoUUID); err != nil {
|
||||||
if _, exists := patterns[args.pattern]; !exists {
|
return fmt.Errorf("invalid video UUID %q: %w", args.videoUUID, err)
|
||||||
fmt.Fprintf(os.Stderr, "Pattern %s is not in available list.\n", args.pattern)
|
|
||||||
listPatterns(os.Stderr)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !audioFlowDefProvided {
|
func validateMediaSelection(args appArgs) error {
|
||||||
if args.audioChannels == 0 {
|
if args.noVideo && args.videoFlowDefFile != "" {
|
||||||
// TODO: ignore audio
|
return fmt.Errorf("--no-video cannot be used with --video")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAudioArgs(args *appArgs) error {
|
||||||
|
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := audioLevels[args.audioLevel]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"unsupported audio level %q (supported: %s)",
|
||||||
|
args.audioLevel,
|
||||||
|
sortedMapKeys(audioLevels),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if args.audioFlowDefFile != "" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
|
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
|
||||||
fmt.Fprintf(os.Stderr, "Sample rate %s is not in available list.\n", args.audioSamplingFreq)
|
return fmt.Errorf("unsupported audio sample rate %q (supported: %s); use a flow definition for other rates",
|
||||||
fmt.Fprintln(os.Stderr, "If you need more complex solution, use flow definition .json instead.")
|
args.audioSamplingFreq, sortedMapKeys(samplingRates))
|
||||||
fmt.Fprintln(os.Stderr, "Available list:")
|
|
||||||
for key, _ := range samplingRates {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s\n", key)
|
|
||||||
}
|
}
|
||||||
printUsageAndExit()
|
if args.audioUUID == "" {
|
||||||
}
|
|
||||||
if args.audioUUID != "" {
|
|
||||||
if err := uuid.Validate(args.audioUUID); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Audio UUID %s is not valid.\n", args.audioUUID)
|
|
||||||
printUsageAndExit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args.audioUUID = uuid.NewString()
|
args.audioUUID = uuid.NewString()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := uuid.Validate(args.audioUUID); err != nil {
|
||||||
|
return fmt.Errorf("invalid audio UUID %q: %w", args.audioUUID, err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type pattern struct {
|
func validateArgs(args *appArgs) error {
|
||||||
name string
|
checks := []func() error{
|
||||||
description string
|
func() error { return validateDomain(args.domain) },
|
||||||
kernelPath string
|
func() error { return validateMediaSelection(*args) },
|
||||||
motion bool
|
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
|
||||||
|
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
|
||||||
|
func() error { return validateVideoArgs(args) },
|
||||||
|
func() error { return validateAudioArgs(args) },
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
if err := check(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var patterns = map[string]pattern{
|
func sortedMapKeys[V any](values map[string]V) string {
|
||||||
"ebu75": {
|
keys := make([]string, 0, len(values))
|
||||||
name: "ebu75",
|
for key := range values {
|
||||||
description: "EBU 75% Color Bar Signal",
|
keys = append(keys, key)
|
||||||
kernelPath: "kernels/static/ebu75.wgsl",
|
}
|
||||||
motion: false,
|
sort.Strings(keys)
|
||||||
},
|
return strings.Join(keys, ", ")
|
||||||
"ebu75-move": {
|
|
||||||
name: "ebu75-move",
|
|
||||||
description: "EBU 75% Color Bar Signal with moving square",
|
|
||||||
kernelPath: "kernels/dynamic/ebu75.wgsl",
|
|
||||||
motion: true,
|
|
||||||
},
|
|
||||||
"ebu100": {
|
|
||||||
name: "ebu100",
|
|
||||||
description: "EBU 100% Color Bar Signal",
|
|
||||||
kernelPath: "kernels/static/ebu100.wgsl",
|
|
||||||
motion: false,
|
|
||||||
},
|
|
||||||
"ebu100-move": {
|
|
||||||
name: "ebu100-move",
|
|
||||||
description: "EBU 100% Color Bar Signal with moving square",
|
|
||||||
kernelPath: "kernels/dynamic/ebu100.wgsl",
|
|
||||||
motion: true,
|
|
||||||
},
|
|
||||||
"smpte": {
|
|
||||||
name: "smpte",
|
|
||||||
description: "SMPTE RP-219 Color Bar Signal",
|
|
||||||
kernelPath: "kernels/static/smpteBars.wgsl",
|
|
||||||
motion: false,
|
|
||||||
},
|
|
||||||
"smpte-move": {
|
|
||||||
name: "smpte-move",
|
|
||||||
description: "SMPTE RP-219 Color Bar Signal with moving square",
|
|
||||||
kernelPath: "kernels/dynamic/smpteBars.wgsl",
|
|
||||||
motion: true,
|
|
||||||
},
|
|
||||||
"gray-bars": {
|
|
||||||
name: "gray-bars",
|
|
||||||
description: "13-step grayscale bars (Y 64..940)",
|
|
||||||
kernelPath: "kernels/static/yBars.wgsl",
|
|
||||||
motion: false,
|
|
||||||
},
|
|
||||||
"gray-bars-move": {
|
|
||||||
name: "gray-bars-move",
|
|
||||||
description: "13-step grayscale bars (Y 64..940) with moving square",
|
|
||||||
kernelPath: "kernels/dynamic/yBars.wgsl",
|
|
||||||
motion: true,
|
|
||||||
},
|
|
||||||
"gray-ramp": {
|
|
||||||
name: "gray-ramp",
|
|
||||||
description: "Y gradient (black -> 100% white)",
|
|
||||||
kernelPath: "kernels/static/yRamp.wgsl",
|
|
||||||
motion: false,
|
|
||||||
},
|
|
||||||
"gray-ramp-move": {
|
|
||||||
name: "gray-ramp-move",
|
|
||||||
description: "Y gradient with moving square",
|
|
||||||
kernelPath: "kernels/dynamic/yRamp.wgsl",
|
|
||||||
motion: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listPatterns(f *os.File) {
|
var audioLevels = map[string]float64{
|
||||||
fmt.Fprintln(f, "List of available video patterns:")
|
"ebu": audio.LevelEBUDBFS,
|
||||||
var maxNameWidth int = 0
|
"smpte": audio.LevelSMPTEDBFS,
|
||||||
for name, _ := range patterns {
|
}
|
||||||
l := len(name)
|
|
||||||
|
func listPatterns(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "List of available video patterns:")
|
||||||
|
patterns := video.Patterns()
|
||||||
|
maxNameWidth := 0
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
l := len(pattern.Name)
|
||||||
if l > maxNameWidth {
|
if l > maxNameWidth {
|
||||||
maxNameWidth = l
|
maxNameWidth = l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for name, p := range patterns {
|
for _, pattern := range patterns {
|
||||||
fmt.Fprintf(f, " %-*s - %s\n", maxNameWidth, name, p.description)
|
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, pattern.Name, pattern.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func flagSetAddFlags(fs *pflag.FlagSet, args *appArgs) {
|
func addFlags(fs *pflag.FlagSet, args *appArgs) {
|
||||||
// common flags
|
// common flags
|
||||||
fs.BoolVarP(&args.showHelp, "help", "h", false, "Show help message and exit")
|
fs.BoolVarP(&args.showHelp, "help", "h", false, "Show help message and exit")
|
||||||
// MXL flags
|
// MXL flags
|
||||||
fs.StringVarP(&args.domain, "domain", "d", "", "MXL domain")
|
fs.StringVarP(&args.domain, "domain", "d", "", "MXL domain")
|
||||||
fs.StringVarP(&args.videoFlowDefFile, "video", "v", "", "Video flow definition JSON file path")
|
fs.StringVarP(&args.videoFlowDefFile, "video", "v", "", "Video flow definition JSON file path")
|
||||||
fs.StringVarP(&args.audioFlowDefFile, "audio", "a", "", "Audio flow definition JSON file path [TODO]")
|
fs.StringVarP(&args.audioFlowDefFile, "audio", "a", "", "Audio flow definition JSON file path")
|
||||||
// Video pattern flags
|
// Video pattern flags
|
||||||
fs.StringVarP(&args.pattern, "pattern", "p", "ebu75", "Video pattern type")
|
fs.StringVarP(&args.pattern, "pattern", "p", "ebu75", "Video pattern type")
|
||||||
fs.BoolVar(&args.listPatterns, "list-patterns", false, "List video available video patterns and exit")
|
fs.BoolVar(&args.listPatterns, "list-patterns", false, "List video available video patterns and exit")
|
||||||
@@ -298,175 +289,212 @@ func flagSetAddFlags(fs *pflag.FlagSet, args *appArgs) {
|
|||||||
"br - bottom-right corner",
|
"br - bottom-right corner",
|
||||||
)
|
)
|
||||||
|
|
||||||
fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width. Zero = no video [TODO: 0 width case]")
|
fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width")
|
||||||
fs.UintVar(&args.videoHeight, "height", 1080, "Video pattern height")
|
fs.UintVar(&args.videoHeight, "height", 1080, "Video pattern height")
|
||||||
fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS")
|
fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS")
|
||||||
fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided")
|
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
|
// Audio pattern flags
|
||||||
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz. Zero = no sound [TODO]")
|
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz")
|
||||||
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz [TODO]")
|
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz")
|
||||||
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided [TODO]")
|
fs.StringVar(&args.audioLevel, "audio-level", "ebu", "Audio alignment level: ebu (-18 dBFS) or smpte (-20 dBFS)")
|
||||||
|
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
|
||||||
var args appArgs
|
var args appArgs
|
||||||
flagSet := pflag.NewFlagSet("args", pflag.ContinueOnError)
|
flagSet := pflag.NewFlagSet("args", pflag.ContinueOnError)
|
||||||
flagSet.SortFlags = false
|
flagSet.SortFlags = false
|
||||||
flagSet.Usage = func() { printUsage() }
|
flagSet.SetOutput(stderr)
|
||||||
flagSetAddFlags(flagSet, &args)
|
flagSet.Usage = func() { printUsage(stderr) }
|
||||||
|
addFlags(flagSet, &args)
|
||||||
|
|
||||||
if err := flagSet.Parse(os.Args[1:]); err != nil {
|
if err := flagSet.Parse(argv); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
return parseResult{}, err
|
||||||
printUsage()
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
}
|
||||||
if args.showHelp {
|
if args.showHelp {
|
||||||
printHelp(flagSet)
|
printHelp(stdout, flagSet)
|
||||||
return
|
return parseResult{args: args}, nil
|
||||||
}
|
}
|
||||||
if args.listPatterns {
|
if args.listPatterns {
|
||||||
listPatterns(os.Stderr)
|
listPatterns(stdout)
|
||||||
return
|
return parseResult{args: args}, nil
|
||||||
|
}
|
||||||
|
if flagSet.NArg() != 0 {
|
||||||
|
return parseResult{}, fmt.Errorf("unexpected positional arguments: %v", flagSet.Args())
|
||||||
|
}
|
||||||
|
if err := validateArgs(&args); err != nil {
|
||||||
|
return parseResult{}, err
|
||||||
|
}
|
||||||
|
return parseResult{args: args, shouldRun: true}, nil
|
||||||
}
|
}
|
||||||
checkArgs(&args)
|
|
||||||
|
|
||||||
type videoInfo struct {
|
func parseVideoBackend(value string) (video.Backend, error) {
|
||||||
uuid string
|
if value == "" {
|
||||||
width uint
|
return video.BackendAuto, nil
|
||||||
height uint
|
|
||||||
fps mxl.Rational
|
|
||||||
}
|
}
|
||||||
var vi videoInfo
|
backend := video.Backend(value)
|
||||||
var videoFlowDef string
|
switch backend {
|
||||||
if args.videoFlowDefFile == "" {
|
case video.BackendAuto, video.BackendGPU, video.BackendCPU:
|
||||||
args.videoUUID = "8f1d2a4b-6c3e-4f5a-9b2c-1d7e8a3f0b5d" // TODO: remove before public release
|
return backend, nil
|
||||||
vi = videoInfo{
|
default:
|
||||||
uuid: args.videoUUID,
|
return "", fmt.Errorf(
|
||||||
width: args.videoWidth,
|
"unsupported video backend %q (supported: auto, gpu, cpu)",
|
||||||
height: args.videoHeight,
|
value,
|
||||||
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
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVideoConfig(args appArgs) (*video.Config, error) {
|
||||||
|
if args.noVideo {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var definition flowdef.Video
|
||||||
|
if args.videoFlowDefFile != "" {
|
||||||
|
data, err := os.ReadFile(args.videoFlowDefFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not create Flow Definition: %v", err)
|
return nil, fmt.Errorf(
|
||||||
|
"read video flow definition %q: %w",
|
||||||
|
args.videoFlowDefFile,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition, err = flowdef.ParseVideo(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"parse video flow definition %q: %w",
|
||||||
|
args.videoFlowDefFile,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
flowDef, err := flowdef.ReadFlowDefFile(args.videoFlowDefFile)
|
rate, ok := frameRates[args.videoFPS]
|
||||||
if err != nil {
|
if !ok {
|
||||||
log.Fatalf("Could not read video flow def .json: %s. Reason: %v", args.videoFlowDefFile, err)
|
return nil, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
|
||||||
}
|
|
||||||
videoFlowDef = flowDef
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("%s %s", APP_NAME, APP_VER)
|
var err error
|
||||||
log.Printf("Domain: %s", args.domain)
|
newVideo := flowdef.NewV210Video
|
||||||
log.Printf("Video: %dx%d %d/%d", vi.width, vi.height, vi.fps.Num, vi.fps.Den)
|
if args.videoAlpha {
|
||||||
log.Printf("Video UUID: %s", vi.uuid)
|
newVideo = flowdef.NewV210AVideo
|
||||||
|
|
||||||
// TODO: if init failed -> CPU generator
|
|
||||||
videoPattern := patterns[args.pattern]
|
|
||||||
gen, err := generator.NewWGPUGenerator(vi.width, vi.height, videoPattern.kernelPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("wgpu init failed: %v", err)
|
|
||||||
}
|
}
|
||||||
defer gen.Close()
|
definition, err = newVideo(
|
||||||
|
args.videoUUID,
|
||||||
// Static text overlay: rasterized + pre-packed once, stamped on each
|
args.videoWidth,
|
||||||
// frame after the GPU render (microseconds per frame).
|
args.videoHeight,
|
||||||
var overlay *generator.TextOverlay
|
flowdef.Rational{
|
||||||
if args.textOverlay != "" {
|
Numerator: uint(rate.Num),
|
||||||
face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48)
|
Denominator: uint(rate.Den),
|
||||||
if err != nil {
|
},
|
||||||
log.Fatalf("text overlay init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer face.Close()
|
|
||||||
if args.overlayX < 0 ||
|
|
||||||
args.overlayX > int(vi.width) ||
|
|
||||||
args.overlayY < 0 ||
|
|
||||||
args.overlayY > int(vi.height) {
|
|
||||||
log.Fatalf("overlay X/Y pos can't be negative or greater, than video width/height")
|
|
||||||
}
|
|
||||||
overlay, err = generator.NewTextOverlay(
|
|
||||||
args.textOverlay,
|
|
||||||
int(vi.width),
|
|
||||||
int(vi.height),
|
|
||||||
args.overlayX,
|
|
||||||
args.overlayY,
|
|
||||||
args.overlayPos,
|
|
||||||
face,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("text overlay init failed: %v", err)
|
return nil, fmt.Errorf(
|
||||||
|
"build video flow definition: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !video.HasPattern(args.pattern) {
|
||||||
|
return nil, fmt.Errorf("unknown video pattern %q", args.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
inst, err := mxl.NewInstance(args.domain, "")
|
backend, err := parseVideoBackend(args.videoBackend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("MXL Init Failed: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer inst.Close()
|
|
||||||
|
|
||||||
writer, isCreated, err := inst.NewWriter(videoFlowDef)
|
return &video.Config{
|
||||||
|
Definition: definition,
|
||||||
|
Pattern: args.pattern,
|
||||||
|
Overlay: video.OverlayConfig{
|
||||||
|
Text: args.textOverlay,
|
||||||
|
X: args.overlayX,
|
||||||
|
Y: args.overlayY,
|
||||||
|
Position: args.overlayPos,
|
||||||
|
},
|
||||||
|
Backend: backend,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAudioConfig returns nil when audio is disabled. An explicit audio flow
|
||||||
|
// definition enables audio even when --channel is zero.
|
||||||
|
func buildAudioConfig(args appArgs) (*audio.Config, error) {
|
||||||
|
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
levelDBFS, ok := audioLevels[args.audioLevel]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported audio level %q", args.audioLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var definition flowdef.Audio
|
||||||
|
if args.audioFlowDefFile != "" {
|
||||||
|
data, err := os.ReadFile(args.audioFlowDefFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create MXL writer: %v", err)
|
return nil, fmt.Errorf("read audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||||
|
}
|
||||||
|
definition, err = flowdef.ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rate, ok := samplingRates[args.audioSamplingFreq]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported audio sample rate %q", args.audioSamplingFreq)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
definition, err = flowdef.NewFloat32Audio(
|
||||||
|
args.audioUUID,
|
||||||
|
uint(args.audioChannels),
|
||||||
|
flowdef.Rational{Numerator: uint(rate.Num), Denominator: uint(rate.Den)},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build audio flow definition: %w", err)
|
||||||
}
|
}
|
||||||
if !isCreated {
|
|
||||||
log.Printf("reusing existing flow: %s, domain: %s", vi.uuid, args.domain)
|
|
||||||
}
|
}
|
||||||
defer writer.Close()
|
|
||||||
|
|
||||||
flowCfg := writer.Config()
|
return &audio.Config{
|
||||||
rate := flowCfg.Common.GrainRate
|
Definition: definition,
|
||||||
idx := mxl.CurrentIndex(rate)
|
LevelDBFS: levelDBFS,
|
||||||
log.Printf("writing flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx)
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
stop := make(chan os.Signal, 1)
|
func main() {
|
||||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
|
||||||
|
if err != nil {
|
||||||
// core loop
|
fmt.Fprintln(os.Stderr, err)
|
||||||
var grainsWritten int64
|
printUsage(os.Stderr)
|
||||||
// animation clock: small counter, not the huge grain index.
|
os.Exit(2)
|
||||||
// Reason: current wgpu shaders limitations
|
}
|
||||||
var tick uint32
|
if !parsed.shouldRun {
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
log.Printf("stopping after %d grains", grainsWritten)
|
|
||||||
return
|
return
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gwa, err := writer.OpenGrain(idx)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := run(ctx, parsed.args); err != nil {
|
||||||
|
log.Printf("%s: %v", APP_NAME, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, args appArgs) error {
|
||||||
|
videoCfg, err := buildVideoConfig(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("OpenGrain(%d): %v", idx, err)
|
return fmt.Errorf("video configuration: %w", err)
|
||||||
}
|
}
|
||||||
if err := gen.GenerateFrame(gwa.Payload, int(tick)); err != nil {
|
audioCfg, err := buildAudioConfig(args)
|
||||||
log.Fatalf("GenerateFrame(%d): %v", idx, err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("audio configuration: %w", err)
|
||||||
if overlay != nil {
|
|
||||||
if err := overlay.ApplyV210(gwa.Payload); err != nil {
|
|
||||||
log.Fatalf("text overlay: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := gwa.Commit(gwa.TotalSlices, 0); err != nil {
|
|
||||||
log.Fatalf("Commit(%d): %v", idx, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
grainsWritten++
|
return app.Run(ctx, app.Config{
|
||||||
idx++
|
Domain: args.domain,
|
||||||
tick++
|
Video: videoCfg,
|
||||||
if grainsWritten%100 == 0 {
|
Audio: audioCfg,
|
||||||
log.Printf("grains written=%d, index=%d", grainsWritten, idx)
|
})
|
||||||
}
|
|
||||||
// Pace ourselves to roughly the grain rate
|
|
||||||
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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
|
||||||
|
|
||||||
|
result, err := parseArgs([]string{"--help"}, &stdout, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseArgs: %v", err)
|
||||||
|
}
|
||||||
|
if result.shouldRun {
|
||||||
|
t.Fatal("shouldRun = true, want false")
|
||||||
|
}
|
||||||
|
if !result.args.showHelp {
|
||||||
|
t.Fatal("showHelp = false, want true")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "Usage: mxl-gen") {
|
||||||
|
t.Fatalf("help output does not contain usage: %q", stdout.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsListPatternsStopsBeforeValidation(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
result, err := parseArgs([]string{"--list-patterns"}, &stdout, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseArgs: %v", err)
|
||||||
|
}
|
||||||
|
if result.shouldRun {
|
||||||
|
t.Fatal("shouldRun = true, want false")
|
||||||
|
}
|
||||||
|
if !result.args.listPatterns {
|
||||||
|
t.Fatal("listPatterns = false, want true")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "ebu75") {
|
||||||
|
t.Fatalf("pattern output does not contain ebu75: %q", stdout.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsRejectsUnexpectedPositionalArguments(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
result, err := parseArgs([]string{"unexpected"}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("parseArgs returned nil error")
|
||||||
|
}
|
||||||
|
if result.shouldRun {
|
||||||
|
t.Fatal("shouldRun = true, want false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unexpected positional arguments") {
|
||||||
|
t.Fatalf("error = %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsRejectsUnknownFlag(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
result, err := parseArgs([]string{"--not-a-flag"}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("parseArgs returned nil error")
|
||||||
|
}
|
||||||
|
if result.shouldRun {
|
||||||
|
t.Fatal("shouldRun = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAudioArgsSkipsDisabledAudio(t *testing.T) {
|
||||||
|
args := appArgs{
|
||||||
|
audioChannels: 0,
|
||||||
|
audioSamplingFreq: "unsupported",
|
||||||
|
audioUUID: "not-a-uuid",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAudioArgs(&args); err != nil {
|
||||||
|
t.Fatalf("validateAudioArgs: %v", err)
|
||||||
|
}
|
||||||
|
if args.audioUUID != "not-a-uuid" {
|
||||||
|
t.Fatalf("audio UUID changed while audio is disabled: %q", args.audioUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateVideoArgsSkipsDisabledVideo(t *testing.T) {
|
||||||
|
args := appArgs{
|
||||||
|
noVideo: true,
|
||||||
|
pattern: "not-a-pattern",
|
||||||
|
videoWidth: 1,
|
||||||
|
videoFPS: "unsupported",
|
||||||
|
videoUUID: "not-a-uuid",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateVideoArgs(&args); err != nil {
|
||||||
|
t.Fatalf("validateVideoArgs: %v", err)
|
||||||
|
}
|
||||||
|
if args.videoUUID != "not-a-uuid" {
|
||||||
|
t.Fatalf("video UUID changed while video is disabled: %q", args.videoUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVideoBackend(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want video.Backend
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "zero value defaults to auto", want: video.BackendAuto},
|
||||||
|
{name: "auto", value: "auto", want: video.BackendAuto},
|
||||||
|
{name: "gpu", value: "gpu", want: video.BackendGPU},
|
||||||
|
{name: "cpu", value: "cpu", want: video.BackendCPU},
|
||||||
|
{name: "unknown", value: "other", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseVideoBackend(tc.value)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsupported video backend") {
|
||||||
|
t.Fatalf("error = %v, want unsupported backend error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseVideoBackend: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("backend = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateVideoArgsRejectsUnknownBackend(t *testing.T) {
|
||||||
|
args := appArgs{videoBackend: "other"}
|
||||||
|
if err := validateVideoArgs(&args); err == nil || !strings.Contains(err.Error(), "unsupported video backend") {
|
||||||
|
t.Fatalf("error = %v, want unsupported backend error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMediaSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args appArgs
|
||||||
|
wantErrSub string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no video with video definition",
|
||||||
|
args: appArgs{noVideo: true, videoFlowDefFile: "video.json", audioChannels: 2},
|
||||||
|
wantErrSub: "cannot be used with --video",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no flows",
|
||||||
|
args: appArgs{noVideo: true},
|
||||||
|
wantErrSub: "requires audio enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generated audio only",
|
||||||
|
args: appArgs{noVideo: true, audioChannels: 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "external audio only",
|
||||||
|
args: appArgs{noVideo: true, audioFlowDefFile: "audio.json"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateMediaSelection(tc.args)
|
||||||
|
if tc.wantErrSub == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateMediaSelection: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||||
|
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPatternsIsSorted(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
listPatterns(&output)
|
||||||
|
|
||||||
|
text := output.String()
|
||||||
|
if strings.Index(text, "ebu100") > strings.Index(text, "ebu75") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if cfg.Backend != video.BackendAuto {
|
||||||
|
t.Fatalf("backend = %q, want %q", cfg.Backend, video.BackendAuto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cfg, err := buildVideoConfig(appArgs{
|
||||||
|
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||||
|
videoWidth: 1920,
|
||||||
|
videoHeight: 1080,
|
||||||
|
videoFPS: "25",
|
||||||
|
pattern: "ebu75",
|
||||||
|
videoBackend: string(backend),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVideoConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Backend != backend {
|
||||||
|
t.Fatalf("backend = %q, want %q", cfg.Backend, backend)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVideoConfigDisabled(t *testing.T) {
|
||||||
|
cfg, err := buildVideoConfig(appArgs{noVideo: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVideoConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("config = %+v, want nil for disabled video", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAudioConfigDisabled(t *testing.T) {
|
||||||
|
cfg, err := buildAudioConfig(appArgs{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("config = %+v, want nil for disabled audio", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAudioConfigFromArgs(t *testing.T) {
|
||||||
|
cfg, err := buildAudioConfig(appArgs{
|
||||||
|
audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
audioChannels: 2,
|
||||||
|
audioSamplingFreq: "48",
|
||||||
|
audioLevel: "ebu",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("config is nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels() != 2 {
|
||||||
|
t.Fatalf("channels = %d, want 2", cfg.Channels())
|
||||||
|
}
|
||||||
|
if cfg.Rate().Num != 48000 || cfg.Rate().Den != 1 {
|
||||||
|
t.Fatalf("rate = %d/%d, want 48000/1", cfg.Rate().Num, cfg.Rate().Den)
|
||||||
|
}
|
||||||
|
if cfg.LevelDBFS != audio.LevelEBUDBFS {
|
||||||
|
t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelEBUDBFS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAudioConfigFromFile(t *testing.T) {
|
||||||
|
definition, err := flowdef.NewFloat32Audio(
|
||||||
|
"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
8,
|
||||||
|
flowdef.Rational{Numerator: 96000, Denominator: 1},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := t.TempDir() + "/audio.json"
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildAudioConfig(appArgs{
|
||||||
|
audioFlowDefFile: path,
|
||||||
|
audioChannels: 2,
|
||||||
|
audioSamplingFreq: "48",
|
||||||
|
audioLevel: "smpte",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("config is nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels() != 8 {
|
||||||
|
t.Fatalf("channels = %d, want file value 8", cfg.Channels())
|
||||||
|
}
|
||||||
|
if cfg.Rate().Num != 96000 || cfg.Rate().Den != 1 {
|
||||||
|
t.Fatalf("rate = %d/%d, want file value 96000/1", cfg.Rate().Num, cfg.Rate().Den)
|
||||||
|
}
|
||||||
|
if cfg.LevelDBFS != audio.LevelSMPTEDBFS {
|
||||||
|
t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelSMPTEDBFS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAudioArgsRejectsUnknownLevelForFlowDefinition(t *testing.T) {
|
||||||
|
args := appArgs{
|
||||||
|
audioFlowDefFile: "audio.json",
|
||||||
|
audioLevel: "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validateAudioArgs(&args)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsupported audio level") {
|
||||||
|
t.Fatalf("error = %v, want unsupported audio level error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/audio"
|
||||||
|
"mxl-pattern-generator/internal/video"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Name = "MXL pattern generator"
|
||||||
|
Version = "0.2.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Domain string
|
||||||
|
Video *video.Config
|
||||||
|
Audio *audio.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, cfg Config) (runErr error) {
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s %s", Name, Version)
|
||||||
|
log.Printf("Domain: %s", cfg.Domain)
|
||||||
|
if cfg.Video != nil {
|
||||||
|
log.Printf("Video: %dx%d %d/%d",
|
||||||
|
cfg.Video.Width(), cfg.Video.Height(), cfg.Video.Rate().Num, cfg.Video.Rate().Den)
|
||||||
|
log.Printf("Video ID: %s", cfg.Video.ID())
|
||||||
|
}
|
||||||
|
if cfg.Audio != nil {
|
||||||
|
log.Printf("Audio: %d channels %d/%d Hz %.0f dBFS",
|
||||||
|
cfg.Audio.Channels(), cfg.Audio.Rate().Num, cfg.Audio.Rate().Den, cfg.Audio.LevelDBFS)
|
||||||
|
log.Printf("Audio ID: %s", cfg.Audio.ID())
|
||||||
|
}
|
||||||
|
inst, err := mxl.NewInstance(cfg.Domain, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize MXL domain %q: %w", cfg.Domain, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := inst.Close(); err != nil {
|
||||||
|
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
runners := make([]Runner, 0, 2)
|
||||||
|
if cfg.Video != nil {
|
||||||
|
runners = append(runners, Runner{
|
||||||
|
Name: "video",
|
||||||
|
Run: func(ctx context.Context) error {
|
||||||
|
return video.Run(ctx, inst, *cfg.Video)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if cfg.Audio != nil {
|
||||||
|
runners = append(runners, Runner{
|
||||||
|
Name: "audio",
|
||||||
|
Run: func(ctx context.Context) error {
|
||||||
|
return audio.Run(ctx, inst, *cfg.Audio)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunConcurrent(ctx, runners...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(cfg Config) error {
|
||||||
|
if cfg.Video == nil && cfg.Audio == nil {
|
||||||
|
return fmt.Errorf("at least one media flow must be enabled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsNoFlows(t *testing.T) {
|
||||||
|
err := validateConfig(Config{
|
||||||
|
Domain: "/unused",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "at least one media flow") {
|
||||||
|
t.Fatalf("error = %v, want no-flow validation error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
Name string
|
||||||
|
Run func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type runnerResult struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunConcurrent(ctx context.Context, runners ...Runner) error {
|
||||||
|
if len(runners) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
results := make(chan runnerResult, len(runners))
|
||||||
|
for _, runner := range runners {
|
||||||
|
runner := runner
|
||||||
|
go func() {
|
||||||
|
results <- runnerResult{name: runner.Name, err: runner.Run(ctx)}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultErr error
|
||||||
|
for range runners {
|
||||||
|
result := <-results
|
||||||
|
if result.err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("%s flow: %w", result.name, result.err))
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"mxl-pattern-generator/internal/app"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunConcurrentCancelsSiblingAndWaitsForCleanup(t *testing.T) {
|
||||||
|
wantErr := errors.New("writer failed")
|
||||||
|
peerStarted := make(chan struct{})
|
||||||
|
peerStopped := make(chan struct{})
|
||||||
|
|
||||||
|
err := app.RunConcurrent(context.Background(),
|
||||||
|
app.Runner{
|
||||||
|
Name: "video",
|
||||||
|
Run: func(ctx context.Context) error {
|
||||||
|
<-peerStarted
|
||||||
|
return wantErr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
app.Runner{
|
||||||
|
Name: "audio",
|
||||||
|
Run: func(ctx context.Context) error {
|
||||||
|
close(peerStarted)
|
||||||
|
<-ctx.Done()
|
||||||
|
close(peerStopped)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "video flow") {
|
||||||
|
t.Fatalf("error = %q, want runner name", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-peerStopped:
|
||||||
|
default:
|
||||||
|
t.Fatal("runConcurrent returned before the sibling completed cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConcurrentParentCancellationIsGraceful(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
started := make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- app.RunConcurrent(ctx, app.Runner{
|
||||||
|
Name: "video",
|
||||||
|
Run: func(ctx context.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-started
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runConcurrent: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runConcurrent did not stop after parent cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelEBUDBFS = -18.0
|
||||||
|
LevelSMPTEDBFS = -20.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Definition flowdef.Audio
|
||||||
|
LevelDBFS float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) ID() string {
|
||||||
|
return c.Definition.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Channels() uint {
|
||||||
|
return c.Definition.ChannelCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Rate() mxl.Rational {
|
||||||
|
return mxl.Rational{
|
||||||
|
Num: int64(c.Definition.SampleRate.Numerator),
|
||||||
|
Den: int64(c.Definition.SampleRate.Denominator),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Generator interface {
|
||||||
|
// Generate fills consecutive float32 sample fragments for one channel.
|
||||||
|
// firstSample is the absolute MXL sample index, so phase does not depend on
|
||||||
|
// batch or ring-buffer boundaries.
|
||||||
|
Generate(channel uint, firstSample uint64, fragments ...[]byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SineGenerator struct {
|
||||||
|
channels uint
|
||||||
|
sampleRate float64
|
||||||
|
baseFrequency float64
|
||||||
|
amplitude float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSineGenerator(
|
||||||
|
cfg Config,
|
||||||
|
baseFrequency float64,
|
||||||
|
) (*SineGenerator, error) {
|
||||||
|
if cfg.Channels() == 0 {
|
||||||
|
return nil, fmt.Errorf("channel count must be greater than zero")
|
||||||
|
}
|
||||||
|
rate := cfg.Rate()
|
||||||
|
if rate.Num <= 0 || rate.Den <= 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"sample rate numerator and denominator must be greater than zero, got %d/%d",
|
||||||
|
rate.Num,
|
||||||
|
rate.Den,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if math.IsNaN(baseFrequency) || math.IsInf(baseFrequency, 0) || baseFrequency <= 0 {
|
||||||
|
return nil, fmt.Errorf("base frequency must be finite and greater than zero, got %g", baseFrequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep every generated tone below the Nyquist frequency (the
|
||||||
|
// Kotelnikov–Nyquist–Shannon sampling limit), sampleRate/2
|
||||||
|
// Nyquist leaved as compromise for common English terminology
|
||||||
|
sampleRate := float64(rate.Num) / float64(rate.Den)
|
||||||
|
highestFrequency := float64(cfg.Channels()) * baseFrequency
|
||||||
|
nyquist := sampleRate / 2
|
||||||
|
if highestFrequency >= nyquist {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"highest tone frequency %.0f Hz must be below Nyquist frequency %.0f Hz",
|
||||||
|
highestFrequency,
|
||||||
|
nyquist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if math.IsNaN(cfg.LevelDBFS) || math.IsInf(cfg.LevelDBFS, 0) {
|
||||||
|
return nil, fmt.Errorf("audio level must be finite")
|
||||||
|
}
|
||||||
|
if cfg.LevelDBFS > 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"audio level must not exceed 0 dBFS, got %.2f",
|
||||||
|
cfg.LevelDBFS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if cfg.LevelDBFS < -100 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"audio level must be at least -100 dBFS, got %.2f dBFS",
|
||||||
|
cfg.LevelDBFS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
amplitude := math.Pow(10, cfg.LevelDBFS/20)
|
||||||
|
return &SineGenerator{
|
||||||
|
channels: cfg.Channels(),
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
baseFrequency: baseFrequency,
|
||||||
|
amplitude: float32(amplitude),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *SineGenerator) Generate(
|
||||||
|
channel uint,
|
||||||
|
firstSample uint64,
|
||||||
|
fragments ...[]byte,
|
||||||
|
) error {
|
||||||
|
if channel >= g.channels {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"audio channel %d is out of range [0, %d)",
|
||||||
|
channel,
|
||||||
|
g.channels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
frequency := float64(channel+1) * g.baseFrequency
|
||||||
|
sampleIndex := firstSample
|
||||||
|
|
||||||
|
for fragmentIndex, fragment := range fragments {
|
||||||
|
if len(fragment)%4 != 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"audio fragment %d has %d bytes; float32 data requires a multiple of 4",
|
||||||
|
fragmentIndex,
|
||||||
|
len(fragment),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fragment := range fragments {
|
||||||
|
for offset := 0; offset < len(fragment); offset += 4 {
|
||||||
|
phase := 2 * math.Pi *
|
||||||
|
frequency *
|
||||||
|
float64(sampleIndex) /
|
||||||
|
g.sampleRate
|
||||||
|
|
||||||
|
sample := g.amplitude * float32(math.Sin(phase))
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint32(
|
||||||
|
fragment[offset:offset+4],
|
||||||
|
math.Float32bits(sample),
|
||||||
|
)
|
||||||
|
sampleIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||||
|
|
||||||
|
func testConfig(t *testing.T, channels uint, rate flowdef.Rational, level float64) Config {
|
||||||
|
t.Helper()
|
||||||
|
definition, err := flowdef.NewFloat32Audio(testAudioID, channels, rate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
return Config{Definition: definition, LevelDBFS: level}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSample(data []byte, index int) float32 {
|
||||||
|
return math.Float32frombits(binary.LittleEndian.Uint32(data[index*4:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSampleNear(t *testing.T, got, want float32) {
|
||||||
|
t.Helper()
|
||||||
|
if math.Abs(float64(got-want)) > 1e-6 {
|
||||||
|
t.Fatalf("sample = %.8f, want %.8f", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSineGeneratorValidation(t *testing.T) {
|
||||||
|
valid := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
baseHz float64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "valid", cfg: valid, baseHz: 1000},
|
||||||
|
{name: "zero channels", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000, Denominator: 1}}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "zero numerator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Denominator: 1}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "zero denominator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "zero base", cfg: valid, baseHz: 0, wantErr: true},
|
||||||
|
{name: "NaN base", cfg: valid, baseHz: math.NaN(), wantErr: true},
|
||||||
|
{name: "NaN level", cfg: Config{Definition: valid.Definition, LevelDBFS: math.NaN()}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "above full scale", cfg: Config{Definition: valid.Definition, LevelDBFS: 1}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "too quiet", cfg: Config{Definition: valid.Definition, LevelDBFS: -101}, baseHz: 1000, wantErr: true},
|
||||||
|
{name: "Nyquist", cfg: testConfig(t, 24, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS), baseHz: 1000, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := NewSineGenerator(tc.cfg, tc.baseHz)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSineGeneratorPreservesRationalSampleRate(t *testing.T) {
|
||||||
|
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1001}, LevelEBUDBFS)
|
||||||
|
gen, err := NewSineGenerator(cfg, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSineGenerator: %v", err)
|
||||||
|
}
|
||||||
|
want := 48000.0 / 1001.0
|
||||||
|
if math.Abs(gen.sampleRate-want) > 1e-12 {
|
||||||
|
t.Fatalf("sample rate = %.12f, want %.12f", gen.sampleRate, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSineGeneratorKnownSamplesAndChannels(t *testing.T) {
|
||||||
|
cfg := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelSMPTEDBFS)
|
||||||
|
gen, err := NewSineGenerator(cfg, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSineGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
channel0 := make([]byte, 37*4)
|
||||||
|
if err := gen.Generate(0, 0, channel0); err != nil {
|
||||||
|
t.Fatalf("Generate channel 0: %v", err)
|
||||||
|
}
|
||||||
|
requireSampleNear(t, decodeSample(channel0, 0), 0)
|
||||||
|
requireSampleNear(t, decodeSample(channel0, 12), 0.1)
|
||||||
|
requireSampleNear(t, decodeSample(channel0, 24), 0)
|
||||||
|
requireSampleNear(t, decodeSample(channel0, 36), -0.1)
|
||||||
|
|
||||||
|
channel1 := make([]byte, 7*4)
|
||||||
|
if err := gen.Generate(1, 0, channel1); err != nil {
|
||||||
|
t.Fatalf("Generate channel 1: %v", err)
|
||||||
|
}
|
||||||
|
requireSampleNear(t, decodeSample(channel1, 6), 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSineGeneratorUsesConfiguredBaseFrequency(t *testing.T) {
|
||||||
|
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||||
|
gen, err := NewSineGenerator(cfg, 2000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSineGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, 7*4)
|
||||||
|
if err := gen.Generate(0, 0, data); err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
wantPeak := float32(math.Pow(10, LevelEBUDBFS/20))
|
||||||
|
requireSampleNear(t, decodeSample(data, 6), wantPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSineGeneratorFragmentContinuity(t *testing.T) {
|
||||||
|
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||||
|
gen, err := NewSineGenerator(cfg, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSineGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
whole := make([]byte, 40*4)
|
||||||
|
if err := gen.Generate(0, 100, whole); err != nil {
|
||||||
|
t.Fatalf("Generate whole: %v", err)
|
||||||
|
}
|
||||||
|
first := make([]byte, 13*4)
|
||||||
|
second := make([]byte, 27*4)
|
||||||
|
if err := gen.Generate(0, 100, first, second); err != nil {
|
||||||
|
t.Fatalf("Generate fragments: %v", err)
|
||||||
|
}
|
||||||
|
joined := append(append([]byte(nil), first...), second...)
|
||||||
|
if !bytes.Equal(joined, whole) {
|
||||||
|
t.Fatal("fragmented output differs from contiguous output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSineGeneratorRejectsInvalidInputWithoutWriting(t *testing.T) {
|
||||||
|
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||||
|
gen, err := NewSineGenerator(cfg, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSineGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := bytes.Repeat([]byte{0xAA}, 8)
|
||||||
|
before := append([]byte(nil), first...)
|
||||||
|
if err := gen.Generate(0, 0, first, make([]byte, 3)); err == nil {
|
||||||
|
t.Fatal("Generate accepted a misaligned fragment")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(first, before) {
|
||||||
|
t.Fatal("Generate modified data before reporting an invalid fragment")
|
||||||
|
}
|
||||||
|
if err := gen.Generate(1, 0, make([]byte, 4)); err == nil {
|
||||||
|
t.Fatal("Generate accepted an out-of-range channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(
|
||||||
|
ctx context.Context,
|
||||||
|
inst *mxl.Instance,
|
||||||
|
cfg Config,
|
||||||
|
) (runErr error) {
|
||||||
|
flowJSON, err := json.Marshal(cfg.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writer, isCreated, err := inst.NewWriter(string(flowJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create audio writer: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
runErr = errors.Join(
|
||||||
|
runErr,
|
||||||
|
fmt.Errorf("close audio writer: %w", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !isCreated {
|
||||||
|
log.Printf("reusing existing audio flow: %s", cfg.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
writerCfg := writer.Config()
|
||||||
|
if writerCfg.Common.Format != mxl.FormatAudio {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"audio writer has format %s, want audio",
|
||||||
|
writerCfg.Common.Format,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if writerCfg.Continuous.ChannelCount != uint32(cfg.Channels()) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"audio writer has %d channels, configured generator expects %d",
|
||||||
|
writerCfg.Continuous.ChannelCount,
|
||||||
|
cfg.Channels(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := writerCfg.Common.GrainRate
|
||||||
|
if rate != cfg.Rate() {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"audio writer has sample rate %d/%d, configured generator expects %d/%d",
|
||||||
|
rate.Num,
|
||||||
|
rate.Den,
|
||||||
|
cfg.Rate().Num,
|
||||||
|
cfg.Rate().Den,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFrequency = 1000.0
|
||||||
|
|
||||||
|
gen, err := NewSineGenerator(cfg, baseFrequency)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize audio generator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := batchSize(rate)
|
||||||
|
maxBatch, err := writer.GetMaxWriteLengthSamples()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get maximum audio write length: %w", err)
|
||||||
|
}
|
||||||
|
if maxBatch == 0 {
|
||||||
|
return fmt.Errorf("audio writer reported a maximum write length of zero samples")
|
||||||
|
}
|
||||||
|
if batch > maxBatch {
|
||||||
|
batch = maxBatch
|
||||||
|
}
|
||||||
|
index := mxl.CurrentIndex(rate)
|
||||||
|
if index < batch-1 {
|
||||||
|
return fmt.Errorf("current audio index %d is too small for batch size %d", index, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"writing audio flow sampleRate=%d/%d channels=%d batch=%d starting at idx=%d",
|
||||||
|
rate.Num,
|
||||||
|
rate.Den,
|
||||||
|
cfg.Channels(),
|
||||||
|
batch,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
|
||||||
|
var samplesWritten uint64
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("stopping audio after %d samples", samplesWritten)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := writer.OpenSamples(index, int(batch))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"open %d audio samples at index %d: %w",
|
||||||
|
batch,
|
||||||
|
index,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstSample := index - batch + 1
|
||||||
|
for channel := uint64(0); channel < access.ChannelCount; channel++ {
|
||||||
|
first, second, err := access.ChannelFragments(channel)
|
||||||
|
if err != nil {
|
||||||
|
return cancelSamples(
|
||||||
|
access,
|
||||||
|
fmt.Errorf(
|
||||||
|
"get fragments for audio channel %d at index %d: %w",
|
||||||
|
channel,
|
||||||
|
index,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gen.Generate(
|
||||||
|
uint(channel),
|
||||||
|
firstSample,
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
); err != nil {
|
||||||
|
return cancelSamples(
|
||||||
|
access, fmt.Errorf(
|
||||||
|
"generate audio channel %d at index %d: %w",
|
||||||
|
channel,
|
||||||
|
index,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := access.Commit(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"commit %d audio samples at index %d: %w",
|
||||||
|
batch,
|
||||||
|
index,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
samplesWritten += batch
|
||||||
|
index += batch
|
||||||
|
mxl.SleepNs(mxl.NsUntilIndex(index, rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSamples(
|
||||||
|
access *mxl.SamplesWriteAccess,
|
||||||
|
cause error,
|
||||||
|
) error {
|
||||||
|
if err := access.Cancel(); err != nil {
|
||||||
|
return errors.Join(
|
||||||
|
cause,
|
||||||
|
fmt.Errorf("cancel audio samples: %w", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchSize(rate mxl.Rational) uint64 {
|
||||||
|
if rate.Num <= 0 || rate.Den <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := rate.Num / (100 * rate.Den)
|
||||||
|
if samples < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return uint64(samples)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBatchSize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rate mxl.Rational
|
||||||
|
want uint64
|
||||||
|
}{
|
||||||
|
{name: "44.1 kHz", rate: mxl.Rational{Num: 44100, Den: 1}, want: 441},
|
||||||
|
{name: "48 kHz", rate: mxl.Rational{Num: 48000, Den: 1}, want: 480},
|
||||||
|
{name: "96 kHz", rate: mxl.Rational{Num: 96000, Den: 1}, want: 960},
|
||||||
|
{name: "192 kHz", rate: mxl.Rational{Num: 192000, Den: 1}, want: 1920},
|
||||||
|
{name: "minimum", rate: mxl.Rational{Num: 1, Den: 1}, want: 1},
|
||||||
|
{name: "zero numerator", rate: mxl.Rational{Num: 0, Den: 1}, want: 1},
|
||||||
|
{name: "zero denominator", rate: mxl.Rational{Num: 48000, Den: 0}, want: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := batchSize(tc.rate); got != tc.want {
|
||||||
|
t.Fatalf("batchSize(%d/%d) = %d, want %d", tc.rate.Num, tc.rate.Den, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,239 @@
|
|||||||
|
// 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"
|
||||||
|
MediaTypeV210A = "video/v210a"
|
||||||
|
MediaTypeFloat32 = "audio/float32"
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rational) UnmarshalJSON(data []byte) error {
|
||||||
|
var value struct {
|
||||||
|
Numerator uint `json:"numerator"`
|
||||||
|
Denominator *uint `json:"denominator"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Numerator = value.Numerator
|
||||||
|
r.Denominator = 1
|
||||||
|
if value.Denominator != nil {
|
||||||
|
r.Denominator = *value.Denominator
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newVideo(
|
||||||
|
id string,
|
||||||
|
width, height uint,
|
||||||
|
rate Rational,
|
||||||
|
mediaType string,
|
||||||
|
) (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: mediaType,
|
||||||
|
},
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if v.ColorSpace != ColorSpaceBT709 {
|
||||||
|
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
|
||||||
|
}
|
||||||
|
if v.FrameWidth == 0 || v.FrameWidth%2 != 0 {
|
||||||
|
return fmt.Errorf("frame_width must be greater than zero and even for 4:2:2 video, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFloat32Audio(
|
||||||
|
id string,
|
||||||
|
channels uint,
|
||||||
|
rate Rational,
|
||||||
|
) (Audio, error) {
|
||||||
|
definition := Audio{
|
||||||
|
Common: Common{
|
||||||
|
Description: "go-mxl-pattern-gen generated audio",
|
||||||
|
ID: id,
|
||||||
|
Tags: map[string][]string{
|
||||||
|
"urn:x-nmos:tag:grouphint/v1.0": {
|
||||||
|
"go-mxl-pattern-gen:Audio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Format: FormatAudio,
|
||||||
|
Label: "go-mxl-pattern-gen generated audio",
|
||||||
|
Parents: []string{},
|
||||||
|
MediaType: MediaTypeFloat32,
|
||||||
|
},
|
||||||
|
SampleRate: rate,
|
||||||
|
ChannelCount: channels,
|
||||||
|
BitDepth: 32,
|
||||||
|
}
|
||||||
|
if err := definition.Validate(); err != nil {
|
||||||
|
return Audio{}, err
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Audio) Validate() error {
|
||||||
|
if err := uuid.Validate(a.ID); err != nil {
|
||||||
|
return fmt.Errorf("invalid id %q: %w", a.ID, err)
|
||||||
|
}
|
||||||
|
if a.Format != FormatAudio {
|
||||||
|
return fmt.Errorf("format must be %q, got %q", FormatAudio, a.Format)
|
||||||
|
}
|
||||||
|
if a.MediaType != MediaTypeFloat32 {
|
||||||
|
return fmt.Errorf("media_type must be %q, got %q", MediaTypeFloat32, a.MediaType)
|
||||||
|
}
|
||||||
|
if a.ChannelCount == 0 {
|
||||||
|
return fmt.Errorf("channel_count must be greater than zero")
|
||||||
|
}
|
||||||
|
if a.SampleRate.Numerator == 0 || a.SampleRate.Denominator == 0 {
|
||||||
|
return fmt.Errorf("sample_rate numerator and denominator must be greater than zero, got %d/%d",
|
||||||
|
a.SampleRate.Numerator, a.SampleRate.Denominator)
|
||||||
|
}
|
||||||
|
if a.BitDepth != 32 {
|
||||||
|
return fmt.Errorf("bit_depth must be 32, got %d", a.BitDepth)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseFloat32Audio(data []byte) (Audio, error) {
|
||||||
|
var definition Audio
|
||||||
|
if err := json.Unmarshal(data, &definition); err != nil {
|
||||||
|
return Audio{}, fmt.Errorf("decode audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
if err := definition.Validate(); err != nil {
|
||||||
|
return Audio{}, fmt.Errorf("invalid audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package flowdef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
||||||
|
const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||||
|
|
||||||
|
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 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") {
|
||||||
|
t.Fatalf("error = %v, want even-width error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVideo(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 := ParseVideo(data)
|
||||||
|
if err != nil {
|
||||||
|
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 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 := 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 {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if definition.ID != testAudioID {
|
||||||
|
t.Fatalf("ID = %q, want %q", definition.ID, testAudioID)
|
||||||
|
}
|
||||||
|
if definition.Format != FormatAudio || definition.MediaType != MediaTypeFloat32 {
|
||||||
|
t.Fatalf("format/media type = %q/%q", definition.Format, definition.MediaType)
|
||||||
|
}
|
||||||
|
if definition.ChannelCount != 2 || definition.BitDepth != 32 {
|
||||||
|
t.Fatalf("channels/bit depth = %d/%d, want 2/32", definition.ChannelCount, definition.BitDepth)
|
||||||
|
}
|
||||||
|
if definition.SampleRate != (Rational{Numerator: 48000, Denominator: 1}) {
|
||||||
|
t.Fatalf("sample rate = %+v, want 48000/1", definition.SampleRate)
|
||||||
|
}
|
||||||
|
if definition.Parents == nil {
|
||||||
|
t.Fatal("Parents is nil; want an empty JSON array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFloat32AudioRejectsZeroChannels(t *testing.T) {
|
||||||
|
_, err := NewFloat32Audio(testAudioID, 0, Rational{Numerator: 48000, Denominator: 1})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "channel_count") {
|
||||||
|
t.Fatalf("error = %v, want channel_count error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFloat32AudioRejectsInvalidRate(t *testing.T) {
|
||||||
|
_, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 0})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||||
|
t.Fatalf("error = %v, want sample_rate error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32Audio(t *testing.T) {
|
||||||
|
want, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != want.ID || got.ChannelCount != want.ChannelCount || got.SampleRate != want.SampleRate {
|
||||||
|
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioDefaultsDenominator(t *testing.T) {
|
||||||
|
data := []byte(`{
|
||||||
|
"description":"test audio",
|
||||||
|
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
"tags":{},
|
||||||
|
"format":"urn:x-nmos:format:audio",
|
||||||
|
"label":"test audio",
|
||||||
|
"parents":[],
|
||||||
|
"media_type":"audio/float32",
|
||||||
|
"sample_rate":{"numerator":48000},
|
||||||
|
"channel_count":2,
|
||||||
|
"bit_depth":32
|
||||||
|
}`)
|
||||||
|
|
||||||
|
definition, err := ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if definition.SampleRate.Denominator != 1 {
|
||||||
|
t.Fatalf("denominator = %d, want implicit 1", definition.SampleRate.Denominator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioRejectsExplicitZeroDenominator(t *testing.T) {
|
||||||
|
data := []byte(`{
|
||||||
|
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
"format":"urn:x-nmos:format:audio",
|
||||||
|
"media_type":"audio/float32",
|
||||||
|
"sample_rate":{"numerator":48000,"denominator":0},
|
||||||
|
"channel_count":2,
|
||||||
|
"bit_depth":32
|
||||||
|
}`)
|
||||||
|
|
||||||
|
_, err := ParseFloat32Audio(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||||
|
t.Fatalf("error = %v, want sample_rate error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioRejectsVideo(t *testing.T) {
|
||||||
|
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewV210Video: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseFloat32Audio(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||||
|
t.Fatalf("error = %v, want audio format error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YCbCr10 struct {
|
||||||
|
Y uint32
|
||||||
|
Cb uint32
|
||||||
|
Cr uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
base []byte
|
||||||
|
patch FrameRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCPUGenerator(
|
||||||
|
width, height uint,
|
||||||
|
baseRenderer FrameRenderer,
|
||||||
|
patch FrameRenderer,
|
||||||
|
) (*CPUGenerator, error) {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return nil, fmt.Errorf("cpu: width and height must be greater than zero, got %dx%d", width, height)
|
||||||
|
}
|
||||||
|
if width%2 != 0 {
|
||||||
|
return nil, fmt.Errorf("cpu: width must be even for 4:2:2 video, got %d", width)
|
||||||
|
}
|
||||||
|
if baseRenderer == nil {
|
||||||
|
return nil, fmt.Errorf("cpu: base renderer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
g := &CPUGenerator{
|
||||||
|
width: int(width),
|
||||||
|
height: int(height),
|
||||||
|
base: make([]byte, V210FrameSize(int(width), int(height))),
|
||||||
|
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(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(dst),
|
||||||
|
len(g.base),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(dst, g.base)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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)))
|
||||||
|
stride := V210LineSize(width)
|
||||||
|
|
||||||
|
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*stride + 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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 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},
|
||||||
|
{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 "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":
|
||||||
|
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 {
|
||||||
|
stride := V210LineSize(width)
|
||||||
|
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*stride + x/6*16
|
||||||
|
packV210Block(dst[offset:offset+16], pixels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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) {
|
||||||
|
renderer := fillFrame(0)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
width uint
|
||||||
|
height uint
|
||||||
|
renderer FrameRenderer
|
||||||
|
wantErrSub string
|
||||||
|
}{
|
||||||
|
{name: "zero width", height: 1, renderer: renderer, wantErrSub: "greater than zero"},
|
||||||
|
{name: "zero height", width: 6, renderer: renderer, wantErrSub: "greater than zero"},
|
||||||
|
{name: "odd width", width: 7, height: 1, renderer: renderer, wantErrSub: "even"},
|
||||||
|
{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.renderer, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||||
|
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, V210FrameSize(12, 1))
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestPackV210BlockMasksComponents(t *testing.T) {
|
||||||
|
pixel := YCbCr10{Y: 0xC01, Cb: 0xC02, Cr: 0xC03}
|
||||||
|
frame := make([]byte, 16)
|
||||||
|
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 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, V210FrameSize(6, 1)-1), 0); err == nil || !strings.Contains(err.Error(), "too small") {
|
||||||
|
t.Fatalf("small destination error = %v", err)
|
||||||
|
}
|
||||||
|
if err := g.GenerateFrame(make([]byte, V210FrameSize(6, 1)), 4); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("patch error = %v, want wrapped %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUGeneratorUsesPaddedV210Rows(t *testing.T) {
|
||||||
|
const width, height = 100, 2
|
||||||
|
g, err := NewCPUPatternGenerator(width, height, "gray-ramp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||||
|
}
|
||||||
|
frame := make([]byte, V210FrameSize(width, height))
|
||||||
|
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||||
|
t.Fatalf("GenerateFrame: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
gotY, gotCb, gotCr := sampleV210(frame, width, 0, y)
|
||||||
|
if gotY != 64 || gotCb != 512 || gotCr != 512 {
|
||||||
|
t.Fatalf("row %d first pixel = %d/%d/%d, want 64/512/512", y, gotY, gotCb, gotCr)
|
||||||
|
}
|
||||||
|
padding := frame[y*V210LineSize(width)+V210ActiveLineSize(width) : (y+1)*V210LineSize(width)]
|
||||||
|
for i, b := range padding {
|
||||||
|
if b != 0 {
|
||||||
|
t.Fatalf("row %d padding byte %d = %#x, want 0", y, i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-10
@@ -33,9 +33,19 @@ func LoadFace(path string, size float64) (font.Face, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("text: %w", err)
|
return nil, fmt.Errorf("text: %w", err)
|
||||||
}
|
}
|
||||||
|
face, err := NewFace(data, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("text: load %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return face, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFace parses TTF/OTF data and builds a render-ready face at the given
|
||||||
|
// pixel size (DPI 72, full hinting for crisp video text).
|
||||||
|
func NewFace(data []byte, size float64) (font.Face, error) {
|
||||||
f, err := opentype.Parse(data)
|
f, err := opentype.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("text: parse %s: %w", path, err)
|
return nil, fmt.Errorf("parse font: %w", err)
|
||||||
}
|
}
|
||||||
face, err := opentype.NewFace(f, &opentype.FaceOptions{
|
face, err := opentype.NewFace(f, &opentype.FaceOptions{
|
||||||
Size: size,
|
Size: size,
|
||||||
@@ -43,7 +53,7 @@ func LoadFace(path string, size float64) (font.Face, error) {
|
|||||||
Hinting: font.HintingFull,
|
Hinting: font.HintingFull,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("text: face %s: %w", path, err)
|
return nil, fmt.Errorf("create font face: %w", err)
|
||||||
}
|
}
|
||||||
return face, nil
|
return face, nil
|
||||||
}
|
}
|
||||||
@@ -87,9 +97,8 @@ func NewTextOverlay(text string, frameW, frameH, posX, posY int, textPos string,
|
|||||||
return nil, fmt.Errorf("text: box %dpx wider than frame %dpx", w, frameW)
|
return nil, fmt.Errorf("text: box %dpx wider than frame %dpx", w, frameW)
|
||||||
}
|
}
|
||||||
h := 2*padY + textH
|
h := 2*padY + textH
|
||||||
const topMargin = 48
|
if h > frameH {
|
||||||
if topMargin+h > frameH {
|
return nil, fmt.Errorf("text: box %dpx higher than frame %dpx", h, frameH)
|
||||||
return nil, fmt.Errorf("text: box %dpx does not fit frame height %dpx", topMargin+h, frameH)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if textPos != "" {
|
if textPos != "" {
|
||||||
@@ -120,6 +129,18 @@ func NewTextOverlay(text string, frameW, frameH, posX, posY int, textPos string,
|
|||||||
posY = frameH - h
|
posY = frameH - h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if posX < 0 || posY < 0 {
|
||||||
|
return nil, fmt.Errorf("text: position (%d, %d) must not be negative", posX, posY)
|
||||||
|
}
|
||||||
|
if posX%6 != 0 {
|
||||||
|
return nil, fmt.Errorf("text: x position %d must be divisible by 6 for v210", posX)
|
||||||
|
}
|
||||||
|
if posX+w > frameW || posY+h > frameH {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"text: box at (%d, %d), size %dx%d, does not fit frame %dx%d",
|
||||||
|
posX, posY, w, h, frameW, frameH,
|
||||||
|
)
|
||||||
|
}
|
||||||
o := &TextOverlay{
|
o := &TextOverlay{
|
||||||
frameW: frameW,
|
frameW: frameW,
|
||||||
x: posX,
|
x: posX,
|
||||||
@@ -168,15 +189,16 @@ func (o *TextOverlay) pack() {
|
|||||||
// ApplyV210 stamps the pre-packed text tile over a packed v210 frame.
|
// ApplyV210 stamps the pre-packed text tile over a packed v210 frame.
|
||||||
// The tile occupies whole 16-byte blocks, so this is a row-wise copy.
|
// The tile occupies whole 16-byte blocks, so this is a row-wise copy.
|
||||||
func (o *TextOverlay) ApplyV210(dest []byte) error {
|
func (o *TextOverlay) ApplyV210(dest []byte) error {
|
||||||
lastPixel := (o.y+o.h-1)*o.frameW + o.x + o.w - 1
|
frameStride := V210LineSize(o.frameW)
|
||||||
if need := (lastPixel/6 + 1) * 16; len(dest) < need {
|
tileStride := (o.w / 6) * 16
|
||||||
|
need := (o.y+o.h-1)*frameStride + o.x/6*16 + tileStride
|
||||||
|
if len(dest) < need {
|
||||||
return fmt.Errorf("text: dest %d bytes too small, need %d", len(dest), need)
|
return fmt.Errorf("text: dest %d bytes too small, need %d", len(dest), need)
|
||||||
}
|
}
|
||||||
tileStride := (o.w / 6) * 16
|
|
||||||
for row := 0; row < o.h; row++ {
|
for row := 0; row < o.h; row++ {
|
||||||
frameBlock := ((o.y+row)*o.frameW + o.x) / 6
|
frameOffset := (o.y+row)*frameStride + o.x/6*16
|
||||||
src := o.blocks[row*tileStride : (row+1)*tileStride]
|
src := o.blocks[row*tileStride : (row+1)*tileStride]
|
||||||
copy(dest[frameBlock*16:frameBlock*16+tileStride], src)
|
copy(dest[frameOffset:frameOffset+tileStride], src)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
package generator
|
package generator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"strings"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/assets"
|
||||||
|
|
||||||
"golang.org/x/image/font"
|
"golang.org/x/image/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testFace(t *testing.T, size float64) font.Face {
|
func testFace(t *testing.T, size float64) font.Face {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
face, err := LoadFace(filepath.Join("..", "..", "assets", "fonts",
|
face, err := NewFace(assets.JetBrainsMono, size)
|
||||||
"JetBrainsMonoNLNerdFontMono-Regular.ttf"), size)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadFace: %v", err)
|
t.Fatalf("LoadFace: %v", err)
|
||||||
}
|
}
|
||||||
@@ -32,11 +32,8 @@ func TestNewTextOverlay(t *testing.T) {
|
|||||||
if o.x%6 != 0 {
|
if o.x%6 != 0 {
|
||||||
t.Fatalf("x=%d not a multiple of 6", o.x)
|
t.Fatalf("x=%d not a multiple of 6", o.x)
|
||||||
}
|
}
|
||||||
if center := o.x + o.w/2; center < frameW/2-3 || center > frameW/2+3 {
|
if o.x != 0 || o.y != 0 {
|
||||||
t.Fatalf("box center %d not near frame center %d", center, frameW/2)
|
t.Fatalf("position = (%d,%d), want (0,0)", o.x, o.y)
|
||||||
}
|
|
||||||
if o.y != 48 {
|
|
||||||
t.Fatalf("y=%d, want 48", o.y)
|
|
||||||
}
|
}
|
||||||
if o.blocks == nil || len(o.blocks) != o.h*(o.w/6)*16 {
|
if o.blocks == nil || len(o.blocks) != o.h*(o.w/6)*16 {
|
||||||
t.Fatalf("pre-packed tile has wrong size")
|
t.Fatalf("pre-packed tile has wrong size")
|
||||||
@@ -87,6 +84,78 @@ func TestNewTextOverlay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewTextOverlayPositioning(t *testing.T) {
|
||||||
|
const frameW, frameH = 1920, 1080
|
||||||
|
face := testFace(t, 48)
|
||||||
|
|
||||||
|
t.Run("explicit", func(t *testing.T) {
|
||||||
|
o, err := NewTextOverlay("AB", frameW, frameH, 120, 48, "", face)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTextOverlay: %v", err)
|
||||||
|
}
|
||||||
|
if o.x != 120 || o.y != 48 {
|
||||||
|
t.Fatalf("position = (%d,%d), want (120,48)", o.x, o.y)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
pos string
|
||||||
|
want func(*TextOverlay) bool
|
||||||
|
}{
|
||||||
|
{name: "top center", pos: "tc", want: func(o *TextOverlay) bool {
|
||||||
|
return o.x%6 == 0 && absInt((o.x+o.w/2)-frameW/2) <= 3 && o.y == 0
|
||||||
|
}},
|
||||||
|
{name: "center", pos: "cc", want: func(o *TextOverlay) bool {
|
||||||
|
return o.x%6 == 0 && absInt((o.x+o.w/2)-frameW/2) <= 3 && o.y == (frameH-o.h)/2
|
||||||
|
}},
|
||||||
|
{name: "bottom right", pos: "br", want: func(o *TextOverlay) bool {
|
||||||
|
return o.x+o.w == frameW && o.y+o.h == frameH
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
o, err := NewTextOverlay("AB", frameW, frameH, 0, 0, tc.pos, face)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTextOverlay: %v", err)
|
||||||
|
}
|
||||||
|
if !tc.want(o) {
|
||||||
|
t.Fatalf("unexpected %s position: box=(%d,%d %dx%d)", tc.pos, o.x, o.y, o.w, o.h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTextOverlayRejectsInvalidPosition(t *testing.T) {
|
||||||
|
const frameW, frameH = 1920, 1080
|
||||||
|
face := testFace(t, 48)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
x, y int
|
||||||
|
wantErrSub string
|
||||||
|
}{
|
||||||
|
{name: "unaligned x", x: 7, y: 0, wantErrSub: "divisible by 6"},
|
||||||
|
{name: "negative x", x: -6, y: 0, wantErrSub: "must not be negative"},
|
||||||
|
{name: "right overflow", x: 1902, y: 0, wantErrSub: "does not fit"},
|
||||||
|
{name: "bottom overflow", x: 0, y: 1070, wantErrSub: "does not fit"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := NewTextOverlay("AB", frameW, frameH, tc.x, tc.y, "", face)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||||
|
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func TestTextOverlayApplyV210(t *testing.T) {
|
func TestTextOverlayApplyV210(t *testing.T) {
|
||||||
const frameW, frameH = 1920, 1080
|
const frameW, frameH = 1920, 1080
|
||||||
o, err := NewTextOverlay("MXL", frameW, frameH, 0, 0, "", testFace(t, 48))
|
o, err := NewTextOverlay("MXL", frameW, frameH, 0, 0, "", testFace(t, 48))
|
||||||
@@ -94,7 +163,7 @@ func TestTextOverlayApplyV210(t *testing.T) {
|
|||||||
t.Fatalf("NewTextOverlay: %v", err)
|
t.Fatalf("NewTextOverlay: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
frame := make([]byte, frameW*frameH*8/3)
|
frame := make([]byte, V210FrameSize(frameW, frameH))
|
||||||
for i := range frame {
|
for i := range frame {
|
||||||
frame[i] = 0x5A // marker: untouched regions must survive
|
frame[i] = 0x5A // marker: untouched regions must survive
|
||||||
}
|
}
|
||||||
@@ -102,34 +171,11 @@ func TestTextOverlayApplyV210(t *testing.T) {
|
|||||||
t.Fatalf("ApplyV210: %v", err)
|
t.Fatalf("ApplyV210: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sample := func(x, y int) (yc, cb, cr uint32) {
|
|
||||||
p := y*frameW + x
|
|
||||||
off := (p / 6) * 16
|
|
||||||
w0 := binary.LittleEndian.Uint32(frame[off:])
|
|
||||||
w1 := binary.LittleEndian.Uint32(frame[off+4:])
|
|
||||||
w2 := binary.LittleEndian.Uint32(frame[off+8:])
|
|
||||||
w3 := binary.LittleEndian.Uint32(frame[off+12:])
|
|
||||||
switch p % 6 {
|
|
||||||
case 0:
|
|
||||||
return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
|
||||||
case 1:
|
|
||||||
return w1 & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
|
||||||
case 2:
|
|
||||||
return (w1 >> 20) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
|
||||||
case 3:
|
|
||||||
return (w2 >> 10) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
|
||||||
case 4:
|
|
||||||
return w3 & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
|
||||||
default:
|
|
||||||
return (w3 >> 20) & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every box pixel: Y from coverage, neutral chroma at even columns.
|
// Every box pixel: Y from coverage, neutral chroma at even columns.
|
||||||
for row := 0; row < o.h; row++ {
|
for row := 0; row < o.h; row++ {
|
||||||
for col := 0; col < o.w; col++ {
|
for col := 0; col < o.w; col++ {
|
||||||
wantY := uint32(64 + (int(o.cov[row*o.w+col])*876+127)/255)
|
wantY := uint32(64 + (int(o.cov[row*o.w+col])*876+127)/255)
|
||||||
yc, cb, cr := sample(o.x+col, o.y+row)
|
yc, cb, cr := sampleV210(frame, frameW, o.x+col, o.y+row)
|
||||||
if yc != wantY {
|
if yc != wantY {
|
||||||
t.Fatalf("box pixel (%d,%d): Y=%d, want %d", o.x+col, o.y+row, yc, wantY)
|
t.Fatalf("box pixel (%d,%d): Y=%d, want %d", o.x+col, o.y+row, yc, wantY)
|
||||||
}
|
}
|
||||||
@@ -146,7 +192,7 @@ func TestTextOverlayApplyV210(t *testing.T) {
|
|||||||
{o.x + o.w, o.y + o.h}, // corner
|
{o.x + o.w, o.y + o.h}, // corner
|
||||||
{100, 1000}, // far away
|
{100, 1000}, // far away
|
||||||
} {
|
} {
|
||||||
off := ((p[1]*frameW + p[0]) / 6) * 16
|
off := p[1]*V210LineSize(frameW) + p[0]/6*16
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
if frame[off+i] != 0x5A {
|
if frame[off+i] != 0x5A {
|
||||||
t.Fatalf("block at (%d,%d) modified outside the box", p[0], p[1])
|
t.Fatalf("block at (%d,%d) modified outside the box", p[0], p[1])
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
const v210RowAlignment = 128
|
||||||
|
|
||||||
|
// V210ActiveLineSize returns the number of bytes containing pixel data in one
|
||||||
|
// v210 row, including the final partial six-pixel block when needed.
|
||||||
|
func V210ActiveLineSize(width int) int {
|
||||||
|
return (width + 5) / 6 * 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// V210LineSize returns the MXL v210 row stride. MXL stores every row at a
|
||||||
|
// 128-byte boundary, equivalent to rounding the width up to 48 pixels.
|
||||||
|
func V210LineSize(width int) int {
|
||||||
|
return ((width + 47) / 48) * v210RowAlignment
|
||||||
|
}
|
||||||
|
|
||||||
|
// V210FrameSize returns the complete MXL payload size for a v210 frame.
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestV210Sizes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
width int
|
||||||
|
activeLine int
|
||||||
|
line int
|
||||||
|
}{
|
||||||
|
{width: 1920, activeLine: 5120, line: 5120},
|
||||||
|
{width: 1280, activeLine: 3424, line: 3456},
|
||||||
|
{width: 100, activeLine: 272, line: 384},
|
||||||
|
{width: 54, activeLine: 144, line: 256},
|
||||||
|
{width: 48, activeLine: 128, line: 128},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := V210ActiveLineSize(tc.width); got != tc.activeLine {
|
||||||
|
t.Errorf("V210ActiveLineSize(%d) = %d, want %d", tc.width, got, tc.activeLine)
|
||||||
|
}
|
||||||
|
if got := V210LineSize(tc.width); got != tc.line {
|
||||||
|
t.Errorf("V210LineSize(%d) = %d, want %d", tc.width, got, tc.line)
|
||||||
|
}
|
||||||
|
if got := V210FrameSize(tc.width, 2); got != tc.line*2 {
|
||||||
|
t.Errorf("V210FrameSize(%d, 2) = %d, want %d", tc.width, got, tc.line*2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:])
|
||||||
|
w1 := binary.LittleEndian.Uint32(buf[offset+4:])
|
||||||
|
w2 := binary.LittleEndian.Uint32(buf[offset+8:])
|
||||||
|
w3 := binary.LittleEndian.Uint32(buf[offset+12:])
|
||||||
|
|
||||||
|
switch x % 6 {
|
||||||
|
case 0:
|
||||||
|
return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
||||||
|
case 1:
|
||||||
|
return w1 & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
||||||
|
case 2:
|
||||||
|
return (w1 >> 20) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
||||||
|
case 3:
|
||||||
|
return (w2 >> 10) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
||||||
|
case 4:
|
||||||
|
return w3 & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
||||||
|
default:
|
||||||
|
return (w3 >> 20) & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-17
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gogpu/gputypes"
|
"github.com/gogpu/gputypes"
|
||||||
@@ -39,6 +38,9 @@ type WGPUGenerator struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
blocks int
|
blocks int
|
||||||
|
activeLineSize int
|
||||||
|
lineSize int
|
||||||
|
compactFrameSize uint64
|
||||||
frameSize uint64
|
frameSize uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +49,20 @@ var _ FrameGenerator = (*WGPUGenerator)(nil)
|
|||||||
// WGPUOption customizes NewWGPUGenerator.
|
// WGPUOption customizes NewWGPUGenerator.
|
||||||
type WGPUOption func(*WGPUGenerator)
|
type WGPUOption func(*WGPUGenerator)
|
||||||
|
|
||||||
func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption) (*WGPUGenerator, error) {
|
func NewWGPUGenerator(width, height uint, wgsl string, opts ...WGPUOption) (*WGPUGenerator, error) {
|
||||||
g := &WGPUGenerator{
|
g := &WGPUGenerator{
|
||||||
width: int(width),
|
width: int(width),
|
||||||
height: int(height),
|
height: int(height),
|
||||||
blocks: int(width*height) / 6,
|
blocks: ((int(width) + 5) / 6) * int(height),
|
||||||
|
activeLineSize: V210ActiveLineSize(int(width)),
|
||||||
|
lineSize: V210LineSize(int(width)),
|
||||||
params: make([]byte, 16),
|
params: make([]byte, 16),
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(g)
|
opt(g)
|
||||||
}
|
}
|
||||||
g.frameSize = uint64(g.blocks) * 16
|
g.compactFrameSize = uint64(g.activeLineSize * g.height)
|
||||||
|
g.frameSize = uint64(g.lineSize * g.height)
|
||||||
binary.LittleEndian.PutUint32(g.params[0:], uint32(width))
|
binary.LittleEndian.PutUint32(g.params[0:], uint32(width))
|
||||||
binary.LittleEndian.PutUint32(g.params[4:], uint32(height))
|
binary.LittleEndian.PutUint32(g.params[4:], uint32(height))
|
||||||
|
|
||||||
@@ -75,26 +80,21 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
|
|||||||
}
|
}
|
||||||
g.queue = g.device.Queue()
|
g.queue = g.device.Queue()
|
||||||
|
|
||||||
wgsl, err := os.ReadFile(kernelPath)
|
|
||||||
if err != nil {
|
|
||||||
g.Close()
|
|
||||||
return nil, fmt.Errorf("wgpu: read kernel: %w", err)
|
|
||||||
}
|
|
||||||
if g.shader, err = g.device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{
|
if g.shader, err = g.device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{
|
||||||
Label: "v210-shader", WGSL: string(wgsl),
|
Label: "v210-shader", WGSL: wgsl,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
g.Close()
|
g.Close()
|
||||||
return nil, fmt.Errorf("wgpu: shader: %w", err)
|
return nil, fmt.Errorf("wgpu: shader: %w", err)
|
||||||
}
|
}
|
||||||
if g.out, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
|
if g.out, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
|
||||||
Label: "v210-out", Size: g.frameSize,
|
Label: "v210-out", Size: g.compactFrameSize,
|
||||||
Usage: wgpu.BufferUsageStorage | wgpu.BufferUsageCopySrc,
|
Usage: wgpu.BufferUsageStorage | wgpu.BufferUsageCopySrc,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
g.Close()
|
g.Close()
|
||||||
return nil, fmt.Errorf("wgpu: out buffer: %w", err)
|
return nil, fmt.Errorf("wgpu: out buffer: %w", err)
|
||||||
}
|
}
|
||||||
if g.host, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
|
if g.host, err = g.device.CreateBuffer(&wgpu.BufferDescriptor{
|
||||||
Label: "v210-host", Size: g.frameSize,
|
Label: "v210-host", Size: g.compactFrameSize,
|
||||||
Usage: wgpu.BufferUsageCopyDst | wgpu.BufferUsageMapRead,
|
Usage: wgpu.BufferUsageCopyDst | wgpu.BufferUsageMapRead,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
g.Close()
|
g.Close()
|
||||||
@@ -124,7 +124,7 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
|
|||||||
if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{
|
if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{
|
||||||
Label: "v210-bg", Layout: g.bgl,
|
Label: "v210-bg", Layout: g.bgl,
|
||||||
Entries: []wgpu.BindGroupEntry{
|
Entries: []wgpu.BindGroupEntry{
|
||||||
{Binding: 0, Buffer: g.out, Size: g.frameSize},
|
{Binding: 0, Buffer: g.out, Size: g.compactFrameSize},
|
||||||
{Binding: 1, Buffer: g.uniform, Size: uint64(len(g.params))},
|
{Binding: 1, Buffer: g.uniform, Size: uint64(len(g.params))},
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -170,7 +170,7 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
|
|||||||
if err := pass.End(); err != nil {
|
if err := pass.End(); err != nil {
|
||||||
return fmt.Errorf("wgpu: end pass: %w", err)
|
return fmt.Errorf("wgpu: end pass: %w", err)
|
||||||
}
|
}
|
||||||
encoder.CopyBufferToBuffer(g.out, 0, g.host, 0, g.frameSize)
|
encoder.CopyBufferToBuffer(g.out, 0, g.host, 0, g.compactFrameSize)
|
||||||
cmd, err := encoder.Finish()
|
cmd, err := encoder.Finish()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wgpu: finish: %w", err)
|
return fmt.Errorf("wgpu: finish: %w", err)
|
||||||
@@ -181,15 +181,25 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := g.host.Map(ctx, wgpu.MapModeRead, 0, g.frameSize); err != nil {
|
if err := g.host.Map(ctx, wgpu.MapModeRead, 0, g.compactFrameSize); err != nil {
|
||||||
return fmt.Errorf("wgpu: map: %w", err)
|
return fmt.Errorf("wgpu: map: %w", err)
|
||||||
}
|
}
|
||||||
rng, err := g.host.MappedRange(0, g.frameSize)
|
rng, err := g.host.MappedRange(0, g.compactFrameSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = g.host.Unmap()
|
_ = g.host.Unmap()
|
||||||
return fmt.Errorf("wgpu: mapped range: %w", err)
|
return fmt.Errorf("wgpu: mapped range: %w", err)
|
||||||
}
|
}
|
||||||
copy(dest, rng.Bytes())
|
mapped := rng.Bytes()
|
||||||
|
if g.activeLineSize == g.lineSize {
|
||||||
|
copy(dest[:g.frameSize], mapped)
|
||||||
|
} else {
|
||||||
|
for y := 0; y < g.height; y++ {
|
||||||
|
src := mapped[y*g.activeLineSize : (y+1)*g.activeLineSize]
|
||||||
|
dst := dest[y*g.lineSize : (y+1)*g.lineSize]
|
||||||
|
copy(dst, src)
|
||||||
|
clear(dst[g.activeLineSize:])
|
||||||
|
}
|
||||||
|
}
|
||||||
return g.host.Unmap()
|
return g.host.Unmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,24 @@
|
|||||||
package generator
|
package generator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/kernels"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWGPUMoveSquare(t *testing.T) {
|
func TestWGPUMoveSquare(t *testing.T) {
|
||||||
const width, height = 1920, 1080
|
const width, height = 1920, 1080
|
||||||
g, err := NewWGPUGenerator(width, height, filepath.Join("..", "..", "kernels", "dynamic", "smpteBars.wgsl"))
|
shader, err := kernels.Read("dynamic/smpteBars.wgsl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read shader: %v", err)
|
||||||
|
}
|
||||||
|
g, err := NewWGPUGenerator(width, height, string(shader))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init: %v", err)
|
t.Fatalf("init: %v", err)
|
||||||
}
|
}
|
||||||
defer g.Close()
|
defer g.Close()
|
||||||
|
|
||||||
buf := make([]byte, width*height*8/3)
|
buf := make([]byte, width*height*8/3)
|
||||||
sample := func(x, y int) (yc, cb, cr uint32) {
|
|
||||||
p := y*width + x
|
|
||||||
off := (p / 6) * 16
|
|
||||||
w0 := binary.LittleEndian.Uint32(buf[off:])
|
|
||||||
w1 := binary.LittleEndian.Uint32(buf[off+4:])
|
|
||||||
w2 := binary.LittleEndian.Uint32(buf[off+8:])
|
|
||||||
w3 := binary.LittleEndian.Uint32(buf[off+12:])
|
|
||||||
switch p % 6 {
|
|
||||||
case 0:
|
|
||||||
return (w0 >> 10) & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
|
||||||
case 1:
|
|
||||||
return w1 & 0x3FF, w0 & 0x3FF, (w0 >> 20) & 0x3FF
|
|
||||||
case 2:
|
|
||||||
return (w1 >> 20) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
|
||||||
case 3:
|
|
||||||
return (w2 >> 10) & 0x3FF, (w1 >> 10) & 0x3FF, w2 & 0x3FF
|
|
||||||
case 4:
|
|
||||||
return w3 & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
|
||||||
default:
|
|
||||||
return (w3 >> 20) & 0x3FF, (w2 >> 20) & 0x3FF, (w3 >> 10) & 0x3FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// square center x=960 sits on the green bar (534/253/207):
|
// square center x=960 sits on the green bar (534/253/207):
|
||||||
// inverted -> Y=1004-534=470, Cb=1024-253=771, Cr=1024-207=817
|
// inverted -> Y=1004-534=470, Cb=1024-253=771, Cr=1024-207=817
|
||||||
@@ -47,28 +29,28 @@ func TestWGPUMoveSquare(t *testing.T) {
|
|||||||
if err := g.GenerateFrame(buf, 0); err != nil {
|
if err := g.GenerateFrame(buf, 0); err != nil {
|
||||||
t.Fatalf("tick 0: %v", err)
|
t.Fatalf("tick 0: %v", err)
|
||||||
}
|
}
|
||||||
if y, cb, cr := sample(960, 540); y != invY || cb != invCb || cr != invCr {
|
if y, cb, cr := sampleV210(buf, width, 960, 540); y != invY || cb != invCb || cr != invCr {
|
||||||
t.Fatalf("tick 0 center: got %d/%d/%d, want inverted green 470/771/817", y, cb, cr)
|
t.Fatalf("tick 0 center: got %d/%d/%d, want inverted green 470/771/817", y, cb, cr)
|
||||||
}
|
}
|
||||||
if y, _, _ := sample(860, 540); y != greenY {
|
if y, _, _ := sampleV210(buf, width, 860, 540); y != greenY {
|
||||||
t.Fatalf("tick 0 left of square: Y=%d, want green %d", y, greenY)
|
t.Fatalf("tick 0 left of square: Y=%d, want green %d", y, greenY)
|
||||||
}
|
}
|
||||||
|
|
||||||
// amplitude = centerX - half = 885: tick 79 (sin~1) puts the square at
|
// amplitude = centerX - half = 885: tick 79 (sin~1) puts the square at
|
||||||
// the far right, x in [1770,1920), over the blue bar (111/848/481):
|
// the far right, x in [1770,1920), over the gray side panel
|
||||||
// inverted -> Y=1004-111=893, Cb=1024-848=176, Cr=1024-481=543
|
// (414/512/512): inverted -> 590/512/512.
|
||||||
const invBlueY, invBlueCb, invBlueCr = 893, 176, 543
|
const invFlankY, invFlankCb, invFlankCr = 590, 512, 512
|
||||||
if err := g.GenerateFrame(buf, 79); err != nil {
|
if err := g.GenerateFrame(buf, 79); err != nil {
|
||||||
t.Fatalf("tick 79: %v", err)
|
t.Fatalf("tick 79: %v", err)
|
||||||
}
|
}
|
||||||
if y, cb, cr := sample(1840, 540); y != invBlueY || cb != invBlueCb || cr != invBlueCr {
|
if y, cb, cr := sampleV210(buf, width, 1840, 540); y != invFlankY || cb != invFlankCb || cr != invFlankCr {
|
||||||
t.Fatalf("tick 79 shifted center: got %d/%d/%d, want inverted blue 893/176/543", y, cb, cr)
|
t.Fatalf("tick 79 shifted center: got %d/%d/%d, want inverted gray 590/512/512", y, cb, cr)
|
||||||
}
|
}
|
||||||
if y, _, _ := sample(960, 540); y != greenY {
|
if y, _, _ := sampleV210(buf, width, 960, 540); y != greenY {
|
||||||
t.Fatalf("tick 79 old center: Y=%d, want green %d (square moved away)", y, greenY)
|
t.Fatalf("tick 79 old center: Y=%d, want green %d (square moved away)", y, greenY)
|
||||||
}
|
}
|
||||||
// bars untouched far from the square
|
// bars untouched far from the square
|
||||||
if y, _, _ := sample(100, 100); y != 721 {
|
if y, _, _ := sampleV210(buf, width, 100, 100); y != 414 {
|
||||||
t.Fatalf("tick 79 bars: Y=%d, want white 721", y)
|
t.Fatalf("tick 79 bars: Y=%d, want gray flank 414", y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package generator
|
package generator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/kernels"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWGPUGenerator(t *testing.T) {
|
func TestWGPUSMPTEPattern(t *testing.T) {
|
||||||
const width, height = 1920, 1080
|
const width, height = 1920, 1080
|
||||||
g, err := NewWGPUGenerator(width, height, filepath.Join("..", "..", "kernels", "static", "smpteBars.wgsl"))
|
shader, err := kernels.Read("static/smpteBars.wgsl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read shader: %v", err)
|
||||||
|
}
|
||||||
|
g, err := NewWGPUGenerator(width, height, string(shader))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init: %v", err)
|
t.Fatalf("init: %v", err)
|
||||||
}
|
}
|
||||||
@@ -22,45 +26,64 @@ func TestWGPUGenerator(t *testing.T) {
|
|||||||
t.Fatalf("GenerateFrame 2: %v", err)
|
t.Fatalf("GenerateFrame 2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := [7][3]uint32{
|
tests := []struct {
|
||||||
{721, 512, 512}, {674, 176, 543}, {581, 589, 176},
|
name string
|
||||||
{534, 253, 207}, {251, 771, 817}, {204, 435, 848}, {111, 848, 481},
|
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},
|
||||||
}
|
}
|
||||||
barOf := func(x int) int {
|
for _, tt := range tests {
|
||||||
if b := x * 7 / width; b < 7 {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
return b
|
y, cb, cr := sampleV210(buf, width, tt.x, tt.y)
|
||||||
|
if y != tt.wantY || cb != tt.wantCb || cr != tt.wantCr {
|
||||||
|
t.Fatalf("pixel (%d,%d): got %d/%d/%d, want %d/%d/%d",
|
||||||
|
tt.x, tt.y, y, cb, cr, tt.wantY, tt.wantCb, tt.wantCr)
|
||||||
}
|
}
|
||||||
return 6
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWGPUGeneratorUsesPaddedV210Rows(t *testing.T) {
|
||||||
|
const width, height = 100, 2
|
||||||
|
shader, err := kernels.Read("static/ebu75.wgsl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read shader: %v", err)
|
||||||
|
}
|
||||||
|
g, err := NewWGPUGenerator(width, height, string(shader))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
defer g.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, V210FrameSize(width, height))
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = 0xff
|
||||||
|
}
|
||||||
|
if err := g.GenerateFrame(buf, 0); err != nil {
|
||||||
|
t.Fatalf("GenerateFrame: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
for y := 0; y < height; y++ {
|
for y := 0; y < height; y++ {
|
||||||
for x := 0; x < width; x++ {
|
gotY, gotCb, gotCr := sampleV210(buf, width, 0, y)
|
||||||
p := y*width + x
|
if gotY != 721 || gotCb != 512 || gotCr != 512 {
|
||||||
off := (p / 6) * 16
|
t.Fatalf("row %d first pixel = %d/%d/%d, want 721/512/512", y, gotY, gotCb, gotCr)
|
||||||
w0 := binary.LittleEndian.Uint32(buf[off:])
|
|
||||||
w1 := binary.LittleEndian.Uint32(buf[off+4:])
|
|
||||||
w2 := binary.LittleEndian.Uint32(buf[off+8:])
|
|
||||||
w3 := binary.LittleEndian.Uint32(buf[off+12:])
|
|
||||||
var yv, cb, cr uint32
|
|
||||||
switch p % 6 {
|
|
||||||
case 0:
|
|
||||||
yv, cb, cr = (w0>>10)&0x3FF, w0&0x3FF, (w0>>20)&0x3FF
|
|
||||||
case 1:
|
|
||||||
yv = w1 & 0x3FF
|
|
||||||
case 2:
|
|
||||||
yv, cb, cr = (w1>>20)&0x3FF, (w1>>10)&0x3FF, w2&0x3FF
|
|
||||||
case 3:
|
|
||||||
yv = (w2 >> 10) & 0x3FF
|
|
||||||
case 4:
|
|
||||||
yv, cb, cr = w3&0x3FF, (w2>>20)&0x3FF, (w3>>10)&0x3FF
|
|
||||||
case 5:
|
|
||||||
yv = (w3 >> 20) & 0x3FF
|
|
||||||
}
|
}
|
||||||
b := want[barOf(x)]
|
padding := buf[y*V210LineSize(width)+V210ActiveLineSize(width) : (y+1)*V210LineSize(width)]
|
||||||
if yv != b[0] {
|
for i, b := range padding {
|
||||||
t.Fatalf("pixel (%d,%d): Y=%d want %d", x, y, yv, b[0])
|
if b != 0 {
|
||||||
}
|
t.Fatalf("row %d padding byte %d = %#x, want 0", y, i, b)
|
||||||
if x%2 == 0 && (cb != b[1] || cr != b[2]) {
|
|
||||||
t.Fatalf("pixel (%d,%d): Cb=%d Cr=%d want %d/%d", x, y, cb, cr, b[1], b[2])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendAuto Backend = "auto"
|
||||||
|
BackendGPU Backend = "gpu"
|
||||||
|
BackendCPU Backend = "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Definition flowdef.Video
|
||||||
|
Pattern string
|
||||||
|
Overlay OverlayConfig
|
||||||
|
Backend Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
type OverlayConfig struct {
|
||||||
|
Text string
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
Position 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) HasAlpha() bool {
|
||||||
|
return c.Definition.MediaType == flowdef.MediaTypeV210A
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Rate() mxl.Rational {
|
||||||
|
return mxl.Rational{
|
||||||
|
Num: int64(c.Definition.GrainRate.Numerator),
|
||||||
|
Den: int64(c.Definition.GrainRate.Denominator),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/kernels"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatternInfo struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pattern struct {
|
||||||
|
PatternInfo
|
||||||
|
kernelSource string
|
||||||
|
dynamic bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns = map[string]pattern{
|
||||||
|
"ebu75": {
|
||||||
|
PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"},
|
||||||
|
kernelSource: mustReadKernel("static/ebu75.wgsl"),
|
||||||
|
},
|
||||||
|
"ebu75-move": {
|
||||||
|
PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"},
|
||||||
|
kernelSource: mustReadKernel("dynamic/ebu75.wgsl"),
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
|
"ebu100": {
|
||||||
|
PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"},
|
||||||
|
kernelSource: mustReadKernel("static/ebu100.wgsl"),
|
||||||
|
},
|
||||||
|
"ebu100-move": {
|
||||||
|
PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"},
|
||||||
|
kernelSource: mustReadKernel("dynamic/ebu100.wgsl"),
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
|
"smpte": {
|
||||||
|
PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"},
|
||||||
|
kernelSource: mustReadKernel("static/smpteBars.wgsl"),
|
||||||
|
},
|
||||||
|
"smpte-move": {
|
||||||
|
PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"},
|
||||||
|
kernelSource: mustReadKernel("dynamic/smpteBars.wgsl"),
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
|
"gray-bars": {
|
||||||
|
PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"},
|
||||||
|
kernelSource: mustReadKernel("static/yBars.wgsl"),
|
||||||
|
},
|
||||||
|
"gray-bars-move": {
|
||||||
|
PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"},
|
||||||
|
kernelSource: mustReadKernel("dynamic/yBars.wgsl"),
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
|
"gray-ramp": {
|
||||||
|
PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"},
|
||||||
|
kernelSource: mustReadKernel("static/yRamp.wgsl"),
|
||||||
|
},
|
||||||
|
"gray-ramp-move": {
|
||||||
|
PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"},
|
||||||
|
kernelSource: mustReadKernel("dynamic/yRamp.wgsl"),
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadKernel(name string) string {
|
||||||
|
source, err := kernels.Read(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("read embedded video kernel %q: %v", name, err))
|
||||||
|
}
|
||||||
|
return string(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasPattern(name string) bool {
|
||||||
|
_, ok := patterns[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func Patterns() []PatternInfo {
|
||||||
|
result := make([]PatternInfo, 0, len(patterns))
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
result = append(result, pattern.PatternInfo)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].Name < result[j].Name
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPatterns(t *testing.T) {
|
||||||
|
got := Patterns()
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("Patterns returned no video patterns")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(got))
|
||||||
|
for i, pattern := range got {
|
||||||
|
if pattern.Name == "" {
|
||||||
|
t.Fatalf("pattern %d has an empty name", i)
|
||||||
|
}
|
||||||
|
if pattern.Description == "" {
|
||||||
|
t.Fatalf("pattern %q has an empty description", pattern.Name)
|
||||||
|
}
|
||||||
|
if !HasPattern(pattern.Name) {
|
||||||
|
t.Fatalf("Patterns returned %q, but HasPattern rejected it", pattern.Name)
|
||||||
|
}
|
||||||
|
if _, exists := seen[pattern.Name]; exists {
|
||||||
|
t.Fatalf("duplicate pattern name %q", pattern.Name)
|
||||||
|
}
|
||||||
|
seen[pattern.Name] = struct{}{}
|
||||||
|
if i > 0 && got[i-1].Name >= pattern.Name {
|
||||||
|
t.Fatalf("patterns are not sorted: %q appears before %q", got[i-1].Name, pattern.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPatternRejectsUnknownName(t *testing.T) {
|
||||||
|
if HasPattern("not-a-pattern") {
|
||||||
|
t.Fatal("HasPattern accepted an unknown pattern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatternRegistryKeysMatchNames(t *testing.T) {
|
||||||
|
for name, pattern := range patterns {
|
||||||
|
if pattern.Name != name {
|
||||||
|
t.Errorf("pattern map key %q does not match pattern name %q", name, pattern.Name)
|
||||||
|
}
|
||||||
|
if pattern.kernelSource == "" {
|
||||||
|
t.Errorf("pattern %q has empty kernel source", name)
|
||||||
|
}
|
||||||
|
wantDynamic := strings.HasSuffix(name, "-move")
|
||||||
|
if pattern.dynamic != wantDynamic {
|
||||||
|
t.Errorf("pattern %q dynamic = %t, want %t", name, pattern.dynamic, wantDynamic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/assets"
|
||||||
|
"mxl-pattern-generator/internal/generator"
|
||||||
|
|
||||||
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) {
|
||||||
|
pattern, ok := patterns[cfg.Pattern]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
gen, backend, err := newFrameGenerator(cfg, pattern)
|
||||||
|
if err != nil {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
overlay, err := buildTextOverlay(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticFrame []byte
|
||||||
|
if !pattern.dynamic && !cfg.HasAlpha() {
|
||||||
|
frameSize := generator.V210FrameSize(int(cfg.Width()), int(cfg.Height()))
|
||||||
|
staticFrame = make([]byte, frameSize)
|
||||||
|
|
||||||
|
if err := gen.GenerateFrame(staticFrame, 0); err != nil {
|
||||||
|
return fmt.Errorf("generate static frame: %w", err)
|
||||||
|
}
|
||||||
|
if overlay != nil {
|
||||||
|
if err := overlay.ApplyV210(staticFrame); err != nil {
|
||||||
|
return fmt.Errorf("apply text overlay to static frame: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flowJSON, err := json.Marshal(cfg.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal video flow definition: %w", err)
|
||||||
|
}
|
||||||
|
writer, isCreated, err := inst.NewWriter(string(flowJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create video writer: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
runErr = errors.Join(runErr, fmt.Errorf("close video writer: %w", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !isCreated {
|
||||||
|
log.Printf("reusing existing video flow: %s", cfg.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := writer.Config().Common.GrainRate
|
||||||
|
idx := mxl.CurrentIndex(rate)
|
||||||
|
log.Printf("writing video flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx)
|
||||||
|
|
||||||
|
var grainsWritten int64
|
||||||
|
var tick uint32 // Small animation counter; shaders cannot use the full grain index yet.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("stopping video after %d grains", grainsWritten)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
grain, err := writer.OpenGrain(idx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open video grain %d: %w", idx, err)
|
||||||
|
}
|
||||||
|
if staticFrame != nil {
|
||||||
|
if len(grain.Payload) != len(staticFrame) {
|
||||||
|
return cancelGrain(grain, fmt.Errorf(
|
||||||
|
"video grain payload size %d, expected %d",
|
||||||
|
len(grain.Payload),
|
||||||
|
len(staticFrame),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
copy(grain.Payload, staticFrame)
|
||||||
|
} else {
|
||||||
|
if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil {
|
||||||
|
return cancelGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err))
|
||||||
|
}
|
||||||
|
if overlay != nil {
|
||||||
|
if err := overlay.ApplyV210(grain.Payload); err != nil {
|
||||||
|
return cancelGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick++
|
||||||
|
}
|
||||||
|
if err := grain.Commit(grain.TotalSlices, 0); err != nil {
|
||||||
|
return fmt.Errorf("commit video grain %d: %w", idx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grainsWritten++
|
||||||
|
idx++
|
||||||
|
if grainsWritten%100 == 0 {
|
||||||
|
log.Printf("video grains written=%d, next index=%d", grainsWritten, idx)
|
||||||
|
}
|
||||||
|
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelGrain(grain *mxl.GrainWriteAccess, cause error) error {
|
||||||
|
if err := grain.Cancel(); err != nil {
|
||||||
|
return errors.Join(cause, fmt.Errorf("cancel video grain: %w", err))
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTextOverlay(cfg Config) (overlay *generator.TextOverlay, resultErr error) {
|
||||||
|
if cfg.Overlay.Text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
face, err := generator.NewFace(assets.JetBrainsMono, 48)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load text overlay font: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := face.Close(); err != nil {
|
||||||
|
resultErr = errors.Join(resultErr, fmt.Errorf("close text overlay font: %w", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
overlay, err = generator.NewTextOverlay(
|
||||||
|
cfg.Overlay.Text,
|
||||||
|
int(cfg.Width()),
|
||||||
|
int(cfg.Height()),
|
||||||
|
cfg.Overlay.X,
|
||||||
|
cfg.Overlay.Y,
|
||||||
|
cfg.Overlay.Position,
|
||||||
|
face,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create text overlay: %w", err)
|
||||||
|
}
|
||||||
|
return overlay, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testVideoID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||||
|
|
||||||
|
func testConfig(t *testing.T, overlay OverlayConfig) Config {
|
||||||
|
t.Helper()
|
||||||
|
definition, err := flowdef.NewV210Video(
|
||||||
|
testVideoID,
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
flowdef.Rational{Numerator: 50, Denominator: 1},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewV210Video: %v", err)
|
||||||
|
}
|
||||||
|
return Config{Definition: definition, Pattern: "ebu75", Overlay: overlay}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTextOverlayDisabled(t *testing.T) {
|
||||||
|
overlay, err := buildTextOverlay(testConfig(t, OverlayConfig{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildTextOverlay: %v", err)
|
||||||
|
}
|
||||||
|
if overlay != nil {
|
||||||
|
t.Fatal("buildTextOverlay returned an overlay for empty text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTextOverlayPositioning(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
overlay OverlayConfig
|
||||||
|
}{
|
||||||
|
{name: "explicit", overlay: OverlayConfig{Text: "MXL", X: 120, Y: 48}},
|
||||||
|
{name: "preset", overlay: OverlayConfig{Text: "MXL", Position: "cc"}},
|
||||||
|
{name: "preset ignores explicit coordinates", overlay: OverlayConfig{Text: "MXL", X: -1, Y: -1, Position: "cc"}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
overlay, err := buildTextOverlay(testConfig(t, tc.overlay))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildTextOverlay: %v", err)
|
||||||
|
}
|
||||||
|
if overlay == nil {
|
||||||
|
t.Fatal("buildTextOverlay returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTextOverlayRejectsInvalidPosition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
overlay OverlayConfig
|
||||||
|
wantErrSub string
|
||||||
|
}{
|
||||||
|
{name: "negative", overlay: OverlayConfig{Text: "MXL", X: -6}, wantErrSub: "must not be negative"},
|
||||||
|
{name: "unaligned x", overlay: OverlayConfig{Text: "MXL", X: 7}, wantErrSub: "divisible by 6"},
|
||||||
|
{name: "right overflow", overlay: OverlayConfig{Text: "MXL", X: 1902}, wantErrSub: "does not fit"},
|
||||||
|
{name: "bottom overflow", overlay: OverlayConfig{Text: "MXL", Y: 1070}, wantErrSub: "does not fit"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := buildTextOverlay(testConfig(t, tc.overlay))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||||
|
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let py = (block * 6u) / params.width;
|
let py = block / blocks_per_row;
|
||||||
|
|
||||||
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
|
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
|
||||||
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
|
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let py = (block * 6u) / params.width;
|
let py = block / blocks_per_row;
|
||||||
|
|
||||||
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
|
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
|
||||||
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
|
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32, bars_75_width: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let y_px = (block * 6u) / params.width;
|
let y_px = block / blocks_per_row;
|
||||||
|
|
||||||
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
||||||
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
|
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let py = (block * 6u) / params.width;
|
let py = block / blocks_per_row;
|
||||||
// Moving square: horizontal oscillation around screen center.
|
// Moving square: horizontal oscillation around screen center.
|
||||||
// frame is a small tick; converting the huge raw grain index here
|
// frame is a small tick; converting the huge raw grain index here
|
||||||
// would destroy f32 precision and freeze the motion.
|
// would destroy f32 precision and freeze the motion.
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let py = (block * 6u) / params.width;
|
let py = block / blocks_per_row;
|
||||||
|
|
||||||
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue
|
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue
|
||||||
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kernels
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// files contains every built-in video pattern shader.
|
||||||
|
//
|
||||||
|
//go:embed static/*.wgsl dynamic/*.wgsl
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
func Read(name string) ([]byte, error) {
|
||||||
|
return files.ReadFile(name)
|
||||||
|
}
|
||||||
@@ -18,12 +18,13 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
|
|
||||||
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
|
// Bar order: 100% white, yellow, cyan, green, magenta, red, blue, black
|
||||||
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
|
let y_tab = array<u32, 8>(940u, 877u, 754u, 691u, 313u, 250u, 127u, 64u);
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
|
|
||||||
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
|
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue, black
|
||||||
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
|
let y_tab = array<u32, 8>(721u, 674u, 581u, 534u, 251u, 204u, 111u, 64u);
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32, bars_75_width: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
let y_px = (block * 6u) / params.width;
|
let y_px = block / blocks_per_row;
|
||||||
|
|
||||||
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
||||||
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
let y_tab = array<u32, 7>(721u, 674u, 581u, 534u, 251u, 204u, 111u);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,7 +28,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
|
let y_tab = array<u32, 13>(64u, 137u, 210u, 283u, 356u, 429u, 502u, 575u, 648u, 721u, 794u, 867u, 940u);
|
||||||
|
|
||||||
// 6 = pixels per v210 block (NOT the bar count)
|
// 6 = pixels per v210 block (NOT the bar count)
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
var y: array<u32, 6>;
|
var y: array<u32, 6>;
|
||||||
var cb: array<u32, 6>;
|
var cb: array<u32, 6>;
|
||||||
var cr: array<u32, 6>;
|
var cr: array<u32, 6>;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ fn bar_index(px: u32) -> u32 {
|
|||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
let block = gid.x;
|
let block = gid.x;
|
||||||
let total = (params.width * params.height) / 6u;
|
let blocks_per_row = (params.width + 5u) / 6u;
|
||||||
|
let total = blocks_per_row * params.height;
|
||||||
if (block >= total) {
|
if (block >= total) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
let cb_tab = array<u32, 7>(512u, 176u, 589u, 253u, 771u, 435u, 848u);
|
let cb_tab = array<u32, 7>(512u, 176u, 589u, 253u, 771u, 435u, 848u);
|
||||||
let cr_tab = array<u32, 7>(512u, 543u, 176u, 207u, 817u, 848u, 481u);
|
let cr_tab = array<u32, 7>(512u, 543u, 176u, 207u, 817u, 848u, 481u);
|
||||||
|
|
||||||
let x = (block * 6u) % params.width;
|
let x = (block % blocks_per_row) * 6u;
|
||||||
var y: array<u32, 6>;
|
var y: array<u32, 6>;
|
||||||
var cb: array<u32, 6>;
|
var cb: array<u32, 6>;
|
||||||
var cr: array<u32, 6>;
|
var cr: array<u32, 6>;
|
||||||
|
|||||||
Reference in New Issue
Block a user