Compare commits
24 Commits
1a16436688
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e804897c6 | |||
| dea2e6a59f | |||
| 9b194b5591 | |||
| 4d8035a434 | |||
| efa958723a | |||
| 723bef6342 | |||
| b85293b5d2 | |||
| fa787b5ef1 | |||
| f5df9506ce | |||
| 4d9485220a | |||
| e30cb7a168 | |||
| de4dfcf101 | |||
| d26d9442a0 | |||
| d3f0b533e3 | |||
| d341d22611 | |||
| 07769859f7 | |||
| eeb13c3bd4 | |||
| 6fa3dea391 | |||
| 128d6fe63e | |||
| 72decea866 | |||
| b5c3c82b1e | |||
| 70c966dbad | |||
| ce547451a0 | |||
| ce00305c25 |
+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.
|
||||
- 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
|
||||
|
||||
@@ -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. |
|
||||
| 3 | `checkArgs` received `appArgs` by value, so generated UUIDs were discarded. | **Fixed** | Argument validation mutates the actual configuration, and the hard-coded video UUID has been removed. |
|
||||
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
|
||||
| 5 | `NewFlowDefJSON(TYPE_AUDIO, ...)` produces a video/v210 definition. | **Fixed** | The generic discriminator-based builder was removed. Video and audio have separate schema types, and 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. |
|
||||
| 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`. |
|
||||
|
||||
## Additional implementation priorities
|
||||
|
||||
1. Fix the existing tests or update incorrect expectations after confirming the intended color values and overlay positioning.
|
||||
2. Add a typed audio flow-definition constructor, CPU audio generator, and continuous-flow writer loop using `OpenSamples`, `ChannelFragments`, and `Commit`.
|
||||
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.
|
||||
1. Add integration coverage for simultaneous video/audio startup, cancellation, and error propagation against an MXL instance.
|
||||
2. Measure end-to-end frame time and missed deadlines at 1080p50/60 and UHD. The current wgpu path may be adequate, but it is neither zero-copy nor asynchronous. Treat GPU readback optimization as a separate, potentially large task.
|
||||
|
||||
## Suggested package layout
|
||||
|
||||
@@ -119,12 +117,8 @@ The following command was used:
|
||||
GOCACHE=/tmp/go-mxl-gen-cache go test ./...
|
||||
```
|
||||
|
||||
Package compilation succeeds, but the generator package fails these tests:
|
||||
|
||||
- `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.
|
||||
All packages pass. `go vet ./...`, the application build, and `git diff --check` also succeed.
|
||||
|
||||
## 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
|
||||
+159
-243
@@ -5,8 +5,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -20,14 +18,15 @@ import (
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"mxl-pattern-generator/internal/app"
|
||||
"mxl-pattern-generator/internal/audio"
|
||||
"mxl-pattern-generator/internal/flowdef"
|
||||
"mxl-pattern-generator/internal/generator"
|
||||
"mxl-pattern-generator/internal/video"
|
||||
)
|
||||
|
||||
const (
|
||||
APP_NAME = "MXL pattern generator"
|
||||
APP_VER = "0.1.0"
|
||||
APP_NAME = app.Name
|
||||
APP_VER = app.Version
|
||||
)
|
||||
|
||||
type appArgs struct {
|
||||
@@ -44,13 +43,17 @@ type appArgs struct {
|
||||
overlayY int
|
||||
overlayPos string
|
||||
|
||||
videoWidth uint
|
||||
videoHeight uint
|
||||
videoFPS string
|
||||
videoUUID string
|
||||
videoWidth uint
|
||||
videoHeight uint
|
||||
videoFPS string
|
||||
videoUUID string
|
||||
noVideo bool
|
||||
videoAlpha bool
|
||||
videoBackend string
|
||||
|
||||
audioChannels uint8
|
||||
audioSamplingFreq string
|
||||
audioLevel string
|
||||
audioUUID string
|
||||
}
|
||||
|
||||
@@ -84,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, " 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, " 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, "Video and audio feed parameters are ignored when a flow definition file is provided.")
|
||||
fmt.Fprintln(w)
|
||||
@@ -92,7 +96,7 @@ func printHelp(w io.Writer, fs *pflag.FlagSet) {
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -131,15 +135,20 @@ func validateFlowDefPath(label, path string) 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)
|
||||
}
|
||||
if args.videoFlowDefFile != "" {
|
||||
return nil
|
||||
}
|
||||
if args.videoWidth == 0 || args.videoWidth%6 != 0 {
|
||||
// v210 stores 6 pixels in each 16-byte block.
|
||||
return fmt.Errorf("video width must be greater than zero and divisible by 6")
|
||||
if args.videoWidth == 0 || args.videoWidth%2 != 0 {
|
||||
return fmt.Errorf("video width must be greater than zero and even for 4:2:2 video")
|
||||
}
|
||||
if args.videoHeight == 0 {
|
||||
return fmt.Errorf("video height must be greater than zero")
|
||||
@@ -158,8 +167,34 @@ func validateVideoArgs(args *appArgs) error {
|
||||
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 {
|
||||
if args.audioFlowDefFile != "" || args.audioChannels == 0 {
|
||||
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := audioLevels[args.audioLevel]; !ok {
|
||||
return fmt.Errorf(
|
||||
"unsupported audio level %q (supported: %s)",
|
||||
args.audioLevel,
|
||||
sortedMapKeys(audioLevels),
|
||||
)
|
||||
}
|
||||
if args.audioFlowDefFile != "" {
|
||||
return nil
|
||||
}
|
||||
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
|
||||
@@ -179,6 +214,7 @@ func validateAudioArgs(args *appArgs) error {
|
||||
func validateArgs(args *appArgs) error {
|
||||
checks := []func() error{
|
||||
func() error { return validateDomain(args.domain) },
|
||||
func() error { return validateMediaSelection(*args) },
|
||||
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
|
||||
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
|
||||
func() error { return validateVideoArgs(args) },
|
||||
@@ -201,91 +237,23 @@ func sortedMapKeys[V any](values map[string]V) string {
|
||||
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{
|
||||
"ebu": audio.LevelEBUDBFS,
|
||||
"smpte": audio.LevelSMPTEDBFS,
|
||||
}
|
||||
|
||||
func listPatterns(w io.Writer) {
|
||||
fmt.Fprintln(w, "List of available video patterns:")
|
||||
names := make([]string, 0, len(patterns))
|
||||
patterns := video.Patterns()
|
||||
maxNameWidth := 0
|
||||
for name := range patterns {
|
||||
names = append(names, name)
|
||||
l := len(name)
|
||||
for _, pattern := range patterns {
|
||||
l := len(pattern.Name)
|
||||
if l > maxNameWidth {
|
||||
maxNameWidth = l
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
p := patterns[name]
|
||||
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, name, p.description)
|
||||
for _, pattern := range patterns {
|
||||
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, pattern.Name, pattern.Description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +263,7 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) {
|
||||
// MXL flags
|
||||
fs.StringVarP(&args.domain, "domain", "d", "", "MXL domain")
|
||||
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
|
||||
fs.StringVarP(&args.pattern, "pattern", "p", "ebu75", "Video pattern type")
|
||||
fs.BoolVar(&args.listPatterns, "list-patterns", false, "List video available video patterns and exit")
|
||||
@@ -321,14 +289,18 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) {
|
||||
"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.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS")
|
||||
fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided")
|
||||
fs.BoolVar(&args.noVideo, "no-video", false, "Disable video generation; audio must be enabled")
|
||||
fs.BoolVar(&args.videoAlpha, "alpha", false, "Generate video/v210a with a moving transparent square")
|
||||
fs.StringVar(&args.videoBackend, "backend", string(video.BackendAuto), "Video generator backend: auto, gpu or cpu")
|
||||
// Audio pattern flags
|
||||
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz. Zero = no sound [TODO]")
|
||||
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz [TODO]")
|
||||
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided [TODO]")
|
||||
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz")
|
||||
fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz")
|
||||
fs.StringVar(&args.audioLevel, "audio-level", "ebu", "Audio alignment level: ebu (-18 dBFS) or smpte (-20 dBFS)")
|
||||
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided")
|
||||
}
|
||||
|
||||
func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
|
||||
@@ -359,21 +331,41 @@ func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
|
||||
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
|
||||
if args.videoFlowDefFile != "" {
|
||||
data, err := os.ReadFile(args.videoFlowDefFile)
|
||||
if err != nil {
|
||||
return video.Config{}, fmt.Errorf(
|
||||
return nil, fmt.Errorf(
|
||||
"read video flow definition %q: %w",
|
||||
args.videoFlowDefFile,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
definition, err = flowdef.ParseV210Video(data)
|
||||
definition, err = flowdef.ParseVideo(data)
|
||||
if err != nil {
|
||||
return video.Config{}, fmt.Errorf(
|
||||
return nil, fmt.Errorf(
|
||||
"parse video flow definition %q: %w",
|
||||
args.videoFlowDefFile,
|
||||
err,
|
||||
@@ -382,11 +374,15 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
||||
} else {
|
||||
rate, ok := frameRates[args.videoFPS]
|
||||
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
|
||||
definition, err = flowdef.NewV210Video(
|
||||
newVideo := flowdef.NewV210Video
|
||||
if args.videoAlpha {
|
||||
newVideo = flowdef.NewV210AVideo
|
||||
}
|
||||
definition, err = newVideo(
|
||||
args.videoUUID,
|
||||
args.videoWidth,
|
||||
args.videoHeight,
|
||||
@@ -396,17 +392,22 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return video.Config{}, fmt.Errorf(
|
||||
return nil, fmt.Errorf(
|
||||
"build video flow definition: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, ok := patterns[args.pattern]; !ok {
|
||||
return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern)
|
||||
if !video.HasPattern(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,
|
||||
Pattern: args.pattern,
|
||||
Overlay: video.OverlayConfig{
|
||||
@@ -415,6 +416,50 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
||||
Y: args.overlayY,
|
||||
Position: args.overlayPos,
|
||||
},
|
||||
Backend: backend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildAudioConfig returns nil when audio is disabled. An explicit audio flow
|
||||
// definition enables audio even when --channel is zero.
|
||||
func buildAudioConfig(args appArgs) (*audio.Config, error) {
|
||||
if args.audioFlowDefFile == "" && args.audioChannels == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
levelDBFS, ok := audioLevels[args.audioLevel]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported audio level %q", args.audioLevel)
|
||||
}
|
||||
|
||||
var definition flowdef.Audio
|
||||
if args.audioFlowDefFile != "" {
|
||||
data, err := os.ReadFile(args.audioFlowDefFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||
}
|
||||
definition, err = flowdef.ParseFloat32Audio(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||
}
|
||||
} else {
|
||||
rate, ok := samplingRates[args.audioSamplingFreq]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported audio sample rate %q", args.audioSamplingFreq)
|
||||
}
|
||||
var err error
|
||||
definition, err = flowdef.NewFloat32Audio(
|
||||
args.audioUUID,
|
||||
uint(args.audioChannels),
|
||||
flowdef.Rational{Numerator: uint(rate.Num), Denominator: uint(rate.Den)},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build audio flow definition: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &audio.Config{
|
||||
Definition: definition,
|
||||
LevelDBFS: levelDBFS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -437,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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("video configuration: %w", err)
|
||||
}
|
||||
|
||||
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, "")
|
||||
audioCfg, err := buildAudioConfig(args)
|
||||
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) {
|
||||
pattern, ok := patterns[cfg.Pattern]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
|
||||
}
|
||||
|
||||
// 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
|
||||
return app.Run(ctx, app.Config{
|
||||
Domain: args.domain,
|
||||
Video: videoCfg,
|
||||
Audio: audioCfg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,25 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mxl-pattern-generator/internal/audio"
|
||||
"mxl-pattern-generator/internal/flowdef"
|
||||
"mxl-pattern-generator/internal/video"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestAlphaFlag(t *testing.T) {
|
||||
var args appArgs
|
||||
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
addFlags(flags, &args)
|
||||
if err := flags.Parse([]string{"--alpha"}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if !args.videoAlpha {
|
||||
t.Fatal("videoAlpha = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
@@ -94,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) {
|
||||
var output bytes.Buffer
|
||||
listPatterns(&output)
|
||||
@@ -123,6 +252,60 @@ func TestBuildVideoConfigFromArgs(t *testing.T) {
|
||||
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
|
||||
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
||||
}
|
||||
if cfg.Backend != video.BackendAuto {
|
||||
t.Fatalf("backend = %q, want %q", cfg.Backend, video.BackendAuto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoConfigWithAlpha(t *testing.T) {
|
||||
cfg, err := buildVideoConfig(appArgs{
|
||||
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
videoFPS: "50",
|
||||
pattern: "gray-ramp",
|
||||
videoAlpha: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildVideoConfig: %v", err)
|
||||
}
|
||||
if cfg.Definition.MediaType != flowdef.MediaTypeV210A {
|
||||
t.Fatalf("media type = %q, want %q", cfg.Definition.MediaType, flowdef.MediaTypeV210A)
|
||||
}
|
||||
if !cfg.HasAlpha() {
|
||||
t.Fatal("HasAlpha() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoConfigBackends(t *testing.T) {
|
||||
for _, backend := range []video.Backend{video.BackendGPU, video.BackendCPU} {
|
||||
t.Run(string(backend), func(t *testing.T) {
|
||||
cfg, err := buildVideoConfig(appArgs{
|
||||
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
videoFPS: "25",
|
||||
pattern: "ebu75",
|
||||
videoBackend: string(backend),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildVideoConfig: %v", err)
|
||||
}
|
||||
if cfg.Backend != backend {
|
||||
t.Fatalf("backend = %q, want %q", cfg.Backend, backend)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoConfigDisabled(t *testing.T) {
|
||||
cfg, err := buildVideoConfig(appArgs{noVideo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("buildVideoConfig: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("config = %+v, want nil for disabled video", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoConfigFromFile(t *testing.T) {
|
||||
@@ -162,3 +345,90 @@ func TestBuildVideoConfigFromFile(t *testing.T) {
|
||||
t.Fatalf("rate = %d/%d, want file value 60000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAudioConfigDisabled(t *testing.T) {
|
||||
cfg, err := buildAudioConfig(appArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAudioConfig: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("config = %+v, want nil for disabled audio", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAudioConfigFromArgs(t *testing.T) {
|
||||
cfg, err := buildAudioConfig(appArgs{
|
||||
audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||
audioChannels: 2,
|
||||
audioSamplingFreq: "48",
|
||||
audioLevel: "ebu",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAudioConfig: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("config is nil")
|
||||
}
|
||||
if cfg.Channels() != 2 {
|
||||
t.Fatalf("channels = %d, want 2", cfg.Channels())
|
||||
}
|
||||
if cfg.Rate().Num != 48000 || cfg.Rate().Den != 1 {
|
||||
t.Fatalf("rate = %d/%d, want 48000/1", cfg.Rate().Num, cfg.Rate().Den)
|
||||
}
|
||||
if cfg.LevelDBFS != audio.LevelEBUDBFS {
|
||||
t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelEBUDBFS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAudioConfigFromFile(t *testing.T) {
|
||||
definition, err := flowdef.NewFloat32Audio(
|
||||
"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||
8,
|
||||
flowdef.Rational{Numerator: 96000, Denominator: 1},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFloat32Audio: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
path := t.TempDir() + "/audio.json"
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := buildAudioConfig(appArgs{
|
||||
audioFlowDefFile: path,
|
||||
audioChannels: 2,
|
||||
audioSamplingFreq: "48",
|
||||
audioLevel: "smpte",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAudioConfig: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("config is nil")
|
||||
}
|
||||
if cfg.Channels() != 8 {
|
||||
t.Fatalf("channels = %d, want file value 8", cfg.Channels())
|
||||
}
|
||||
if cfg.Rate().Num != 96000 || cfg.Rate().Den != 1 {
|
||||
t.Fatalf("rate = %d/%d, want file value 96000/1", cfg.Rate().Num, cfg.Rate().Den)
|
||||
}
|
||||
if cfg.LevelDBFS != audio.LevelSMPTEDBFS {
|
||||
t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelSMPTEDBFS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAudioArgsRejectsUnknownLevelForFlowDefinition(t *testing.T) {
|
||||
args := appArgs{
|
||||
audioFlowDefFile: "audio.json",
|
||||
audioLevel: "unknown",
|
||||
}
|
||||
|
||||
err := validateAudioArgs(&args)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported audio level") {
|
||||
t.Fatalf("error = %v, want unsupported audio level error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"mxl-pattern-generator/internal/audio"
|
||||
"mxl-pattern-generator/internal/video"
|
||||
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "MXL pattern generator"
|
||||
Version = "0.2.0"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Domain string
|
||||
Video *video.Config
|
||||
Audio *audio.Config
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg Config) (runErr error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("%s %s", Name, Version)
|
||||
log.Printf("Domain: %s", cfg.Domain)
|
||||
if cfg.Video != nil {
|
||||
log.Printf("Video: %dx%d %d/%d",
|
||||
cfg.Video.Width(), cfg.Video.Height(), cfg.Video.Rate().Num, cfg.Video.Rate().Den)
|
||||
log.Printf("Video ID: %s", cfg.Video.ID())
|
||||
}
|
||||
if cfg.Audio != nil {
|
||||
log.Printf("Audio: %d channels %d/%d Hz %.0f dBFS",
|
||||
cfg.Audio.Channels(), cfg.Audio.Rate().Num, cfg.Audio.Rate().Den, cfg.Audio.LevelDBFS)
|
||||
log.Printf("Audio ID: %s", cfg.Audio.ID())
|
||||
}
|
||||
inst, err := mxl.NewInstance(cfg.Domain, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize MXL domain %q: %w", cfg.Domain, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := inst.Close(); err != nil {
|
||||
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
runners := make([]Runner, 0, 2)
|
||||
if cfg.Video != nil {
|
||||
runners = append(runners, Runner{
|
||||
Name: "video",
|
||||
Run: func(ctx context.Context) error {
|
||||
return video.Run(ctx, inst, *cfg.Video)
|
||||
},
|
||||
})
|
||||
}
|
||||
if cfg.Audio != nil {
|
||||
runners = append(runners, Runner{
|
||||
Name: "audio",
|
||||
Run: func(ctx context.Context) error {
|
||||
return audio.Run(ctx, inst, *cfg.Audio)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return RunConcurrent(ctx, runners...)
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.Video == nil && cfg.Audio == nil {
|
||||
return fmt.Errorf("at least one media flow must be enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateConfigRejectsNoFlows(t *testing.T) {
|
||||
err := validateConfig(Config{
|
||||
Domain: "/unused",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "at least one media flow") {
|
||||
t.Fatalf("error = %v, want no-flow validation error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
Name string
|
||||
Run func(context.Context) error
|
||||
}
|
||||
|
||||
type runnerResult struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func RunConcurrent(ctx context.Context, runners ...Runner) error {
|
||||
if len(runners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
results := make(chan runnerResult, len(runners))
|
||||
for _, runner := range runners {
|
||||
runner := runner
|
||||
go func() {
|
||||
results <- runnerResult{name: runner.Name, err: runner.Run(ctx)}
|
||||
}()
|
||||
}
|
||||
|
||||
var resultErr error
|
||||
for range runners {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("%s flow: %w", result.name, result.err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return resultErr
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mxl-pattern-generator/internal/app"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunConcurrentCancelsSiblingAndWaitsForCleanup(t *testing.T) {
|
||||
wantErr := errors.New("writer failed")
|
||||
peerStarted := make(chan struct{})
|
||||
peerStopped := make(chan struct{})
|
||||
|
||||
err := app.RunConcurrent(context.Background(),
|
||||
app.Runner{
|
||||
Name: "video",
|
||||
Run: func(ctx context.Context) error {
|
||||
<-peerStarted
|
||||
return wantErr
|
||||
},
|
||||
},
|
||||
app.Runner{
|
||||
Name: "audio",
|
||||
Run: func(ctx context.Context) error {
|
||||
close(peerStarted)
|
||||
<-ctx.Done()
|
||||
close(peerStopped)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "video flow") {
|
||||
t.Fatalf("error = %q, want runner name", err)
|
||||
}
|
||||
select {
|
||||
case <-peerStopped:
|
||||
default:
|
||||
t.Fatal("runConcurrent returned before the sibling completed cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConcurrentParentCancellationIsGraceful(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
started := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- app.RunConcurrent(ctx, app.Runner{
|
||||
Name: "video",
|
||||
Run: func(ctx context.Context) error {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
<-started
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("runConcurrent: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("runConcurrent did not stop after parent cancellation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"mxl-pattern-generator/internal/flowdef"
|
||||
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
)
|
||||
|
||||
const (
|
||||
LevelEBUDBFS = -18.0
|
||||
LevelSMPTEDBFS = -20.0
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Definition flowdef.Audio
|
||||
LevelDBFS float64
|
||||
}
|
||||
|
||||
func (c Config) ID() string {
|
||||
return c.Definition.ID
|
||||
}
|
||||
|
||||
func (c Config) Channels() uint {
|
||||
return c.Definition.ChannelCount
|
||||
}
|
||||
|
||||
func (c Config) Rate() mxl.Rational {
|
||||
return mxl.Rational{
|
||||
Num: int64(c.Definition.SampleRate.Numerator),
|
||||
Den: int64(c.Definition.SampleRate.Denominator),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type Generator interface {
|
||||
// Generate fills consecutive float32 sample fragments for one channel.
|
||||
// firstSample is the absolute MXL sample index, so phase does not depend on
|
||||
// batch or ring-buffer boundaries.
|
||||
Generate(channel uint, firstSample uint64, fragments ...[]byte) error
|
||||
}
|
||||
|
||||
type SineGenerator struct {
|
||||
channels uint
|
||||
sampleRate float64
|
||||
baseFrequency float64
|
||||
amplitude float32
|
||||
}
|
||||
|
||||
func NewSineGenerator(
|
||||
cfg Config,
|
||||
baseFrequency float64,
|
||||
) (*SineGenerator, error) {
|
||||
if cfg.Channels() == 0 {
|
||||
return nil, fmt.Errorf("channel count must be greater than zero")
|
||||
}
|
||||
rate := cfg.Rate()
|
||||
if rate.Num <= 0 || rate.Den <= 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"sample rate numerator and denominator must be greater than zero, got %d/%d",
|
||||
rate.Num,
|
||||
rate.Den,
|
||||
)
|
||||
}
|
||||
if math.IsNaN(baseFrequency) || math.IsInf(baseFrequency, 0) || baseFrequency <= 0 {
|
||||
return nil, fmt.Errorf("base frequency must be finite and greater than zero, got %g", baseFrequency)
|
||||
}
|
||||
|
||||
// Keep every generated tone below the Nyquist frequency (the
|
||||
// Kotelnikov–Nyquist–Shannon sampling limit), sampleRate/2
|
||||
// Nyquist leaved as compromise for common English terminology
|
||||
sampleRate := float64(rate.Num) / float64(rate.Den)
|
||||
highestFrequency := float64(cfg.Channels()) * baseFrequency
|
||||
nyquist := sampleRate / 2
|
||||
if highestFrequency >= nyquist {
|
||||
return nil, fmt.Errorf(
|
||||
"highest tone frequency %.0f Hz must be below Nyquist frequency %.0f Hz",
|
||||
highestFrequency,
|
||||
nyquist,
|
||||
)
|
||||
}
|
||||
|
||||
if math.IsNaN(cfg.LevelDBFS) || math.IsInf(cfg.LevelDBFS, 0) {
|
||||
return nil, fmt.Errorf("audio level must be finite")
|
||||
}
|
||||
if cfg.LevelDBFS > 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"audio level must not exceed 0 dBFS, got %.2f",
|
||||
cfg.LevelDBFS,
|
||||
)
|
||||
}
|
||||
if cfg.LevelDBFS < -100 {
|
||||
return nil, fmt.Errorf(
|
||||
"audio level must be at least -100 dBFS, got %.2f dBFS",
|
||||
cfg.LevelDBFS,
|
||||
)
|
||||
}
|
||||
|
||||
amplitude := math.Pow(10, cfg.LevelDBFS/20)
|
||||
return &SineGenerator{
|
||||
channels: cfg.Channels(),
|
||||
sampleRate: sampleRate,
|
||||
baseFrequency: baseFrequency,
|
||||
amplitude: float32(amplitude),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *SineGenerator) Generate(
|
||||
channel uint,
|
||||
firstSample uint64,
|
||||
fragments ...[]byte,
|
||||
) error {
|
||||
if channel >= g.channels {
|
||||
return fmt.Errorf(
|
||||
"audio channel %d is out of range [0, %d)",
|
||||
channel,
|
||||
g.channels,
|
||||
)
|
||||
}
|
||||
|
||||
frequency := float64(channel+1) * g.baseFrequency
|
||||
sampleIndex := firstSample
|
||||
|
||||
for fragmentIndex, fragment := range fragments {
|
||||
if len(fragment)%4 != 0 {
|
||||
return fmt.Errorf(
|
||||
"audio fragment %d has %d bytes; float32 data requires a multiple of 4",
|
||||
fragmentIndex,
|
||||
len(fragment),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fragment := range fragments {
|
||||
for offset := 0; offset < len(fragment); offset += 4 {
|
||||
phase := 2 * math.Pi *
|
||||
frequency *
|
||||
float64(sampleIndex) /
|
||||
g.sampleRate
|
||||
|
||||
sample := g.amplitude * float32(math.Sin(phase))
|
||||
|
||||
binary.LittleEndian.PutUint32(
|
||||
fragment[offset:offset+4],
|
||||
math.Float32bits(sample),
|
||||
)
|
||||
sampleIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"mxl-pattern-generator/internal/flowdef"
|
||||
)
|
||||
|
||||
const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||
|
||||
func testConfig(t *testing.T, channels uint, rate flowdef.Rational, level float64) Config {
|
||||
t.Helper()
|
||||
definition, err := flowdef.NewFloat32Audio(testAudioID, channels, rate)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFloat32Audio: %v", err)
|
||||
}
|
||||
return Config{Definition: definition, LevelDBFS: level}
|
||||
}
|
||||
|
||||
func decodeSample(data []byte, index int) float32 {
|
||||
return math.Float32frombits(binary.LittleEndian.Uint32(data[index*4:]))
|
||||
}
|
||||
|
||||
func requireSampleNear(t *testing.T, got, want float32) {
|
||||
t.Helper()
|
||||
if math.Abs(float64(got-want)) > 1e-6 {
|
||||
t.Fatalf("sample = %.8f, want %.8f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSineGeneratorValidation(t *testing.T) {
|
||||
valid := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
baseHz float64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", cfg: valid, baseHz: 1000},
|
||||
{name: "zero channels", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000, Denominator: 1}}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||
{name: "zero numerator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Denominator: 1}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||
{name: "zero denominator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true},
|
||||
{name: "zero base", cfg: valid, baseHz: 0, wantErr: true},
|
||||
{name: "NaN base", cfg: valid, baseHz: math.NaN(), wantErr: true},
|
||||
{name: "NaN level", cfg: Config{Definition: valid.Definition, LevelDBFS: math.NaN()}, baseHz: 1000, wantErr: true},
|
||||
{name: "above full scale", cfg: Config{Definition: valid.Definition, LevelDBFS: 1}, baseHz: 1000, wantErr: true},
|
||||
{name: "too quiet", cfg: Config{Definition: valid.Definition, LevelDBFS: -101}, baseHz: 1000, wantErr: true},
|
||||
{name: "Nyquist", cfg: testConfig(t, 24, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS), baseHz: 1000, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewSineGenerator(tc.cfg, tc.baseHz)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSineGeneratorPreservesRationalSampleRate(t *testing.T) {
|
||||
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1001}, LevelEBUDBFS)
|
||||
gen, err := NewSineGenerator(cfg, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSineGenerator: %v", err)
|
||||
}
|
||||
want := 48000.0 / 1001.0
|
||||
if math.Abs(gen.sampleRate-want) > 1e-12 {
|
||||
t.Fatalf("sample rate = %.12f, want %.12f", gen.sampleRate, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSineGeneratorKnownSamplesAndChannels(t *testing.T) {
|
||||
cfg := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelSMPTEDBFS)
|
||||
gen, err := NewSineGenerator(cfg, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSineGenerator: %v", err)
|
||||
}
|
||||
|
||||
channel0 := make([]byte, 37*4)
|
||||
if err := gen.Generate(0, 0, channel0); err != nil {
|
||||
t.Fatalf("Generate channel 0: %v", err)
|
||||
}
|
||||
requireSampleNear(t, decodeSample(channel0, 0), 0)
|
||||
requireSampleNear(t, decodeSample(channel0, 12), 0.1)
|
||||
requireSampleNear(t, decodeSample(channel0, 24), 0)
|
||||
requireSampleNear(t, decodeSample(channel0, 36), -0.1)
|
||||
|
||||
channel1 := make([]byte, 7*4)
|
||||
if err := gen.Generate(1, 0, channel1); err != nil {
|
||||
t.Fatalf("Generate channel 1: %v", err)
|
||||
}
|
||||
requireSampleNear(t, decodeSample(channel1, 6), 0.1)
|
||||
}
|
||||
|
||||
func TestSineGeneratorUsesConfiguredBaseFrequency(t *testing.T) {
|
||||
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||
gen, err := NewSineGenerator(cfg, 2000)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSineGenerator: %v", err)
|
||||
}
|
||||
|
||||
data := make([]byte, 7*4)
|
||||
if err := gen.Generate(0, 0, data); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
wantPeak := float32(math.Pow(10, LevelEBUDBFS/20))
|
||||
requireSampleNear(t, decodeSample(data, 6), wantPeak)
|
||||
}
|
||||
|
||||
func TestSineGeneratorFragmentContinuity(t *testing.T) {
|
||||
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||
gen, err := NewSineGenerator(cfg, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSineGenerator: %v", err)
|
||||
}
|
||||
|
||||
whole := make([]byte, 40*4)
|
||||
if err := gen.Generate(0, 100, whole); err != nil {
|
||||
t.Fatalf("Generate whole: %v", err)
|
||||
}
|
||||
first := make([]byte, 13*4)
|
||||
second := make([]byte, 27*4)
|
||||
if err := gen.Generate(0, 100, first, second); err != nil {
|
||||
t.Fatalf("Generate fragments: %v", err)
|
||||
}
|
||||
joined := append(append([]byte(nil), first...), second...)
|
||||
if !bytes.Equal(joined, whole) {
|
||||
t.Fatal("fragmented output differs from contiguous output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSineGeneratorRejectsInvalidInputWithoutWriting(t *testing.T) {
|
||||
cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS)
|
||||
gen, err := NewSineGenerator(cfg, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSineGenerator: %v", err)
|
||||
}
|
||||
|
||||
first := bytes.Repeat([]byte{0xAA}, 8)
|
||||
before := append([]byte(nil), first...)
|
||||
if err := gen.Generate(0, 0, first, make([]byte, 3)); err == nil {
|
||||
t.Fatal("Generate accepted a misaligned fragment")
|
||||
}
|
||||
if !bytes.Equal(first, before) {
|
||||
t.Fatal("Generate modified data before reporting an invalid fragment")
|
||||
}
|
||||
if err := gen.Generate(1, 0, make([]byte, 4)); err == nil {
|
||||
t.Fatal("Generate accepted an out-of-range channel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
)
|
||||
|
||||
func Run(
|
||||
ctx context.Context,
|
||||
inst *mxl.Instance,
|
||||
cfg Config,
|
||||
) (runErr error) {
|
||||
flowJSON, err := json.Marshal(cfg.Definition)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audio flow definition: %w", err)
|
||||
}
|
||||
|
||||
writer, isCreated, err := inst.NewWriter(string(flowJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create audio writer: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
runErr = errors.Join(
|
||||
runErr,
|
||||
fmt.Errorf("close audio writer: %w", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
if !isCreated {
|
||||
log.Printf("reusing existing audio flow: %s", cfg.ID())
|
||||
}
|
||||
|
||||
writerCfg := writer.Config()
|
||||
if writerCfg.Common.Format != mxl.FormatAudio {
|
||||
return fmt.Errorf(
|
||||
"audio writer has format %s, want audio",
|
||||
writerCfg.Common.Format,
|
||||
)
|
||||
}
|
||||
if writerCfg.Continuous.ChannelCount != uint32(cfg.Channels()) {
|
||||
return fmt.Errorf(
|
||||
"audio writer has %d channels, configured generator expects %d",
|
||||
writerCfg.Continuous.ChannelCount,
|
||||
cfg.Channels(),
|
||||
)
|
||||
}
|
||||
|
||||
rate := writerCfg.Common.GrainRate
|
||||
if rate != cfg.Rate() {
|
||||
return fmt.Errorf(
|
||||
"audio writer has sample rate %d/%d, configured generator expects %d/%d",
|
||||
rate.Num,
|
||||
rate.Den,
|
||||
cfg.Rate().Num,
|
||||
cfg.Rate().Den,
|
||||
)
|
||||
}
|
||||
|
||||
const baseFrequency = 1000.0
|
||||
|
||||
gen, err := NewSineGenerator(cfg, baseFrequency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize audio generator: %w", err)
|
||||
}
|
||||
|
||||
batch := batchSize(rate)
|
||||
maxBatch, err := writer.GetMaxWriteLengthSamples()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get maximum audio write length: %w", err)
|
||||
}
|
||||
if maxBatch == 0 {
|
||||
return fmt.Errorf("audio writer reported a maximum write length of zero samples")
|
||||
}
|
||||
if batch > maxBatch {
|
||||
batch = maxBatch
|
||||
}
|
||||
index := mxl.CurrentIndex(rate)
|
||||
if index < batch-1 {
|
||||
return fmt.Errorf("current audio index %d is too small for batch size %d", index, batch)
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"writing audio flow sampleRate=%d/%d channels=%d batch=%d starting at idx=%d",
|
||||
rate.Num,
|
||||
rate.Den,
|
||||
cfg.Channels(),
|
||||
batch,
|
||||
index,
|
||||
)
|
||||
|
||||
var samplesWritten uint64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("stopping audio after %d samples", samplesWritten)
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
access, err := writer.OpenSamples(index, int(batch))
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"open %d audio samples at index %d: %w",
|
||||
batch,
|
||||
index,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
firstSample := index - batch + 1
|
||||
for channel := uint64(0); channel < access.ChannelCount; channel++ {
|
||||
first, second, err := access.ChannelFragments(channel)
|
||||
if err != nil {
|
||||
return cancelSamples(
|
||||
access,
|
||||
fmt.Errorf(
|
||||
"get fragments for audio channel %d at index %d: %w",
|
||||
channel,
|
||||
index,
|
||||
err,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if err := gen.Generate(
|
||||
uint(channel),
|
||||
firstSample,
|
||||
first,
|
||||
second,
|
||||
); err != nil {
|
||||
return cancelSamples(
|
||||
access, fmt.Errorf(
|
||||
"generate audio channel %d at index %d: %w",
|
||||
channel,
|
||||
index,
|
||||
err,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := access.Commit(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"commit %d audio samples at index %d: %w",
|
||||
batch,
|
||||
index,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
samplesWritten += batch
|
||||
index += batch
|
||||
mxl.SleepNs(mxl.NsUntilIndex(index, rate))
|
||||
}
|
||||
}
|
||||
|
||||
func cancelSamples(
|
||||
access *mxl.SamplesWriteAccess,
|
||||
cause error,
|
||||
) error {
|
||||
if err := access.Cancel(); err != nil {
|
||||
return errors.Join(
|
||||
cause,
|
||||
fmt.Errorf("cancel audio samples: %w", err),
|
||||
)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func batchSize(rate mxl.Rational) uint64 {
|
||||
if rate.Num <= 0 || rate.Den <= 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
samples := rate.Num / (100 * rate.Den)
|
||||
if samples < 1 {
|
||||
return 1
|
||||
}
|
||||
return uint64(samples)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
)
|
||||
|
||||
func TestBatchSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rate mxl.Rational
|
||||
want uint64
|
||||
}{
|
||||
{name: "44.1 kHz", rate: mxl.Rational{Num: 44100, Den: 1}, want: 441},
|
||||
{name: "48 kHz", rate: mxl.Rational{Num: 48000, Den: 1}, want: 480},
|
||||
{name: "96 kHz", rate: mxl.Rational{Num: 96000, Den: 1}, want: 960},
|
||||
{name: "192 kHz", rate: mxl.Rational{Num: 192000, Den: 1}, want: 1920},
|
||||
{name: "minimum", rate: mxl.Rational{Num: 1, Den: 1}, want: 1},
|
||||
{name: "zero numerator", rate: mxl.Rational{Num: 0, Den: 1}, want: 1},
|
||||
{name: "zero denominator", rate: mxl.Rational{Num: 48000, Den: 0}, want: 1},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := batchSize(tc.rate); got != tc.want {
|
||||
t.Fatalf("batchSize(%d/%d) = %d, want %d", tc.rate.Num, tc.rate.Den, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+106
-10
@@ -9,9 +9,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
FormatVideo = "urn:x-nmos:format:video"
|
||||
FormatAudio = "urn:x-nmos:format:audio"
|
||||
MediaTypeV210 = "video/v210"
|
||||
FormatVideo = "urn:x-nmos:format:video"
|
||||
FormatAudio = "urn:x-nmos:format:audio"
|
||||
MediaTypeV210 = "video/v210"
|
||||
MediaTypeV210A = "video/v210a"
|
||||
MediaTypeFloat32 = "audio/float32"
|
||||
|
||||
InterlaceProgressive = "progressive"
|
||||
ColorSpaceBT709 = "BT709"
|
||||
@@ -32,6 +34,22 @@ type Rational struct {
|
||||
Denominator uint `json:"denominator"`
|
||||
}
|
||||
|
||||
func (r *Rational) UnmarshalJSON(data []byte) error {
|
||||
var value struct {
|
||||
Numerator uint `json:"numerator"`
|
||||
Denominator *uint `json:"denominator"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Numerator = value.Numerator
|
||||
r.Denominator = 1
|
||||
if value.Denominator != nil {
|
||||
r.Denominator = *value.Denominator
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Video struct {
|
||||
Common
|
||||
|
||||
@@ -58,7 +76,12 @@ type VideoComponent struct {
|
||||
BitDepth uint `json:"bit_depth"`
|
||||
}
|
||||
|
||||
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||
func newVideo(
|
||||
id string,
|
||||
width, height uint,
|
||||
rate Rational,
|
||||
mediaType string,
|
||||
) (Video, error) {
|
||||
definition := Video{
|
||||
Common: Common{
|
||||
Description: "go-mxl-pattern-gen generated video",
|
||||
@@ -69,7 +92,7 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||
Format: FormatVideo,
|
||||
Label: "go-mxl-pattern-gen generated video",
|
||||
Parents: []string{},
|
||||
MediaType: MediaTypeV210,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
GrainRate: rate,
|
||||
FrameWidth: width,
|
||||
@@ -88,7 +111,15 @@ func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func ParseV210Video(data []byte) (Video, error) {
|
||||
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
|
||||
return newVideo(id, width, height, rate, MediaTypeV210)
|
||||
}
|
||||
|
||||
func NewV210AVideo(id string, width, height uint, rate Rational) (Video, error) {
|
||||
return newVideo(id, width, height, rate, MediaTypeV210A)
|
||||
}
|
||||
|
||||
func ParseVideo(data []byte) (Video, error) {
|
||||
var definition Video
|
||||
if err := json.Unmarshal(data, &definition); err != nil {
|
||||
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
|
||||
@@ -106,8 +137,10 @@ func (v Video) Validate() error {
|
||||
if v.Format != FormatVideo {
|
||||
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
|
||||
}
|
||||
if v.MediaType != MediaTypeV210 {
|
||||
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, v.MediaType)
|
||||
switch v.MediaType {
|
||||
case MediaTypeV210, MediaTypeV210A:
|
||||
default:
|
||||
return fmt.Errorf("media_type must be %q or %q, got %q", MediaTypeV210, MediaTypeV210A, v.MediaType)
|
||||
}
|
||||
if v.InterlaceMode != InterlaceProgressive {
|
||||
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
|
||||
@@ -115,8 +148,8 @@ func (v Video) Validate() error {
|
||||
if v.ColorSpace != ColorSpaceBT709 {
|
||||
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
|
||||
}
|
||||
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 {
|
||||
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth)
|
||||
if v.FrameWidth == 0 || v.FrameWidth%2 != 0 {
|
||||
return fmt.Errorf("frame_width must be greater than zero and even for 4:2:2 video, got %d", v.FrameWidth)
|
||||
}
|
||||
if v.FrameHeight == 0 {
|
||||
return fmt.Errorf("frame_height must be greater than zero")
|
||||
@@ -141,3 +174,66 @@ func (v Video) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFloat32Audio(
|
||||
id string,
|
||||
channels uint,
|
||||
rate Rational,
|
||||
) (Audio, error) {
|
||||
definition := Audio{
|
||||
Common: Common{
|
||||
Description: "go-mxl-pattern-gen generated audio",
|
||||
ID: id,
|
||||
Tags: map[string][]string{
|
||||
"urn:x-nmos:tag:grouphint/v1.0": {
|
||||
"go-mxl-pattern-gen:Audio",
|
||||
},
|
||||
},
|
||||
Format: FormatAudio,
|
||||
Label: "go-mxl-pattern-gen generated audio",
|
||||
Parents: []string{},
|
||||
MediaType: MediaTypeFloat32,
|
||||
},
|
||||
SampleRate: rate,
|
||||
ChannelCount: channels,
|
||||
BitDepth: 32,
|
||||
}
|
||||
if err := definition.Validate(); err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (a Audio) Validate() error {
|
||||
if err := uuid.Validate(a.ID); err != nil {
|
||||
return fmt.Errorf("invalid id %q: %w", a.ID, err)
|
||||
}
|
||||
if a.Format != FormatAudio {
|
||||
return fmt.Errorf("format must be %q, got %q", FormatAudio, a.Format)
|
||||
}
|
||||
if a.MediaType != MediaTypeFloat32 {
|
||||
return fmt.Errorf("media_type must be %q, got %q", MediaTypeFloat32, a.MediaType)
|
||||
}
|
||||
if a.ChannelCount == 0 {
|
||||
return fmt.Errorf("channel_count must be greater than zero")
|
||||
}
|
||||
if a.SampleRate.Numerator == 0 || a.SampleRate.Denominator == 0 {
|
||||
return fmt.Errorf("sample_rate numerator and denominator must be greater than zero, got %d/%d",
|
||||
a.SampleRate.Numerator, a.SampleRate.Denominator)
|
||||
}
|
||||
if a.BitDepth != 32 {
|
||||
return fmt.Errorf("bit_depth must be 32, got %d", a.BitDepth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseFloat32Audio(data []byte) (Audio, error) {
|
||||
var definition Audio
|
||||
if err := json.Unmarshal(data, &definition); err != nil {
|
||||
return Audio{}, fmt.Errorf("decode audio flow definition: %w", err)
|
||||
}
|
||||
if err := definition.Validate(); err != nil {
|
||||
return Audio{}, fmt.Errorf("invalid audio flow definition: %w", err)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
||||
const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||
|
||||
func TestNewV210Video(t *testing.T) {
|
||||
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 30000, Denominator: 1001})
|
||||
@@ -27,14 +28,37 @@ func TestNewV210Video(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewV210VideoRejectsInvalidWidth(t *testing.T) {
|
||||
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "divisible by 6") {
|
||||
t.Fatalf("error = %v, want width divisibility error", err)
|
||||
func TestNewV210AVideo(t *testing.T) {
|
||||
definition, err := NewV210AVideo(testVideoID, 1280, 720, Rational{Numerator: 50, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210AVideo: %v", err)
|
||||
}
|
||||
if definition.MediaType != MediaTypeV210A {
|
||||
t.Fatalf("media type = %q, want %q", definition.MediaType, MediaTypeV210A)
|
||||
}
|
||||
wantComponents := []VideoComponent{
|
||||
{Name: "Y", Width: 1280, Height: 720, BitDepth: 10},
|
||||
{Name: "Cb", Width: 640, Height: 720, BitDepth: 10},
|
||||
{Name: "Cr", Width: 640, Height: 720, BitDepth: 10},
|
||||
}
|
||||
if len(definition.Components) != len(wantComponents) {
|
||||
t.Fatalf("component count = %d, want %d", len(definition.Components), len(wantComponents))
|
||||
}
|
||||
for i, want := range wantComponents {
|
||||
if definition.Components[i] != want {
|
||||
t.Fatalf("component %d = %+v, want %+v", i, definition.Components[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func 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})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210Video: %v", err)
|
||||
@@ -44,24 +68,164 @@ func TestParseV210Video(t *testing.T) {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
got, err := ParseV210Video(data)
|
||||
got, err := ParseVideo(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseV210Video: %v", err)
|
||||
t.Fatalf("ParseVideo: %v", err)
|
||||
}
|
||||
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
|
||||
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseV210VideoRejectsAudio(t *testing.T) {
|
||||
func TestParseVideoAcceptsV210A(t *testing.T) {
|
||||
want, err := NewV210AVideo(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210AVideo: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
got, err := ParseVideo(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVideo: %v", err)
|
||||
}
|
||||
if got.MediaType != MediaTypeV210A {
|
||||
t.Fatalf("media type = %q, want %q", got.MediaType, MediaTypeV210A)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVideoRejectsAudio(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
|
||||
"format":"urn:x-nmos:format:audio",
|
||||
"media_type":"audio/float32"
|
||||
}`)
|
||||
|
||||
_, err := ParseV210Video(data)
|
||||
_, err := ParseVideo(data)
|
||||
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||
t.Fatalf("error = %v, want video format error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoRejectsUnknownMediaType(t *testing.T) {
|
||||
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210Video: %v", err)
|
||||
}
|
||||
definition.MediaType = "video/unknown"
|
||||
if err := definition.Validate(); err == nil || !strings.Contains(err.Error(), "media_type") {
|
||||
t.Fatalf("error = %v, want media_type error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFloat32Audio(t *testing.T) {
|
||||
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFloat32Audio: %v", err)
|
||||
}
|
||||
if definition.ID != testAudioID {
|
||||
t.Fatalf("ID = %q, want %q", definition.ID, testAudioID)
|
||||
}
|
||||
if definition.Format != FormatAudio || definition.MediaType != MediaTypeFloat32 {
|
||||
t.Fatalf("format/media type = %q/%q", definition.Format, definition.MediaType)
|
||||
}
|
||||
if definition.ChannelCount != 2 || definition.BitDepth != 32 {
|
||||
t.Fatalf("channels/bit depth = %d/%d, want 2/32", definition.ChannelCount, definition.BitDepth)
|
||||
}
|
||||
if definition.SampleRate != (Rational{Numerator: 48000, Denominator: 1}) {
|
||||
t.Fatalf("sample rate = %+v, want 48000/1", definition.SampleRate)
|
||||
}
|
||||
if definition.Parents == nil {
|
||||
t.Fatal("Parents is nil; want an empty JSON array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFloat32AudioRejectsZeroChannels(t *testing.T) {
|
||||
_, err := NewFloat32Audio(testAudioID, 0, Rational{Numerator: 48000, Denominator: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "channel_count") {
|
||||
t.Fatalf("error = %v, want channel_count error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFloat32AudioRejectsInvalidRate(t *testing.T) {
|
||||
_, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 0})
|
||||
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||
t.Fatalf("error = %v, want sample_rate error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat32Audio(t *testing.T) {
|
||||
want, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFloat32Audio: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
got, err := ParseFloat32Audio(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||
}
|
||||
if got.ID != want.ID || got.ChannelCount != want.ChannelCount || got.SampleRate != want.SampleRate {
|
||||
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat32AudioDefaultsDenominator(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"description":"test audio",
|
||||
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||
"tags":{},
|
||||
"format":"urn:x-nmos:format:audio",
|
||||
"label":"test audio",
|
||||
"parents":[],
|
||||
"media_type":"audio/float32",
|
||||
"sample_rate":{"numerator":48000},
|
||||
"channel_count":2,
|
||||
"bit_depth":32
|
||||
}`)
|
||||
|
||||
definition, err := ParseFloat32Audio(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||
}
|
||||
if definition.SampleRate.Denominator != 1 {
|
||||
t.Fatalf("denominator = %d, want implicit 1", definition.SampleRate.Denominator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat32AudioRejectsExplicitZeroDenominator(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||
"format":"urn:x-nmos:format:audio",
|
||||
"media_type":"audio/float32",
|
||||
"sample_rate":{"numerator":48000,"denominator":0},
|
||||
"channel_count":2,
|
||||
"bit_depth":32
|
||||
}`)
|
||||
|
||||
_, err := ParseFloat32Audio(data)
|
||||
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||
t.Fatalf("error = %v, want sample_rate error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat32AudioRejectsVideo(t *testing.T) {
|
||||
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("NewV210Video: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseFloat32Audio(data)
|
||||
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||
t.Fatalf("error = %v, want audio format error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
alphaTransparent uint32 = 64
|
||||
alphaOpaque uint32 = 940
|
||||
)
|
||||
|
||||
func packAlphaBlock(dst []byte, samples [3]uint32) {
|
||||
const mask uint32 = 0x3ff
|
||||
|
||||
word := samples[0]&mask |
|
||||
(samples[1]&mask)<<10 |
|
||||
(samples[2]&mask)<<20
|
||||
|
||||
binary.LittleEndian.PutUint32(dst, word)
|
||||
}
|
||||
|
||||
func fillAlphaPlane(
|
||||
dst []byte,
|
||||
width, height int,
|
||||
value uint32,
|
||||
) error {
|
||||
need := AlphaFrameSize(width, height)
|
||||
if len(dst) < need {
|
||||
return fmt.Errorf(
|
||||
"alpha: destination is too small: got %d bytes, need %d",
|
||||
len(dst),
|
||||
need,
|
||||
)
|
||||
}
|
||||
|
||||
stride := AlphaLineSize(width)
|
||||
for y := 0; y < height; y++ {
|
||||
row := dst[y*stride : (y+1)*stride]
|
||||
for x := 0; x < width; x += 3 {
|
||||
var samples [3]uint32
|
||||
for i := range samples {
|
||||
if x+i < width {
|
||||
samples[i] = value
|
||||
}
|
||||
}
|
||||
packAlphaBlock(row[x/3*4:], samples)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchAlphaMovingSquare(dst []byte, width, height, frameIndex int) error {
|
||||
need := AlphaFrameSize(width, height)
|
||||
if len(dst) < need {
|
||||
return fmt.Errorf(
|
||||
"alpha: destination is too small: got %d bytes, need %d",
|
||||
len(dst),
|
||||
need,
|
||||
)
|
||||
}
|
||||
|
||||
bounds := movingSquareBounds(width, height, frameIndex)
|
||||
firstPixelX := max(0, int(math.Floor(bounds.minX)))
|
||||
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
|
||||
firstBlockX := firstPixelX / 3 * 3
|
||||
lastBlockX := min(width, (lastPixelX+2)/3*3)
|
||||
firstY := max(0, int(math.Floor(bounds.minY)))
|
||||
lastY := min(height, int(math.Ceil(bounds.maxY)))
|
||||
stride := AlphaLineSize(width)
|
||||
|
||||
for y := firstY; y < lastY; y++ {
|
||||
for blockX := firstBlockX; blockX < lastBlockX; blockX += 3 {
|
||||
var samples [3]uint32
|
||||
for i := range samples {
|
||||
x := blockX + i
|
||||
switch {
|
||||
case x >= width:
|
||||
samples[i] = 0
|
||||
case bounds.contains(x, y):
|
||||
samples[i] = alphaTransparent
|
||||
default:
|
||||
samples[i] = alphaOpaque
|
||||
}
|
||||
}
|
||||
offset := y*stride + blockX/3*4
|
||||
packAlphaBlock(dst[offset:], samples)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPackAlphaBlock(t *testing.T) {
|
||||
var dst [4]byte
|
||||
packAlphaBlock(dst[:], [3]uint32{64, 512, 940})
|
||||
|
||||
word := binary.LittleEndian.Uint32(dst[:])
|
||||
|
||||
if got := word & 0x3ff; got != 64 {
|
||||
t.Errorf("sample 0 = %d, want 64", got)
|
||||
}
|
||||
if got := (word >> 10) & 0x3ff; got != 512 {
|
||||
t.Errorf("sample 1 = %d, want 512", got)
|
||||
}
|
||||
if got := (word >> 20) & 0x3ff; got != 940 {
|
||||
t.Errorf("sample 2 = %d, want 940", got)
|
||||
}
|
||||
if got := word >> 30; got != 0 {
|
||||
t.Errorf("unused bits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackAlphaBlockMasksSamples(t *testing.T) {
|
||||
var dst [4]byte
|
||||
packAlphaBlock(dst[:], [3]uint32{0x401, 0x802, 0xc03})
|
||||
|
||||
word := binary.LittleEndian.Uint32(dst[:])
|
||||
|
||||
if got := word & 0x3ff; got != 1 {
|
||||
t.Errorf("sample 0 = %d, want 1", got)
|
||||
}
|
||||
if got := (word >> 10) & 0x3ff; got != 2 {
|
||||
t.Errorf("sample 1 = %d, want 2", got)
|
||||
}
|
||||
if got := (word >> 20) & 0x3ff; got != 3 {
|
||||
t.Errorf("sample 2 = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillAlphaPlaneCompleteBlocks(t *testing.T) {
|
||||
const width, height = 6, 2
|
||||
dst := make([]byte, AlphaFrameSize(width, height))
|
||||
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||
t.Fatalf("fillAlphaPlane: %v", err)
|
||||
}
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
if got := sampleAlpha(dst, width, x, y); got != alphaOpaque {
|
||||
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaOpaque)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillAlphaPlaneZerosPartialBlockPadding(t *testing.T) {
|
||||
const width, height = 4, 2
|
||||
dst := make([]byte, AlphaFrameSize(width, height))
|
||||
if err := fillAlphaPlane(dst, width, height, alphaTransparent); err != nil {
|
||||
t.Fatalf("fillAlphaPlane: %v", err)
|
||||
}
|
||||
|
||||
stride := AlphaLineSize(width)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
if got := sampleAlpha(dst, width, x, y); got != alphaTransparent {
|
||||
t.Errorf("sample (%d,%d) = %d, want %d", x, y, got, alphaTransparent)
|
||||
}
|
||||
}
|
||||
lastWord := binary.LittleEndian.Uint32(dst[y*stride+4:])
|
||||
if got := (lastWord >> 10) & 0x3ff; got != 0 {
|
||||
t.Errorf("row %d padding sample 1 = %d, want 0", y, got)
|
||||
}
|
||||
if got := (lastWord >> 20) & 0x3ff; got != 0 {
|
||||
t.Errorf("row %d padding sample 2 = %d, want 0", y, got)
|
||||
}
|
||||
if got := lastWord >> 30; got != 0 {
|
||||
t.Errorf("row %d unused bits = %d, want 0", y, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillAlphaPlaneRejectsSmallDestination(t *testing.T) {
|
||||
const width, height = 6, 2
|
||||
dst := make([]byte, AlphaFrameSize(width, height)-1)
|
||||
err := fillAlphaPlane(dst, width, height, alphaOpaque)
|
||||
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
|
||||
t.Fatalf("error = %v, want destination size error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAlphaMovingSquare(t *testing.T) {
|
||||
const width, height = 304, 200
|
||||
dst := make([]byte, AlphaFrameSize(width, height))
|
||||
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||
t.Fatalf("fillAlphaPlane: %v", err)
|
||||
}
|
||||
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
|
||||
t.Fatalf("patchAlphaMovingSquare: %v", err)
|
||||
}
|
||||
|
||||
if got := sampleAlpha(dst, width, width/2, height/2); got != alphaTransparent {
|
||||
t.Errorf("square center = %d, want transparent %d", got, alphaTransparent)
|
||||
}
|
||||
if got := sampleAlpha(dst, width, 10, height/2); got != alphaOpaque {
|
||||
t.Errorf("outside square = %d, want opaque %d", got, alphaOpaque)
|
||||
}
|
||||
|
||||
// At frame zero the square begins at x=77. Its first three-sample word
|
||||
// therefore contains two opaque samples followed by one transparent sample.
|
||||
for x, want := range []uint32{alphaOpaque, alphaOpaque, alphaTransparent} {
|
||||
if got := sampleAlpha(dst, width, 75+x, height/2); got != want {
|
||||
t.Errorf("boundary sample x=%d = %d, want %d", 75+x, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAlphaMovingSquarePreservesPartialBlockPadding(t *testing.T) {
|
||||
const width, height = 100, 200
|
||||
dst := make([]byte, AlphaFrameSize(width, height))
|
||||
if err := fillAlphaPlane(dst, width, height, alphaOpaque); err != nil {
|
||||
t.Fatalf("fillAlphaPlane: %v", err)
|
||||
}
|
||||
if err := patchAlphaMovingSquare(dst, width, height, 0); err != nil {
|
||||
t.Fatalf("patchAlphaMovingSquare: %v", err)
|
||||
}
|
||||
|
||||
lastWordOffset := height/2*AlphaLineSize(width) + (width/3)*4
|
||||
lastWord := binary.LittleEndian.Uint32(dst[lastWordOffset:])
|
||||
if got := lastWord & 0x3ff; got != alphaTransparent {
|
||||
t.Errorf("last visible sample = %d, want %d", got, alphaTransparent)
|
||||
}
|
||||
if got := lastWord >> 10; got != 0 {
|
||||
t.Errorf("partial-block padding bits = %#x, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAlphaMovingSquareRejectsSmallDestination(t *testing.T) {
|
||||
const width, height = 100, 200
|
||||
err := patchAlphaMovingSquare(
|
||||
make([]byte, AlphaFrameSize(width, height)-1),
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "destination is too small") {
|
||||
t.Fatalf("error = %v, want destination size error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleAlpha(buf []byte, width, x, y int) uint32 {
|
||||
offset := y*AlphaLineSize(width) + x/3*4
|
||||
word := binary.LittleEndian.Uint32(buf[offset:])
|
||||
return (word >> uint(x%3*10)) & 0x3ff
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type YCbCr10 struct {
|
||||
Y uint32
|
||||
Cb uint32
|
||||
Cr uint32
|
||||
}
|
||||
|
||||
// FrameRenderer writes a complete frame or patches part of an existing frame.
|
||||
type FrameRenderer func(dst []byte, width, height, frameIndex int) error
|
||||
|
||||
type CPUGenerator struct {
|
||||
width int
|
||||
height int
|
||||
base []byte
|
||||
patch FrameRenderer
|
||||
}
|
||||
|
||||
func NewCPUGenerator(
|
||||
width, height uint,
|
||||
baseRenderer FrameRenderer,
|
||||
patch FrameRenderer,
|
||||
) (*CPUGenerator, error) {
|
||||
if width == 0 || height == 0 {
|
||||
return nil, fmt.Errorf("cpu: width and height must be greater than zero, got %dx%d", width, height)
|
||||
}
|
||||
if width%2 != 0 {
|
||||
return nil, fmt.Errorf("cpu: width must be even for 4:2:2 video, got %d", width)
|
||||
}
|
||||
if baseRenderer == nil {
|
||||
return nil, fmt.Errorf("cpu: base renderer is nil")
|
||||
}
|
||||
|
||||
g := &CPUGenerator{
|
||||
width: int(width),
|
||||
height: int(height),
|
||||
base: make([]byte, V210FrameSize(int(width), int(height))),
|
||||
patch: patch,
|
||||
}
|
||||
if err := baseRenderer(g.base, g.width, g.height, 0); err != nil {
|
||||
return nil, fmt.Errorf("cpu: render base frame: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (g *CPUGenerator) GenerateFrame(dst []byte, frameIndex int) error {
|
||||
if len(dst) < len(g.base) {
|
||||
return fmt.Errorf(
|
||||
"cpu: destination is too small: got %d bytes, need %d",
|
||||
len(dst),
|
||||
len(g.base),
|
||||
)
|
||||
}
|
||||
|
||||
copy(dst, g.base)
|
||||
|
||||
if g.patch != nil {
|
||||
if err := g.patch(dst[:len(g.base)], g.width, g.height, frameIndex); err != nil {
|
||||
return fmt.Errorf("cpu: patch frame %d: %w", frameIndex, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *CPUGenerator) Close() error { return nil }
|
||||
|
||||
func packV210Block(dst []byte, pixels [6]YCbCr10) {
|
||||
const mask uint32 = 0x3ff
|
||||
word0 := pixels[0].Cb&mask | (pixels[0].Y&mask)<<10 | (pixels[0].Cr&mask)<<20
|
||||
word1 := pixels[1].Y&mask | (pixels[2].Cb&mask)<<10 | (pixels[2].Y&mask)<<20
|
||||
word2 := pixels[2].Cr&mask | (pixels[3].Y&mask)<<10 | (pixels[4].Cb&mask)<<20
|
||||
word3 := pixels[4].Y&mask | (pixels[4].Cr&mask)<<10 | (pixels[5].Y&mask)<<20
|
||||
|
||||
binary.LittleEndian.PutUint32(dst, word0)
|
||||
binary.LittleEndian.PutUint32(dst[4:], word1)
|
||||
binary.LittleEndian.PutUint32(dst[8:], word2)
|
||||
binary.LittleEndian.PutUint32(dst[12:], word3)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package generator
|
||||
|
||||
import "math"
|
||||
|
||||
const movingSquareSize = 150
|
||||
|
||||
type baseColorFunc func(x, y, width, height int) YCbCr10
|
||||
|
||||
type squareBounds struct {
|
||||
minX float64
|
||||
maxX float64
|
||||
minY float64
|
||||
maxY float64
|
||||
}
|
||||
|
||||
func movingSquareBounds(width, height, frameIndex int) squareBounds {
|
||||
const half = float64(movingSquareSize) / 2
|
||||
centerX := float64(width) / 2
|
||||
centerY := float64(height) / 2
|
||||
time := float64(frameIndex) / 25.0
|
||||
offset := math.Sin(time*0.5) * (centerX - half)
|
||||
return squareBounds{
|
||||
minX: centerX - half + offset,
|
||||
maxX: centerX + half + offset,
|
||||
minY: centerY - half,
|
||||
maxY: centerY + half,
|
||||
}
|
||||
}
|
||||
|
||||
func (b squareBounds) contains(x, y int) bool {
|
||||
return float64(x) >= b.minX && float64(x) < b.maxX &&
|
||||
float64(y) >= b.minY && float64(y) < b.maxY
|
||||
}
|
||||
|
||||
func patchMovingSquare(
|
||||
dst []byte,
|
||||
width, height, frameIndex int,
|
||||
baseColor baseColorFunc,
|
||||
) error {
|
||||
bounds := movingSquareBounds(width, height, frameIndex)
|
||||
|
||||
firstPixelX := max(0, int(math.Floor(bounds.minX)))
|
||||
lastPixelX := min(width, int(math.Ceil(bounds.maxX)))
|
||||
firstBlockX := firstPixelX / 6 * 6
|
||||
lastBlockX := min(width, (lastPixelX+5)/6*6)
|
||||
firstY := max(0, int(math.Floor(bounds.minY)))
|
||||
lastY := min(height, int(math.Ceil(bounds.maxY)))
|
||||
stride := V210LineSize(width)
|
||||
|
||||
for y := firstY; y < lastY; y++ {
|
||||
for x := firstBlockX; x < lastBlockX; x += 6 {
|
||||
var pixels [6]YCbCr10
|
||||
for i := range pixels {
|
||||
px := x + i
|
||||
color := baseColor(px, y, width, height)
|
||||
if bounds.contains(px, y) {
|
||||
color = invertStudioRange(color)
|
||||
}
|
||||
pixels[i] = color
|
||||
}
|
||||
offset := y*stride + x/6*16
|
||||
packV210Block(dst[offset:offset+16], pixels)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invertStudioRange(color YCbCr10) YCbCr10 {
|
||||
return YCbCr10{
|
||||
Y: 1004 - color.Y,
|
||||
Cb: 1024 - color.Cb,
|
||||
Cr: 1024 - color.Cr,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ebu75Colors = [...]YCbCr10{
|
||||
{Y: 721, Cb: 512, Cr: 512},
|
||||
{Y: 674, Cb: 176, Cr: 543},
|
||||
{Y: 581, Cb: 589, Cr: 176},
|
||||
{Y: 534, Cb: 253, Cr: 207},
|
||||
{Y: 251, Cb: 771, Cr: 817},
|
||||
{Y: 204, Cb: 435, Cr: 848},
|
||||
{Y: 111, Cb: 848, Cr: 481},
|
||||
{Y: 64, Cb: 512, Cr: 512},
|
||||
}
|
||||
|
||||
var ebu100Colors = [...]YCbCr10{
|
||||
{Y: 940, Cb: 512, Cr: 512},
|
||||
{Y: 877, Cb: 64, Cr: 553},
|
||||
{Y: 754, Cb: 615, Cr: 64},
|
||||
{Y: 691, Cb: 167, Cr: 105},
|
||||
{Y: 313, Cb: 857, Cr: 919},
|
||||
{Y: 250, Cb: 409, Cr: 960},
|
||||
{Y: 127, Cb: 960, Cr: 471},
|
||||
{Y: 64, Cb: 512, Cr: 512},
|
||||
}
|
||||
|
||||
var smpte75Colors = [...]YCbCr10{
|
||||
{Y: 721, Cb: 512, Cr: 512},
|
||||
{Y: 674, Cb: 176, Cr: 543},
|
||||
{Y: 581, Cb: 589, Cr: 176},
|
||||
{Y: 534, Cb: 253, Cr: 207},
|
||||
{Y: 251, Cb: 771, Cr: 817},
|
||||
{Y: 204, Cb: 435, Cr: 848},
|
||||
{Y: 111, Cb: 848, Cr: 481},
|
||||
}
|
||||
|
||||
var grayBarsColors = [...]YCbCr10{
|
||||
{Y: 64, Cb: 512, Cr: 512},
|
||||
{Y: 137, Cb: 512, Cr: 512},
|
||||
{Y: 210, Cb: 512, Cr: 512},
|
||||
{Y: 283, Cb: 512, Cr: 512},
|
||||
{Y: 356, Cb: 512, Cr: 512},
|
||||
{Y: 429, Cb: 512, Cr: 512},
|
||||
{Y: 502, Cb: 512, Cr: 512},
|
||||
{Y: 575, Cb: 512, Cr: 512},
|
||||
{Y: 648, Cb: 512, Cr: 512},
|
||||
{Y: 721, Cb: 512, Cr: 512},
|
||||
{Y: 794, Cb: 512, Cr: 512},
|
||||
{Y: 867, Cb: 512, Cr: 512},
|
||||
{Y: 940, Cb: 512, Cr: 512},
|
||||
}
|
||||
|
||||
var (
|
||||
ebu75BaseColor = colorBars(ebu75Colors[:])
|
||||
ebu100BaseColor = colorBars(ebu100Colors[:])
|
||||
grayBarsBaseColor = colorBars(grayBarsColors[:])
|
||||
)
|
||||
|
||||
func NewCPUPatternGenerator(width, height uint, pattern string) (*CPUGenerator, error) {
|
||||
var baseColor baseColorFunc
|
||||
var dynamic bool
|
||||
switch pattern {
|
||||
case "ebu75":
|
||||
baseColor = ebu75BaseColor
|
||||
case "ebu75-move":
|
||||
baseColor, dynamic = ebu75BaseColor, true
|
||||
case "ebu100":
|
||||
baseColor = ebu100BaseColor
|
||||
case "ebu100-move":
|
||||
baseColor, dynamic = ebu100BaseColor, true
|
||||
case "smpte":
|
||||
if err := validateRP219Size(width, height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseColor = rp219BaseColor
|
||||
case "smpte-move":
|
||||
if err := validateRP219Size(width, height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseColor, dynamic = rp219BaseColor, true
|
||||
case "gray-bars":
|
||||
baseColor = grayBarsBaseColor
|
||||
case "gray-bars-move":
|
||||
baseColor, dynamic = grayBarsBaseColor, true
|
||||
case "gray-ramp":
|
||||
baseColor = grayRampBaseColor
|
||||
case "gray-ramp-move":
|
||||
baseColor, dynamic = grayRampBaseColor, true
|
||||
default:
|
||||
return nil, fmt.Errorf("cpu pattern %q is not implemented", pattern)
|
||||
}
|
||||
|
||||
var patch FrameRenderer
|
||||
if dynamic {
|
||||
patch = movingSquarePatch(baseColor)
|
||||
}
|
||||
return NewCPUGenerator(width, height, baseRenderer(baseColor), patch)
|
||||
}
|
||||
|
||||
func grayRampBaseColor(x, _, width, _ int) YCbCr10 {
|
||||
return YCbCr10{
|
||||
Y: uint32(64 + (x*876)/width),
|
||||
Cb: 512,
|
||||
Cr: 512,
|
||||
}
|
||||
}
|
||||
|
||||
func colorBars(colors []YCbCr10) baseColorFunc {
|
||||
return func(x, _, width, _ int) YCbCr10 {
|
||||
bar := min(x*len(colors)/width, len(colors)-1)
|
||||
return colors[bar]
|
||||
}
|
||||
}
|
||||
|
||||
func baseRenderer(baseColor baseColorFunc) FrameRenderer {
|
||||
return func(dst []byte, width, height, _ int) error {
|
||||
return renderBasePattern(dst, width, height, baseColor)
|
||||
}
|
||||
}
|
||||
|
||||
func movingSquarePatch(baseColor baseColorFunc) FrameRenderer {
|
||||
return func(dst []byte, width, height, frameIndex int) error {
|
||||
return patchMovingSquare(dst, width, height, frameIndex, baseColor)
|
||||
}
|
||||
}
|
||||
|
||||
func renderBasePattern(dst []byte, width, height int, baseColor baseColorFunc) error {
|
||||
stride := V210LineSize(width)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x += 6 {
|
||||
var pixels [6]YCbCr10
|
||||
for i := range pixels {
|
||||
pixels[i] = baseColor(x+i, y, width, height)
|
||||
}
|
||||
offset := y*stride + x/6*16
|
||||
packV210Block(dst[offset:offset+16], pixels)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
gray40 = YCbCr10{Y: 414, Cb: 512, Cr: 512}
|
||||
gray15 = YCbCr10{Y: 195, Cb: 512, Cr: 512}
|
||||
black = YCbCr10{Y: 64, Cb: 512, Cr: 512}
|
||||
white = YCbCr10{Y: 940, Cb: 512, Cr: 512}
|
||||
)
|
||||
|
||||
func rp219BaseColor(x, y, width, height int) YCbCr10 {
|
||||
barsWidth := (height / 3) * 4
|
||||
barsStart := (width - barsWidth) / 2
|
||||
barsEnd := barsStart + barsWidth
|
||||
oneBarWidth := barsWidth / 7
|
||||
|
||||
unitH := height / 12
|
||||
section1End := unitH * 7
|
||||
section2End := section1End + unitH
|
||||
section3End := section2End + unitH
|
||||
|
||||
rampStart := barsStart + oneBarWidth
|
||||
rampEnd := barsEnd
|
||||
rampWidth := rampEnd - rampStart
|
||||
|
||||
switch {
|
||||
case y < section1End:
|
||||
if x < barsStart || x >= barsEnd {
|
||||
return gray40
|
||||
}
|
||||
|
||||
bar := min(
|
||||
(x-barsStart)*len(smpte75Colors)/barsWidth,
|
||||
len(smpte75Colors)-1,
|
||||
)
|
||||
return smpte75Colors[bar]
|
||||
|
||||
case y < section2End:
|
||||
switch {
|
||||
case x < barsStart:
|
||||
return YCbCr10{Y: 754, Cb: 615, Cr: 64} // 100% cyan
|
||||
case x < rampStart:
|
||||
return YCbCr10{Y: 244, Cb: 612, Cr: 395} // -I
|
||||
case x >= barsEnd:
|
||||
return YCbCr10{Y: 127, Cb: 960, Cr: 471} // 100% blue
|
||||
default:
|
||||
return YCbCr10{Y: 721, Cb: 512, Cr: 512} // 75% white
|
||||
}
|
||||
|
||||
case y < section3End:
|
||||
switch {
|
||||
case x < barsStart:
|
||||
return YCbCr10{Y: 877, Cb: 64, Cr: 553} // 100% yellow
|
||||
case x < rampStart:
|
||||
return YCbCr10{Y: 141, Cb: 697, Cr: 606} // +Q
|
||||
case x >= barsEnd:
|
||||
return YCbCr10{Y: 250, Cb: 409, Cr: 960} // 75% red
|
||||
default:
|
||||
rampY := 64 + ((x-rampStart)*876)/rampWidth
|
||||
return YCbCr10{Y: uint32(rampY), Cb: 512, Cr: 512}
|
||||
}
|
||||
|
||||
default:
|
||||
if x < barsStart || x >= barsEnd {
|
||||
return gray15
|
||||
}
|
||||
|
||||
bar := (x - barsStart) / oneBarWidth
|
||||
switch {
|
||||
case bar == 1:
|
||||
return white
|
||||
case bar == 3:
|
||||
offset := (x - barsStart) % oneBarWidth
|
||||
subBar := offset * 3 / oneBarWidth
|
||||
|
||||
switch subBar {
|
||||
case 0:
|
||||
return YCbCr10{Y: 46, Cb: 512, Cr: 512}
|
||||
case 1:
|
||||
return black
|
||||
default:
|
||||
return YCbCr10{Y: 82, Cb: 512, Cr: 512}
|
||||
}
|
||||
|
||||
default:
|
||||
return black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateRP219Size(width, height uint) error {
|
||||
if height < 12 {
|
||||
return fmt.Errorf("cpu RP 219 requires frame height of at least 12, got %d", height)
|
||||
}
|
||||
barsWidth := (height / 3) * 4
|
||||
if barsWidth < 7 || width < barsWidth {
|
||||
return fmt.Errorf(
|
||||
"cpu RP 219 requires frame width %d to fit a 4:3 pattern area for height %d, got %d",
|
||||
barsWidth,
|
||||
height,
|
||||
width,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCPUEBU75Static(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
g, err := NewCPUPatternGenerator(width, height, "ebu75")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, width*height*8/3)
|
||||
if err := g.GenerateFrame(frame, 99); err != nil {
|
||||
t.Fatalf("GenerateFrame: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
x int
|
||||
wantY, wantCb, wantCr uint32
|
||||
}{
|
||||
{x: 0, wantY: 721, wantCb: 512, wantCr: 512},
|
||||
{x: 300, wantY: 674, wantCb: 176, wantCr: 543},
|
||||
{x: 960, wantY: 251, wantCb: 771, wantCr: 817},
|
||||
{x: 1800, wantY: 64, wantCb: 512, wantCr: 512},
|
||||
} {
|
||||
y, cb, cr := sampleV210(frame, width, tc.x, 100)
|
||||
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
|
||||
t.Fatalf("x=%d: got %d/%d/%d, want %d/%d/%d",
|
||||
tc.x, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUEBU75MovingSquare(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
g, err := NewCPUPatternGenerator(width, height, "ebu75-move")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, width*height*8/3)
|
||||
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame(0): %v", err)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 753 || cb != 253 || cr != 207 {
|
||||
t.Fatalf("tick 0 center = %d/%d/%d, want inverted magenta 753/253/207", y, cb, cr)
|
||||
}
|
||||
|
||||
if err := g.GenerateFrame(frame, 79); err != nil {
|
||||
t.Fatalf("GenerateFrame(79): %v", err)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 251 || cb != 771 || cr != 817 {
|
||||
t.Fatalf("old square position = %d/%d/%d, want restored magenta 251/771/817", y, cb, cr)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 1840, 540); y != 940 || cb != 512 || cr != 512 {
|
||||
t.Fatalf("shifted square = %d/%d/%d, want inverted black 940/512/512", y, cb, cr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUStaticPatterns(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
tests := []struct {
|
||||
name string
|
||||
x int
|
||||
wantY, wantCb, wantCr uint32
|
||||
}{
|
||||
{name: "ebu100", x: 0, wantY: 940, wantCb: 512, wantCr: 512},
|
||||
{name: "ebu100", x: 300, wantY: 877, wantCb: 64, wantCr: 553},
|
||||
{name: "ebu100", x: 960, wantY: 313, wantCb: 857, wantCr: 919},
|
||||
{name: "gray-bars", x: 0, wantY: 64, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-bars", x: 960, wantY: 502, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-bars", x: 1800, wantY: 940, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-ramp", x: 0, wantY: 64, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-ramp", x: 6, wantY: 66, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-ramp", x: 960, wantY: 502, wantCb: 512, wantCr: 512},
|
||||
{name: "gray-ramp", x: 1918, wantY: 939, wantCb: 512, wantCr: 512},
|
||||
}
|
||||
|
||||
frames := make(map[string][]byte)
|
||||
for _, tc := range tests {
|
||||
frame, ok := frames[tc.name]
|
||||
if !ok {
|
||||
g, err := NewCPUPatternGenerator(width, height, tc.name)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator(%q): %v", tc.name, err)
|
||||
}
|
||||
frame = make([]byte, width*height*8/3)
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame(%q): %v", tc.name, err)
|
||||
}
|
||||
frames[tc.name] = frame
|
||||
}
|
||||
|
||||
y, cb, cr := sampleV210(frame, width, tc.x, 100)
|
||||
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
|
||||
t.Errorf("%s x=%d: got %d/%d/%d, want %d/%d/%d",
|
||||
tc.name, tc.x, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUMovingPatternUsesOwnBaseColor(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
g, err := NewCPUPatternGenerator(width, height, "ebu100-move")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, width*height*8/3)
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame: %v", err)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 691 || cb != 167 || cr != 105 {
|
||||
t.Fatalf("center = %d/%d/%d, want inverted EBU100 magenta 691/167/105", y, cb, cr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPURP219Pattern(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
g, err := NewCPUPatternGenerator(width, height, "smpte")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, width*height*8/3)
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
x, y int
|
||||
wantY, wantCb, wantCr uint32
|
||||
}{
|
||||
{"top left gray flank", 100, 100, 414, 512, 512},
|
||||
{"top white bar", 300, 100, 721, 512, 512},
|
||||
{"top green bar", 960, 100, 534, 253, 207},
|
||||
{"top right gray flank", 1800, 100, 414, 512, 512},
|
||||
{"section 2 cyan flank", 100, 650, 754, 615, 64},
|
||||
{"section 2 minus I", 300, 650, 244, 612, 395},
|
||||
{"section 2 white", 600, 650, 721, 512, 512},
|
||||
{"section 2 blue flank", 1800, 650, 127, 960, 471},
|
||||
{"section 3 yellow flank", 100, 750, 877, 64, 553},
|
||||
{"section 3 plus Q", 300, 750, 141, 697, 606},
|
||||
{"section 3 red flank", 1800, 750, 250, 409, 960},
|
||||
{"bottom gray flank", 100, 900, 195, 512, 512},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
y, cb, cr := sampleV210(frame, width, tc.x, tc.y)
|
||||
if y != tc.wantY || cb != tc.wantCb || cr != tc.wantCr {
|
||||
t.Fatalf("pixel (%d,%d): got %d/%d/%d, want %d/%d/%d",
|
||||
tc.x, tc.y, y, cb, cr, tc.wantY, tc.wantCb, tc.wantCr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPURP219MovingSquare(t *testing.T) {
|
||||
const width, height = 1920, 1080
|
||||
g, err := NewCPUPatternGenerator(width, height, "smpte-move")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, width*height*8/3)
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame(0): %v", err)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 960, 540); y != 470 || cb != 771 || cr != 817 {
|
||||
t.Fatalf("tick 0 center = %d/%d/%d, want inverted green 470/771/817", y, cb, cr)
|
||||
}
|
||||
if err := g.GenerateFrame(frame, 79); err != nil {
|
||||
t.Fatalf("GenerateFrame(79): %v", err)
|
||||
}
|
||||
if y, cb, cr := sampleV210(frame, width, 1840, 540); y != 590 || cb != 512 || cr != 512 {
|
||||
t.Fatalf("tick 79 shifted square = %d/%d/%d, want inverted gray 590/512/512", y, cb, cr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPURP219RejectsInvalidGeometry(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
width, height uint
|
||||
}{
|
||||
{name: "height too small", width: 1920, height: 11},
|
||||
{name: "canvas too narrow", width: 600, height: 1080},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewCPUPatternGenerator(tc.width, tc.height, "smpte")
|
||||
if err == nil || !strings.Contains(err.Error(), "RP 219") {
|
||||
t.Fatalf("error = %v, want RP 219 geometry error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCPUPatternGeneratorRejectsUnknownPattern(t *testing.T) {
|
||||
_, err := NewCPUPatternGenerator(1920, 1080, "unknown")
|
||||
if err == nil || !strings.Contains(err.Error(), "not implemented") {
|
||||
t.Fatalf("error = %v, want unsupported pattern error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fillFrame(value byte) FrameRenderer {
|
||||
return func(dst []byte, _, _, _ int) error {
|
||||
for i := range dst {
|
||||
dst[i] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCPUGeneratorValidation(t *testing.T) {
|
||||
renderer := fillFrame(0)
|
||||
tests := []struct {
|
||||
name string
|
||||
width uint
|
||||
height uint
|
||||
renderer FrameRenderer
|
||||
wantErrSub string
|
||||
}{
|
||||
{name: "zero width", height: 1, renderer: renderer, wantErrSub: "greater than zero"},
|
||||
{name: "zero height", width: 6, renderer: renderer, wantErrSub: "greater than zero"},
|
||||
{name: "odd width", width: 7, height: 1, renderer: renderer, wantErrSub: "even"},
|
||||
{name: "nil renderer", width: 6, height: 1, wantErrSub: "renderer is nil"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewCPUGenerator(tc.width, tc.height, tc.renderer, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErrSub) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.wantErrSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUGeneratorCopiesBaseAndRestoresBeforePatch(t *testing.T) {
|
||||
patch := func(dst []byte, _, _, frameIndex int) error {
|
||||
switch frameIndex {
|
||||
case 1:
|
||||
dst[0] = 0x22
|
||||
case 2:
|
||||
dst[16] = 0x33
|
||||
}
|
||||
return nil
|
||||
}
|
||||
g, err := NewCPUGenerator(12, 1, fillFrame(0x11), patch)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUGenerator: %v", err)
|
||||
}
|
||||
|
||||
frame := make([]byte, V210FrameSize(12, 1))
|
||||
if err := g.GenerateFrame(frame, 1); err != nil {
|
||||
t.Fatalf("GenerateFrame(1): %v", err)
|
||||
}
|
||||
if frame[0] != 0x22 {
|
||||
t.Fatalf("frame 1 patch byte = %#x, want 0x22", frame[0])
|
||||
}
|
||||
|
||||
if err := g.GenerateFrame(frame, 2); err != nil {
|
||||
t.Fatalf("GenerateFrame(2): %v", err)
|
||||
}
|
||||
if frame[0] != 0x11 {
|
||||
t.Fatalf("old patch byte = %#x, want restored base 0x11", frame[0])
|
||||
}
|
||||
if frame[16] != 0x33 {
|
||||
t.Fatalf("frame 2 patch byte = %#x, want 0x33", frame[16])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackV210Block(t *testing.T) {
|
||||
var pixels [6]YCbCr10
|
||||
for i := range pixels {
|
||||
pixels[i] = YCbCr10{
|
||||
Y: uint32(100 + i),
|
||||
Cb: uint32(200 + i),
|
||||
Cr: uint32(300 + i),
|
||||
}
|
||||
}
|
||||
frame := make([]byte, 16)
|
||||
packV210Block(frame, pixels)
|
||||
|
||||
for x := range pixels {
|
||||
y, cb, cr := sampleV210(frame, 6, x, 0)
|
||||
chromaX := x - x%2
|
||||
if y != pixels[x].Y || cb != pixels[chromaX].Cb || cr != pixels[chromaX].Cr {
|
||||
t.Fatalf("pixel %d: got %d/%d/%d, want %d/%d/%d",
|
||||
x, y, cb, cr, pixels[x].Y, pixels[chromaX].Cb, pixels[chromaX].Cr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackV210BlockMasksComponents(t *testing.T) {
|
||||
pixel := YCbCr10{Y: 0xC01, Cb: 0xC02, Cr: 0xC03}
|
||||
frame := make([]byte, 16)
|
||||
packV210Block(frame, [6]YCbCr10{pixel, pixel, pixel, pixel, pixel, pixel})
|
||||
y, cb, cr := sampleV210(frame, 6, 0, 0)
|
||||
if y != 1 || cb != 2 || cr != 3 {
|
||||
t.Fatalf("masked components = %d/%d/%d, want 1/2/3", y, cb, cr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUGeneratorErrors(t *testing.T) {
|
||||
wantErr := errors.New("patch failed")
|
||||
g, err := NewCPUGenerator(6, 1, fillFrame(0), func([]byte, int, int, int) error {
|
||||
return wantErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUGenerator: %v", err)
|
||||
}
|
||||
if err := g.GenerateFrame(make([]byte, V210FrameSize(6, 1)-1), 0); err == nil || !strings.Contains(err.Error(), "too small") {
|
||||
t.Fatalf("small destination error = %v", err)
|
||||
}
|
||||
if err := g.GenerateFrame(make([]byte, V210FrameSize(6, 1)), 4); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("patch error = %v, want wrapped %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUGeneratorUsesPaddedV210Rows(t *testing.T) {
|
||||
const width, height = 100, 2
|
||||
g, err := NewCPUPatternGenerator(width, height, "gray-ramp")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPUPatternGenerator: %v", err)
|
||||
}
|
||||
frame := make([]byte, V210FrameSize(width, height))
|
||||
if err := g.GenerateFrame(frame, 0); err != nil {
|
||||
t.Fatalf("GenerateFrame: %v", err)
|
||||
}
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
gotY, gotCb, gotCr := sampleV210(frame, width, 0, y)
|
||||
if gotY != 64 || gotCb != 512 || gotCr != 512 {
|
||||
t.Fatalf("row %d first pixel = %d/%d/%d, want 64/512/512", y, gotY, gotCb, gotCr)
|
||||
}
|
||||
padding := frame[y*V210LineSize(width)+V210ActiveLineSize(width) : (y+1)*V210LineSize(width)]
|
||||
for i, b := range padding {
|
||||
if b != 0 {
|
||||
t.Fatalf("row %d padding byte %d = %#x, want 0", y, i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-10
@@ -33,9 +33,19 @@ func LoadFace(path string, size float64) (font.Face, error) {
|
||||
if err != nil {
|
||||
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)
|
||||
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{
|
||||
Size: size,
|
||||
@@ -43,7 +53,7 @@ func LoadFace(path string, size float64) (font.Face, error) {
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
h := 2*padY + textH
|
||||
const topMargin = 48
|
||||
if topMargin+h > frameH {
|
||||
return nil, fmt.Errorf("text: box %dpx does not fit frame height %dpx", topMargin+h, frameH)
|
||||
if h > frameH {
|
||||
return nil, fmt.Errorf("text: box %dpx higher than frame %dpx", h, frameH)
|
||||
}
|
||||
|
||||
if textPos != "" {
|
||||
@@ -120,6 +129,18 @@ func NewTextOverlay(text string, frameW, frameH, posX, posY int, textPos string,
|
||||
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{
|
||||
frameW: frameW,
|
||||
x: posX,
|
||||
@@ -168,15 +189,16 @@ func (o *TextOverlay) pack() {
|
||||
// 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.
|
||||
func (o *TextOverlay) ApplyV210(dest []byte) error {
|
||||
lastPixel := (o.y+o.h-1)*o.frameW + o.x + o.w - 1
|
||||
if need := (lastPixel/6 + 1) * 16; len(dest) < need {
|
||||
frameStride := V210LineSize(o.frameW)
|
||||
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)
|
||||
}
|
||||
tileStride := (o.w / 6) * 16
|
||||
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]
|
||||
copy(dest[frameBlock*16:frameBlock*16+tileStride], src)
|
||||
copy(dest[frameOffset:frameOffset+tileStride], src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mxl-pattern-generator/assets"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
)
|
||||
|
||||
func testFace(t *testing.T, size float64) font.Face {
|
||||
t.Helper()
|
||||
face, err := LoadFace(filepath.Join("..", "..", "assets", "fonts",
|
||||
"JetBrainsMonoNLNerdFontMono-Regular.ttf"), size)
|
||||
face, err := NewFace(assets.JetBrainsMono, size)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFace: %v", err)
|
||||
}
|
||||
@@ -32,11 +32,8 @@ func TestNewTextOverlay(t *testing.T) {
|
||||
if o.x%6 != 0 {
|
||||
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 {
|
||||
t.Fatalf("box center %d not near frame center %d", center, frameW/2)
|
||||
}
|
||||
if o.y != 48 {
|
||||
t.Fatalf("y=%d, want 48", o.y)
|
||||
if o.x != 0 || o.y != 0 {
|
||||
t.Fatalf("position = (%d,%d), want (0,0)", o.x, o.y)
|
||||
}
|
||||
if o.blocks == nil || len(o.blocks) != o.h*(o.w/6)*16 {
|
||||
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) {
|
||||
const frameW, frameH = 1920, 1080
|
||||
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)
|
||||
}
|
||||
|
||||
frame := make([]byte, frameW*frameH*8/3)
|
||||
frame := make([]byte, V210FrameSize(frameW, frameH))
|
||||
for i := range frame {
|
||||
frame[i] = 0x5A // marker: untouched regions must survive
|
||||
}
|
||||
@@ -102,34 +171,11 @@ func TestTextOverlayApplyV210(t *testing.T) {
|
||||
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.
|
||||
for row := 0; row < o.h; row++ {
|
||||
for col := 0; col < o.w; col++ {
|
||||
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 {
|
||||
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
|
||||
{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++ {
|
||||
if frame[off+i] != 0x5A {
|
||||
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")
|
||||
}
|
||||
}
|
||||
+47
-37
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gogpu/gputypes"
|
||||
@@ -23,23 +22,26 @@ const wgpuWorkgroupSize = 64
|
||||
// buffer, the GPU DMAs it into a persistent host-visible buffer, and the
|
||||
// mapped contents are copied straight into the destination grain.
|
||||
type WGPUGenerator struct {
|
||||
instance *wgpu.Instance
|
||||
adapter *wgpu.Adapter
|
||||
device *wgpu.Device
|
||||
queue *wgpu.Queue
|
||||
shader *wgpu.ShaderModule
|
||||
bgl *wgpu.BindGroupLayout
|
||||
bg *wgpu.BindGroup
|
||||
pl *wgpu.PipelineLayout
|
||||
pipeline *wgpu.ComputePipeline
|
||||
out *wgpu.Buffer
|
||||
host *wgpu.Buffer
|
||||
uniform *wgpu.Buffer
|
||||
params []byte
|
||||
width int
|
||||
height int
|
||||
blocks int
|
||||
frameSize uint64
|
||||
instance *wgpu.Instance
|
||||
adapter *wgpu.Adapter
|
||||
device *wgpu.Device
|
||||
queue *wgpu.Queue
|
||||
shader *wgpu.ShaderModule
|
||||
bgl *wgpu.BindGroupLayout
|
||||
bg *wgpu.BindGroup
|
||||
pl *wgpu.PipelineLayout
|
||||
pipeline *wgpu.ComputePipeline
|
||||
out *wgpu.Buffer
|
||||
host *wgpu.Buffer
|
||||
uniform *wgpu.Buffer
|
||||
params []byte
|
||||
width int
|
||||
height int
|
||||
blocks int
|
||||
activeLineSize int
|
||||
lineSize int
|
||||
compactFrameSize uint64
|
||||
frameSize uint64
|
||||
}
|
||||
|
||||
var _ FrameGenerator = (*WGPUGenerator)(nil)
|
||||
@@ -47,17 +49,20 @@ var _ FrameGenerator = (*WGPUGenerator)(nil)
|
||||
// WGPUOption customizes NewWGPUGenerator.
|
||||
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{
|
||||
width: int(width),
|
||||
height: int(height),
|
||||
blocks: int(width*height) / 6,
|
||||
params: make([]byte, 16),
|
||||
width: int(width),
|
||||
height: int(height),
|
||||
blocks: ((int(width) + 5) / 6) * int(height),
|
||||
activeLineSize: V210ActiveLineSize(int(width)),
|
||||
lineSize: V210LineSize(int(width)),
|
||||
params: make([]byte, 16),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
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[4:], uint32(height))
|
||||
|
||||
@@ -75,26 +80,21 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
|
||||
}
|
||||
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{
|
||||
Label: "v210-shader", WGSL: string(wgsl),
|
||||
Label: "v210-shader", WGSL: wgsl,
|
||||
}); err != nil {
|
||||
g.Close()
|
||||
return nil, fmt.Errorf("wgpu: shader: %w", err)
|
||||
}
|
||||
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,
|
||||
}); err != nil {
|
||||
g.Close()
|
||||
return nil, fmt.Errorf("wgpu: out buffer: %w", err)
|
||||
}
|
||||
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,
|
||||
}); err != nil {
|
||||
g.Close()
|
||||
@@ -124,7 +124,7 @@ func NewWGPUGenerator(width, height uint, kernelPath string, opts ...WGPUOption)
|
||||
if g.bg, err = g.device.CreateBindGroup(&wgpu.BindGroupDescriptor{
|
||||
Label: "v210-bg", Layout: g.bgl,
|
||||
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))},
|
||||
},
|
||||
}); err != nil {
|
||||
@@ -170,7 +170,7 @@ func (g *WGPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
|
||||
if err := pass.End(); err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
rng, err := g.host.MappedRange(0, g.frameSize)
|
||||
rng, err := g.host.MappedRange(0, g.compactFrameSize)
|
||||
if err != nil {
|
||||
_ = g.host.Unmap()
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"mxl-pattern-generator/kernels"
|
||||
)
|
||||
|
||||
func TestWGPUMoveSquare(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
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):
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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):
|
||||
// inverted -> Y=1004-111=893, Cb=1024-848=176, Cr=1024-481=543
|
||||
const invBlueY, invBlueCb, invBlueCr = 893, 176, 543
|
||||
// the far right, x in [1770,1920), over the gray side panel
|
||||
// (414/512/512): inverted -> 590/512/512.
|
||||
const invFlankY, invFlankCb, invFlankCr = 590, 512, 512
|
||||
if err := g.GenerateFrame(buf, 79); err != nil {
|
||||
t.Fatalf("tick 79: %v", err)
|
||||
}
|
||||
if y, cb, cr := sample(1840, 540); y != invBlueY || cb != invBlueCb || cr != invBlueCr {
|
||||
t.Fatalf("tick 79 shifted center: got %d/%d/%d, want inverted blue 893/176/543", y, cb, cr)
|
||||
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 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)
|
||||
}
|
||||
// bars untouched far from the square
|
||||
if y, _, _ := sample(100, 100); y != 721 {
|
||||
t.Fatalf("tick 79 bars: Y=%d, want white 721", y)
|
||||
if y, _, _ := sampleV210(buf, width, 100, 100); y != 414 {
|
||||
t.Fatalf("tick 79 bars: Y=%d, want gray flank 414", y)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"mxl-pattern-generator/kernels"
|
||||
)
|
||||
|
||||
func TestWGPUGenerator(t *testing.T) {
|
||||
func TestWGPUSMPTEPattern(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
@@ -22,45 +26,64 @@ func TestWGPUGenerator(t *testing.T) {
|
||||
t.Fatalf("GenerateFrame 2: %v", err)
|
||||
}
|
||||
|
||||
want := [7][3]uint32{
|
||||
{721, 512, 512}, {674, 176, 543}, {581, 589, 176},
|
||||
{534, 253, 207}, {251, 771, 817}, {204, 435, 848}, {111, 848, 481},
|
||||
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},
|
||||
}
|
||||
barOf := func(x int) int {
|
||||
if b := x * 7 / width; b < 7 {
|
||||
return b
|
||||
}
|
||||
return 6
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 x := 0; x < width; x++ {
|
||||
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:])
|
||||
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)]
|
||||
if yv != b[0] {
|
||||
t.Fatalf("pixel (%d,%d): Y=%d want %d", x, y, yv, b[0])
|
||||
}
|
||||
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])
|
||||
gotY, gotCb, gotCr := sampleV210(buf, width, 0, y)
|
||||
if gotY != 721 || gotCb != 512 || gotCr != 512 {
|
||||
t.Fatalf("row %d first pixel = %d/%d/%d, want 721/512/512", y, gotY, gotCb, gotCr)
|
||||
}
|
||||
padding := buf[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,19 @@ import (
|
||||
"github.com/qvest-digital/go-mxl/mxl"
|
||||
)
|
||||
|
||||
type Backend string
|
||||
|
||||
const (
|
||||
BackendAuto Backend = "auto"
|
||||
BackendGPU Backend = "gpu"
|
||||
BackendCPU Backend = "cpu"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Definition flowdef.Video
|
||||
Pattern string
|
||||
Overlay OverlayConfig
|
||||
Backend Backend
|
||||
}
|
||||
|
||||
type OverlayConfig struct {
|
||||
@@ -31,6 +40,10 @@ func (c Config) Height() uint {
|
||||
return c.Definition.FrameHeight
|
||||
}
|
||||
|
||||
func (c Config) HasAlpha() bool {
|
||||
return c.Definition.MediaType == flowdef.MediaTypeV210A
|
||||
}
|
||||
|
||||
func (c Config) Rate() mxl.Rational {
|
||||
return mxl.Rational{
|
||||
Num: int64(c.Definition.GrainRate.Numerator),
|
||||
|
||||
@@ -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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = (block * 6u) % params.width;
|
||||
let py = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let py = block / blocks_per_row;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = (block * 6u) % params.width;
|
||||
let py = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let py = block / blocks_per_row;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32, bars_75_width: u32) -> u32 {
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = (block * 6u) % params.width;
|
||||
let y_px = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let y_px = block / blocks_per_row;
|
||||
|
||||
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
||||
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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 py = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let py = block / blocks_per_row;
|
||||
// Moving square: horizontal oscillation around screen center.
|
||||
// frame is a small tick; converting the huge raw grain index here
|
||||
// would destroy f32 precision and freeze the motion.
|
||||
|
||||
@@ -18,13 +18,14 @@ fn bar_index(px: u32) -> u32 {
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = (block * 6u) % params.width;
|
||||
let py = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let py = block / blocks_per_row;
|
||||
|
||||
// Bar order: 75% white, yellow, cyan, green, magenta, red, blue
|
||||
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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
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
|
||||
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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
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
|
||||
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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = (block * 6u) % params.width;
|
||||
let y_px = (block * 6u) / params.width;
|
||||
let x = (block % blocks_per_row) * 6u;
|
||||
let y_px = block / blocks_per_row;
|
||||
|
||||
// 75% Color Table: white, yellow, cyan, green, magenta, red, blue
|
||||
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)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
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);
|
||||
|
||||
// 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 cb: array<u32, 6>;
|
||||
var cr: array<u32, 6>;
|
||||
|
||||
@@ -18,7 +18,8 @@ fn bar_index(px: u32) -> u32 {
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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) {
|
||||
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 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 cb: array<u32, 6>;
|
||||
var cr: array<u32, 6>;
|
||||
|
||||
Reference in New Issue
Block a user