Compare commits
21 Commits
70c966dbad
..
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 |
+7
-13
@@ -11,7 +11,7 @@ The current stack is a good fit for a test-pattern generator:
|
|||||||
- wgpu/WGSL is a reasonable portable GPU abstraction for producing v210 video.
|
- 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.
|
- 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 its orchestration and configuration layers. Before adding substantial audio functionality, flow-definition handling, lifecycle management, and the failing tests should be corrected.
|
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
|
## Recommended audio architecture
|
||||||
|
|
||||||
@@ -72,17 +72,15 @@ For `audio/float32`, the audio package may use `[]float32` internally and keep b
|
|||||||
| 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. |
|
| 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. |
|
| 3 | `checkArgs` received `appArgs` by value, so generated UUIDs were discarded. | **Fixed** | Argument validation mutates the actual configuration, and the hard-coded video UUID has been removed. |
|
||||||
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
|
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
|
||||||
| 5 | `NewFlowDefJSON(TYPE_AUDIO, ...)` produces a video/v210 definition. | **Fixed** | The generic discriminator-based builder was removed. Video and audio have separate schema types, and video uses the typed `NewV210Video` constructor. An audio constructor will be added with audio generation. |
|
| 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. |
|
| 6 | The wgpu path was described as zero-copy although it performs GPU readback and a CPU copy. | **Deferred — fix after audio** | Every frame is copied from GPU storage to a mapped host buffer and then copied into the MXL payload. The path is synchronous and serial. This may require substantial benchmarking and architectural work, so audio implementation takes priority. Update the documentation now, but defer optimization or redesign until audio is complete. |
|
||||||
| 7 | The test suite had three failures. | **Open** | `TestNewTextOverlay`, `TestWGPUMoveSquare`, and `TestWGPUGenerator` still fail. |
|
| 7 | The test suite had three failures. | **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`. |
|
| 8 | The Makefile clean target uses `fm -f` instead of `rm -f`. | **Fixed** | The clean target now uses `rm -f`. |
|
||||||
|
|
||||||
## Additional implementation priorities
|
## Additional implementation priorities
|
||||||
|
|
||||||
1. Fix the existing tests or update incorrect expectations after confirming the intended color values and overlay positioning.
|
1. Add integration coverage for simultaneous video/audio startup, cancellation, and error propagation against an MXL instance.
|
||||||
2. Add a typed audio flow-definition constructor, CPU audio generator, and continuous-flow writer loop using `OpenSamples`, `ChannelFragments`, and `Commit`.
|
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.
|
||||||
3. Run video and audio as sibling goroutines with shared cancellation and error propagation.
|
|
||||||
4. After audio is complete, measure end-to-end frame time and missed deadlines at 1080p50/60 and UHD. The current wgpu path may be adequate, but it is neither zero-copy nor asynchronous. Treat GPU readback optimization as a separate, potentially large task.
|
|
||||||
|
|
||||||
## Suggested package layout
|
## Suggested package layout
|
||||||
|
|
||||||
@@ -119,12 +117,8 @@ The following command was used:
|
|||||||
GOCACHE=/tmp/go-mxl-gen-cache go test ./...
|
GOCACHE=/tmp/go-mxl-gen-cache go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Package compilation succeeds, but the generator package fails these tests:
|
All packages pass. `go vet ./...`, the application build, and `git diff --check` also succeed.
|
||||||
|
|
||||||
- `TestNewTextOverlay`: expected a centered text box, but its center was reported as 45 instead of approximately 960.
|
|
||||||
- `TestWGPUMoveSquare`: tick 79 produced `590/512/512` instead of expected `893/176/543`.
|
|
||||||
- `TestWGPUGenerator`: pixel `(0,0)` produced Y=414 instead of expected Y=721.
|
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Keep the chosen Go + go-mxl + wgpu stack. Implement audio on the CPU in its own goroutine and give video and audio separate writers, indices, and pacing loops. Coordinate them through a shared context and the common MXL timebase, not through per-frame messages. Typed external-video configuration and the context-aware video runner are now implemented; the next architectural work is typed audio construction and generation. The three rendering-test failures remain a separate correctness task.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
+92
-237
@@ -5,8 +5,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -20,15 +18,15 @@ import (
|
|||||||
"github.com/qvest-digital/go-mxl/mxl"
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/app"
|
||||||
"mxl-pattern-generator/internal/audio"
|
"mxl-pattern-generator/internal/audio"
|
||||||
"mxl-pattern-generator/internal/flowdef"
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
"mxl-pattern-generator/internal/generator"
|
|
||||||
"mxl-pattern-generator/internal/video"
|
"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 {
|
||||||
@@ -49,6 +47,9 @@ 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
|
||||||
@@ -86,6 +87,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
|
|||||||
fmt.Fprintln(w, "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.Fprintln(w, " or: mxl-gen -d <domain> [--width <width px>] [--height <height px>] [--fps <framerate>] \\")
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> [--width <width px>] [--height <height px>] [--fps <framerate>] \\")
|
||||||
fmt.Fprintln(w, " [-c <channels amount>] [-f <sample rate>]")
|
fmt.Fprintln(w, " [-c <channels amount>] [-f <sample rate>]")
|
||||||
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> --no-video (-c <channels amount> | -a <flowDef.json>)")
|
||||||
fmt.Fprintln(w, " or: mxl-gen -d <domain> with default params")
|
fmt.Fprintln(w, " or: mxl-gen -d <domain> with default params")
|
||||||
fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.")
|
fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.")
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
@@ -94,7 +96,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printUsage(w io.Writer) {
|
func printUsage(w io.Writer) {
|
||||||
fmt.Fprintln(w, "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(w, "Try 'mxl-gen -h' for more information.")
|
fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,15 +135,20 @@ func validateFlowDefPath(label, path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateVideoArgs(args *appArgs) error {
|
func validateVideoArgs(args *appArgs) error {
|
||||||
if _, exists := patterns[args.pattern]; !exists {
|
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)
|
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
|
||||||
}
|
}
|
||||||
if args.videoFlowDefFile != "" {
|
if args.videoFlowDefFile != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if args.videoWidth == 0 || args.videoWidth%6 != 0 {
|
if args.videoWidth == 0 || args.videoWidth%2 != 0 {
|
||||||
// v210 stores 6 pixels in each 16-byte block.
|
return fmt.Errorf("video width must be greater than zero and even for 4:2:2 video")
|
||||||
return fmt.Errorf("video width must be greater than zero and divisible by 6")
|
|
||||||
}
|
}
|
||||||
if args.videoHeight == 0 {
|
if args.videoHeight == 0 {
|
||||||
return fmt.Errorf("video height must be greater than zero")
|
return fmt.Errorf("video height must be greater than zero")
|
||||||
@@ -160,6 +167,22 @@ func validateVideoArgs(args *appArgs) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateMediaSelection(args appArgs) error {
|
||||||
|
if args.noVideo && args.videoFlowDefFile != "" {
|
||||||
|
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 {
|
func validateAudioArgs(args *appArgs) error {
|
||||||
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -191,6 +214,7 @@ func validateAudioArgs(args *appArgs) error {
|
|||||||
func validateArgs(args *appArgs) error {
|
func validateArgs(args *appArgs) error {
|
||||||
checks := []func() error{
|
checks := []func() error{
|
||||||
func() error { return validateDomain(args.domain) },
|
func() error { return validateDomain(args.domain) },
|
||||||
|
func() error { return validateMediaSelection(*args) },
|
||||||
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
|
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
|
||||||
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
|
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
|
||||||
func() error { return validateVideoArgs(args) },
|
func() error { return validateVideoArgs(args) },
|
||||||
@@ -213,76 +237,6 @@ func sortedMapKeys[V any](values map[string]V) string {
|
|||||||
return strings.Join(keys, ", ")
|
return strings.Join(keys, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
type pattern struct {
|
|
||||||
name string
|
|
||||||
description string
|
|
||||||
kernelPath string
|
|
||||||
motion bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var patterns = map[string]pattern{
|
|
||||||
"ebu75": {
|
|
||||||
name: "ebu75",
|
|
||||||
description: "EBU 75% Color Bar Signal",
|
|
||||||
kernelPath: "kernels/static/ebu75.wgsl",
|
|
||||||
motion: false,
|
|
||||||
},
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var audioLevels = map[string]float64{
|
var audioLevels = map[string]float64{
|
||||||
"ebu": audio.LevelEBUDBFS,
|
"ebu": audio.LevelEBUDBFS,
|
||||||
"smpte": audio.LevelSMPTEDBFS,
|
"smpte": audio.LevelSMPTEDBFS,
|
||||||
@@ -290,19 +244,16 @@ var audioLevels = map[string]float64{
|
|||||||
|
|
||||||
func listPatterns(w io.Writer) {
|
func listPatterns(w io.Writer) {
|
||||||
fmt.Fprintln(w, "List of available video patterns:")
|
fmt.Fprintln(w, "List of available video patterns:")
|
||||||
names := make([]string, 0, len(patterns))
|
patterns := video.Patterns()
|
||||||
maxNameWidth := 0
|
maxNameWidth := 0
|
||||||
for name := range patterns {
|
for _, pattern := range patterns {
|
||||||
names = append(names, name)
|
l := len(pattern.Name)
|
||||||
l := len(name)
|
|
||||||
if l > maxNameWidth {
|
if l > maxNameWidth {
|
||||||
maxNameWidth = l
|
maxNameWidth = l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
for _, pattern := range patterns {
|
||||||
for _, name := range names {
|
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, pattern.Name, pattern.Description)
|
||||||
p := patterns[name]
|
|
||||||
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, name, p.description)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +263,7 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) {
|
|||||||
// 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")
|
||||||
@@ -338,10 +289,13 @@ func addFlags(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")
|
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")
|
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz")
|
||||||
@@ -377,21 +331,41 @@ func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
|
|||||||
return parseResult{args: args, shouldRun: true}, nil
|
return parseResult{args: args, shouldRun: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildVideoConfig(args appArgs) (video.Config, error) {
|
func parseVideoBackend(value string) (video.Backend, error) {
|
||||||
|
if value == "" {
|
||||||
|
return video.BackendAuto, nil
|
||||||
|
}
|
||||||
|
backend := video.Backend(value)
|
||||||
|
switch backend {
|
||||||
|
case video.BackendAuto, video.BackendGPU, video.BackendCPU:
|
||||||
|
return backend, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"unsupported video backend %q (supported: auto, gpu, cpu)",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVideoConfig(args appArgs) (*video.Config, error) {
|
||||||
|
if args.noVideo {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
var definition flowdef.Video
|
var definition flowdef.Video
|
||||||
if args.videoFlowDefFile != "" {
|
if args.videoFlowDefFile != "" {
|
||||||
data, err := os.ReadFile(args.videoFlowDefFile)
|
data, err := os.ReadFile(args.videoFlowDefFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return video.Config{}, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"read video flow definition %q: %w",
|
"read video flow definition %q: %w",
|
||||||
args.videoFlowDefFile,
|
args.videoFlowDefFile,
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
definition, err = flowdef.ParseV210Video(data)
|
definition, err = flowdef.ParseVideo(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return video.Config{}, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"parse video flow definition %q: %w",
|
"parse video flow definition %q: %w",
|
||||||
args.videoFlowDefFile,
|
args.videoFlowDefFile,
|
||||||
err,
|
err,
|
||||||
@@ -400,11 +374,15 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
|||||||
} else {
|
} else {
|
||||||
rate, ok := frameRates[args.videoFPS]
|
rate, ok := frameRates[args.videoFPS]
|
||||||
if !ok {
|
if !ok {
|
||||||
return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
|
return nil, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
definition, err = flowdef.NewV210Video(
|
newVideo := flowdef.NewV210Video
|
||||||
|
if args.videoAlpha {
|
||||||
|
newVideo = flowdef.NewV210AVideo
|
||||||
|
}
|
||||||
|
definition, err = newVideo(
|
||||||
args.videoUUID,
|
args.videoUUID,
|
||||||
args.videoWidth,
|
args.videoWidth,
|
||||||
args.videoHeight,
|
args.videoHeight,
|
||||||
@@ -414,17 +392,22 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return video.Config{}, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"build video flow definition: %w",
|
"build video flow definition: %w",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := patterns[args.pattern]; !ok {
|
if !video.HasPattern(args.pattern) {
|
||||||
return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern)
|
return nil, fmt.Errorf("unknown video pattern %q", args.pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
return video.Config{
|
backend, err := parseVideoBackend(args.videoBackend)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &video.Config{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
Pattern: args.pattern,
|
Pattern: args.pattern,
|
||||||
Overlay: video.OverlayConfig{
|
Overlay: video.OverlayConfig{
|
||||||
@@ -433,6 +416,7 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
|||||||
Y: args.overlayY,
|
Y: args.overlayY,
|
||||||
Position: args.overlayPos,
|
Position: args.overlayPos,
|
||||||
},
|
},
|
||||||
|
Backend: backend,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,148 +482,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx context.Context, args appArgs) (runErr error) {
|
func run(ctx context.Context, args appArgs) error {
|
||||||
videoCfg, err := buildVideoConfig(args)
|
videoCfg, err := buildVideoConfig(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("video configuration: %w", err)
|
return fmt.Errorf("video configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
audioCfg, err := buildAudioConfig(args)
|
||||||
log.Printf("%s %s", APP_NAME, APP_VER)
|
|
||||||
log.Printf("Domain: %s", args.domain)
|
|
||||||
log.Printf("Video: %dx%d %d/%d",
|
|
||||||
videoCfg.Width(), videoCfg.Height(), videoCfg.Rate().Num, videoCfg.Rate().Den)
|
|
||||||
log.Printf("Video ID: %s", videoCfg.ID())
|
|
||||||
|
|
||||||
inst, err := mxl.NewInstance(args.domain, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize MXL domain %q: %w", args.domain, err)
|
return fmt.Errorf("audio configuration: %w", err)
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := inst.Close(); err != nil {
|
|
||||||
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return runVideo(ctx, inst, videoCfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runVideo(ctx context.Context, inst *mxl.Instance, cfg video.Config) (runErr error) {
|
return app.Run(ctx, app.Config{
|
||||||
pattern, ok := patterns[cfg.Pattern]
|
Domain: args.domain,
|
||||||
if !ok {
|
Video: videoCfg,
|
||||||
return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
|
Audio: audioCfg,
|
||||||
}
|
})
|
||||||
|
|
||||||
// TODO: fall back to a CPU generator if GPU initialization fails.
|
|
||||||
gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("initialize wgpu video generator: %w", err)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil {
|
|
||||||
return cancelVideoGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err))
|
|
||||||
}
|
|
||||||
if overlay != nil {
|
|
||||||
if err := overlay.ApplyV210(grain.Payload); err != nil {
|
|
||||||
return cancelVideoGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := grain.Commit(grain.TotalSlices, 0); err != nil {
|
|
||||||
return fmt.Errorf("commit video grain %d: %w", idx, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
grainsWritten++
|
|
||||||
idx++
|
|
||||||
tick++
|
|
||||||
if grainsWritten%100 == 0 {
|
|
||||||
log.Printf("video grains written=%d, next index=%d", grainsWritten, idx)
|
|
||||||
}
|
|
||||||
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTextOverlay(cfg video.Config) (overlay *generator.TextOverlay, resultErr error) {
|
|
||||||
if cfg.Overlay.Text == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if cfg.Overlay.X < 0 || cfg.Overlay.X > int(cfg.Width()) ||
|
|
||||||
cfg.Overlay.Y < 0 || cfg.Overlay.Y > int(cfg.Height()) {
|
|
||||||
return nil, fmt.Errorf("text overlay position (%d, %d) is outside the %dx%d video frame",
|
|
||||||
cfg.Overlay.X, cfg.Overlay.Y, cfg.Width(), cfg.Height())
|
|
||||||
}
|
|
||||||
|
|
||||||
face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelVideoGrain(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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,23 @@ import (
|
|||||||
|
|
||||||
"mxl-pattern-generator/internal/audio"
|
"mxl-pattern-generator/internal/audio"
|
||||||
"mxl-pattern-generator/internal/flowdef"
|
"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) {
|
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
@@ -95,6 +110,119 @@ func TestValidateAudioArgsSkipsDisabledAudio(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestListPatternsIsSorted(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
listPatterns(&output)
|
listPatterns(&output)
|
||||||
@@ -124,6 +252,60 @@ func TestBuildVideoConfigFromArgs(t *testing.T) {
|
|||||||
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
|
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
|
||||||
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
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) {
|
func TestBuildVideoConfigFromFile(t *testing.T) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ func NewSineGenerator(
|
|||||||
return nil, fmt.Errorf("base frequency must be finite and greater than zero, got %g", baseFrequency)
|
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)
|
sampleRate := float64(rate.Num) / float64(rate.Den)
|
||||||
highestFrequency := float64(cfg.Channels()) * baseFrequency
|
highestFrequency := float64(cfg.Channels()) * baseFrequency
|
||||||
nyquist := sampleRate / 2
|
nyquist := sampleRate / 2
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
FormatVideo = "urn:x-nmos:format:video"
|
FormatVideo = "urn:x-nmos:format:video"
|
||||||
FormatAudio = "urn:x-nmos:format:audio"
|
FormatAudio = "urn:x-nmos:format:audio"
|
||||||
MediaTypeV210 = "video/v210"
|
MediaTypeV210 = "video/v210"
|
||||||
|
MediaTypeV210A = "video/v210a"
|
||||||
MediaTypeFloat32 = "audio/float32"
|
MediaTypeFloat32 = "audio/float32"
|
||||||
|
|
||||||
InterlaceProgressive = "progressive"
|
InterlaceProgressive = "progressive"
|
||||||
@@ -75,7 +76,12 @@ type VideoComponent struct {
|
|||||||
BitDepth uint `json:"bit_depth"`
|
BitDepth uint `json:"bit_depth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
func newVideo(
|
||||||
|
id string,
|
||||||
|
width, height uint,
|
||||||
|
rate Rational,
|
||||||
|
mediaType string,
|
||||||
|
) (Video, error) {
|
||||||
definition := Video{
|
definition := Video{
|
||||||
Common: Common{
|
Common: Common{
|
||||||
Description: "go-mxl-pattern-gen generated video",
|
Description: "go-mxl-pattern-gen generated video",
|
||||||
@@ -86,7 +92,7 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
|||||||
Format: FormatVideo,
|
Format: FormatVideo,
|
||||||
Label: "go-mxl-pattern-gen generated video",
|
Label: "go-mxl-pattern-gen generated video",
|
||||||
Parents: []string{},
|
Parents: []string{},
|
||||||
MediaType: MediaTypeV210,
|
MediaType: mediaType,
|
||||||
},
|
},
|
||||||
GrainRate: rate,
|
GrainRate: rate,
|
||||||
FrameWidth: width,
|
FrameWidth: width,
|
||||||
@@ -105,7 +111,15 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
|||||||
return definition, nil
|
return definition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseV210Video(data []byte) (Video, error) {
|
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||||
|
return newVideo(id, width, height, rate, MediaTypeV210)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewV210AVideo(id string, width, height uint, rate Rational) (Video, error) {
|
||||||
|
return newVideo(id, width, height, rate, MediaTypeV210A)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseVideo(data []byte) (Video, error) {
|
||||||
var definition Video
|
var definition Video
|
||||||
if err := json.Unmarshal(data, &definition); err != nil {
|
if err := json.Unmarshal(data, &definition); err != nil {
|
||||||
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
|
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
|
||||||
@@ -123,8 +137,10 @@ func (v Video) Validate() error {
|
|||||||
if v.Format != FormatVideo {
|
if v.Format != FormatVideo {
|
||||||
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
|
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
|
||||||
}
|
}
|
||||||
if v.MediaType != MediaTypeV210 {
|
switch v.MediaType {
|
||||||
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, 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 {
|
if v.InterlaceMode != InterlaceProgressive {
|
||||||
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
|
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
|
||||||
@@ -132,8 +148,8 @@ func (v Video) Validate() error {
|
|||||||
if v.ColorSpace != ColorSpaceBT709 {
|
if v.ColorSpace != ColorSpaceBT709 {
|
||||||
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
|
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
|
||||||
}
|
}
|
||||||
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 {
|
if v.FrameWidth == 0 || v.FrameWidth%2 != 0 {
|
||||||
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth)
|
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 {
|
if v.FrameHeight == 0 {
|
||||||
return fmt.Errorf("frame_height must be greater than zero")
|
return fmt.Errorf("frame_height must be greater than zero")
|
||||||
|
|||||||
@@ -28,14 +28,37 @@ func TestNewV210Video(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewV210VideoRejectsInvalidWidth(t *testing.T) {
|
func TestNewV210AVideo(t *testing.T) {
|
||||||
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
|
definition, err := NewV210AVideo(testVideoID, 1280, 720, Rational{Numerator: 50, Denominator: 1})
|
||||||
if err == nil || !strings.Contains(err.Error(), "divisible by 6") {
|
if err != nil {
|
||||||
t.Fatalf("error = %v, want width divisibility error", err)
|
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 TestParseV210Video(t *testing.T) {
|
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})
|
want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewV210Video: %v", err)
|
t.Fatalf("NewV210Video: %v", err)
|
||||||
@@ -45,28 +68,58 @@ func TestParseV210Video(t *testing.T) {
|
|||||||
t.Fatalf("json.Marshal: %v", err)
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := ParseV210Video(data)
|
got, err := ParseVideo(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseV210Video: %v", err)
|
t.Fatalf("ParseVideo: %v", err)
|
||||||
}
|
}
|
||||||
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
|
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
|
||||||
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseV210VideoRejectsAudio(t *testing.T) {
|
func TestParseVideoAcceptsV210A(t *testing.T) {
|
||||||
|
want, err := NewV210AVideo(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewV210AVideo: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseVideo(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseVideo: %v", err)
|
||||||
|
}
|
||||||
|
if got.MediaType != MediaTypeV210A {
|
||||||
|
t.Fatalf("media type = %q, want %q", got.MediaType, MediaTypeV210A)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVideoRejectsAudio(t *testing.T) {
|
||||||
data := []byte(`{
|
data := []byte(`{
|
||||||
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||||
"format":"urn:x-nmos:format:audio",
|
"format":"urn:x-nmos:format:audio",
|
||||||
"media_type":"audio/float32"
|
"media_type":"audio/float32"
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
_, err := ParseV210Video(data)
|
_, err := ParseVideo(data)
|
||||||
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||||
t.Fatalf("error = %v, want video format error", err)
|
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) {
|
func TestNewFloat32Audio(t *testing.T) {
|
||||||
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
alphaTransparent uint32 = 64
|
||||||
|
alphaOpaque uint32 = 940
|
||||||
|
)
|
||||||
|
|
||||||
|
func packAlphaBlock(dst []byte, samples [3]uint32) {
|
||||||
|
const mask uint32 = 0x3ff
|
||||||
|
|
||||||
|
word := samples[0]&mask |
|
||||||
|
(samples[1]&mask)<<10 |
|
||||||
|
(samples[2]&mask)<<20
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint32(dst, word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillAlphaPlane(
|
||||||
|
dst []byte,
|
||||||
|
width, height int,
|
||||||
|
value uint32,
|
||||||
|
) error {
|
||||||
|
need := AlphaFrameSize(width, height)
|
||||||
|
if len(dst) < need {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"alpha: destination is too small: got %d bytes, need %d",
|
||||||
|
len(dst),
|
||||||
|
need,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stride := AlphaLineSize(width)
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
row := dst[y*stride : (y+1)*stride]
|
||||||
|
for x := 0; x < width; x += 3 {
|
||||||
|
var samples [3]uint32
|
||||||
|
for i := range samples {
|
||||||
|
if x+i < width {
|
||||||
|
samples[i] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packAlphaBlock(row[x/3*4:], samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchAlphaMovingSquare(dst []byte, width, height, frameIndex int) error {
|
||||||
|
need := AlphaFrameSize(width, height)
|
||||||
|
if len(dst) < need {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"alpha: destination is too small: got %d bytes, need %d",
|
||||||
|
len(dst),
|
||||||
|
need,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := movingSquareBounds(width, height, frameIndex)
|
||||||
|
firstPixelX := max(0, int(math.Floor(bounds.minX)))
|
||||||
|
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
|
||||||
|
firstBlockX := firstPixelX / 3 * 3
|
||||||
|
lastBlockX := min(width, (lastPixelX+2)/3*3)
|
||||||
|
firstY := max(0, int(math.Floor(bounds.minY)))
|
||||||
|
lastY := min(height, int(math.Ceil(bounds.maxY)))
|
||||||
|
stride := AlphaLineSize(width)
|
||||||
|
|
||||||
|
for y := firstY; y < lastY; y++ {
|
||||||
|
for blockX := firstBlockX; blockX < lastBlockX; blockX += 3 {
|
||||||
|
var samples [3]uint32
|
||||||
|
for i := range samples {
|
||||||
|
x := blockX + i
|
||||||
|
switch {
|
||||||
|
case x >= width:
|
||||||
|
samples[i] = 0
|
||||||
|
case bounds.contains(x, y):
|
||||||
|
samples[i] = alphaTransparent
|
||||||
|
default:
|
||||||
|
samples[i] = alphaOpaque
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset := y*stride + blockX/3*4
|
||||||
|
packAlphaBlock(dst[offset:], samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackAlphaBlock(t *testing.T) {
|
||||||
|
var dst [4]byte
|
||||||
|
packAlphaBlock(dst[:], [3]uint32{64, 512, 940})
|
||||||
|
|
||||||
|
word := binary.LittleEndian.Uint32(dst[:])
|
||||||
|
|
||||||
|
if got := word & 0x3ff; got != 64 {
|
||||||
|
t.Errorf("sample 0 = %d, want 64", got)
|
||||||
|
}
|
||||||
|
if got := (word >> 10) & 0x3ff; got != 512 {
|
||||||
|
t.Errorf("sample 1 = %d, want 512", got)
|
||||||
|
}
|
||||||
|
if got := (word >> 20) & 0x3ff; got != 940 {
|
||||||
|
t.Errorf("sample 2 = %d, want 940", got)
|
||||||
|
}
|
||||||
|
if got := word >> 30; got != 0 {
|
||||||
|
t.Errorf("unused bits = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackAlphaBlockMasksSamples(t *testing.T) {
|
||||||
|
var dst [4]byte
|
||||||
|
packAlphaBlock(dst[:], [3]uint32{0x401, 0x802, 0xc03})
|
||||||
|
|
||||||
|
word := binary.LittleEndian.Uint32(dst[:])
|
||||||
|
|
||||||
|
if got := word & 0x3ff; got != 1 {
|
||||||
|
t.Errorf("sample 0 = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := (word >> 10) & 0x3ff; got != 2 {
|
||||||
|
t.Errorf("sample 1 = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := (word >> 20) & 0x3ff; got != 3 {
|
||||||
|
t.Errorf("sample 2 = %d, want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillAlphaPlaneCompleteBlocks(t *testing.T) {
|
||||||
|
const width, height = 6, 2
|
||||||
|
dst := make([]byte, AlphaFrameSize(width, height))
|
||||||
|
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||||
|
t.Fatalf("fillAlphaPlane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
if got := sampleAlpha(dst, width, x, y); got != alphaOpaque {
|
||||||
|
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaOpaque)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillAlphaPlaneZerosPartialBlockPadding(t *testing.T) {
|
||||||
|
const width, height = 4, 2
|
||||||
|
dst := make([]byte, AlphaFrameSize(width, height))
|
||||||
|
if err := fillAlphaPlane(dst, width, height, alphaTransparent); err != nil {
|
||||||
|
t.Fatalf("fillAlphaPlane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stride := AlphaLineSize(width)
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
if got := sampleAlpha(dst, width, x, y); got != alphaTransparent {
|
||||||
|
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaTransparent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastWord := binary.LittleEndian.Uint32(dst[y*stride+4:])
|
||||||
|
if got := (lastWord >> 10) & 0x3ff; got != 0 {
|
||||||
|
t.Errorf("row %d padding sample 1 = %d, want 0", y, got)
|
||||||
|
}
|
||||||
|
if got := (lastWord >> 20) & 0x3ff; got != 0 {
|
||||||
|
t.Errorf("row %d padding sample 2 = %d, want 0", y, got)
|
||||||
|
}
|
||||||
|
if got := lastWord >> 30; got != 0 {
|
||||||
|
t.Errorf("row %d unused bits = %d, want 0", y, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillAlphaPlaneRejectsSmallDestination(t *testing.T) {
|
||||||
|
const width, height = 6, 2
|
||||||
|
dst := make([]byte, AlphaFrameSize(width, height)-1)
|
||||||
|
err := fillAlphaPlane(dst, width, height, alphaOpaque)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
|
||||||
|
t.Fatalf("error = %v, want destination size error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatchAlphaMovingSquare(t *testing.T) {
|
||||||
|
const width, height = 304, 200
|
||||||
|
dst := make([]byte, AlphaFrameSize(width, height))
|
||||||
|
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||||
|
t.Fatalf("fillAlphaPlane: %v", err)
|
||||||
|
}
|
||||||
|
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
|
||||||
|
t.Fatalf("patchAlphaMovingSquare: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sampleAlpha(dst, width, width/2, height/2); got != alphaTransparent {
|
||||||
|
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
|
||||||
|
}
|
||||||
|
if got := sampleAlpha(dst, width, 10, height/2); got != alphaOpaque {
|
||||||
|
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At frame zero the square begins at x=77. Its first three-sample word
|
||||||
|
// therefore contains two opaque samples followed by one transparent sample.
|
||||||
|
for x, want := range []uint32{alphaOpaque, alphaOpaque, alphaTransparent} {
|
||||||
|
if got := sampleAlpha(dst, width, 75+x, height/2); got != want {
|
||||||
|
t.Errorf("boundary sample x=%d = %d, want %d", 75+x, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatchAlphaMovingSquarePreservesPartialBlockPadding(t *testing.T) {
|
||||||
|
const width, height = 100, 200
|
||||||
|
dst := make([]byte, AlphaFrameSize(width, height))
|
||||||
|
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||||
|
t.Fatalf("fillAlphaPlane: %v", err)
|
||||||
|
}
|
||||||
|
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
|
||||||
|
t.Fatalf("patchAlphaMovingSquare: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWordOffset := height/2*AlphaLineSize(width) + (width/3)*4
|
||||||
|
lastWord := binary.LittleEndian.Uint32(dst[lastWordOffset:])
|
||||||
|
if got := lastWord & 0x3ff; got != alphaTransparent {
|
||||||
|
t.Errorf("last visible sample = %d, want %d", got, alphaTransparent)
|
||||||
|
}
|
||||||
|
if got := lastWord >> 10; got != 0 {
|
||||||
|
t.Errorf("partial-block padding bits = %#x, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatchAlphaMovingSquareRejectsSmallDestination(t *testing.T) {
|
||||||
|
const width, height = 100, 200
|
||||||
|
err := patchAlphaMovingSquare(
|
||||||
|
make([]byte, AlphaFrameSize(width, height)-1),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
|
||||||
|
t.Fatalf("error = %v, want destination size error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleAlpha(buf []byte, width, x, y int) uint32 {
|
||||||
|
offset := y*AlphaLineSize(width) + x/3*4
|
||||||
|
word := binary.LittleEndian.Uint32(buf[offset:])
|
||||||
|
return (word >> uint(x%3*10)) & 0x3ff
|
||||||
|
}
|
||||||
@@ -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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ import (
|
|||||||
"github.com/qvest-digital/go-mxl/mxl"
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Backend string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendAuto Backend = "auto"
|
||||||
|
BackendGPU Backend = "gpu"
|
||||||
|
BackendCPU Backend = "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Definition flowdef.Video
|
Definition flowdef.Video
|
||||||
Pattern string
|
Pattern string
|
||||||
Overlay OverlayConfig
|
Overlay OverlayConfig
|
||||||
|
Backend Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
type OverlayConfig struct {
|
type OverlayConfig struct {
|
||||||
@@ -31,6 +40,10 @@ func (c Config) Height() uint {
|
|||||||
return c.Definition.FrameHeight
|
return c.Definition.FrameHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Config) HasAlpha() bool {
|
||||||
|
return c.Definition.MediaType == flowdef.MediaTypeV210A
|
||||||
|
}
|
||||||
|
|
||||||
func (c Config) Rate() mxl.Rational {
|
func (c Config) Rate() mxl.Rational {
|
||||||
return mxl.Rational{
|
return mxl.Rational{
|
||||||
Num: int64(c.Definition.GrainRate.Numerator),
|
Num: int64(c.Definition.GrainRate.Numerator),
|
||||||
|
|||||||
@@ -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