Compare commits

...

24 Commits

Author SHA1 Message Date
Dmitry Sergeev 1e804897c6 alpha perfomance fix 2026-09-18 10:25:35 +03:00
Dmitry Sergeev dea2e6a59f alpha patterns 2026-09-18 10:11:07 +03:00
Dmitry Sergeev 9b194b5591 v210A flowdef 2026-09-18 09:54:36 +03:00
Dmitry Sergeev 4d8035a434 V210 issue fix + correct packing 2026-09-18 01:30:11 +03:00
Dmitry Sergeev efa958723a RP219 CPU pattern 2026-09-18 01:02:56 +03:00
Dmitry Sergeev 723bef6342 CPU patterns fallback 2026-09-18 00:41:38 +03:00
Dmitry Sergeev b85293b5d2 CPU generator 2026-09-17 23:27:38 +03:00
Dmitry Sergeev fa787b5ef1 video backend CLI flag 2026-09-17 23:16:59 +03:00
Dmitry Sergeev f5df9506ce static pattern frame cache 2026-09-17 22:59:42 +03:00
Dmitry Sergeev 4d9485220a --no-video flag 2026-09-17 22:46:11 +03:00
Dmitry Sergeev e30cb7a168 refactoring finished 2026-09-17 20:42:38 +03:00
Dmitry Sergeev de4dfcf101 tests 2026-09-17 20:39:41 +03:00
Dmitry Sergeev d26d9442a0 app run in app.go 2026-09-17 20:37:37 +03:00
Dmitry Sergeev d3f0b533e3 runner.go 2026-09-17 20:30:00 +03:00
Dmitry Sergeev d341d22611 video run extraction 2026-09-17 20:21:16 +03:00
Dmitry Sergeev 07769859f7 run audio extraction 2026-09-17 20:14:41 +03:00
Dmitry Sergeev eeb13c3bd4 rendering tests fixed 2026-09-16 23:58:45 +03:00
Dmitry Sergeev 6fa3dea391 minor fix 2026-09-16 23:52:01 +03:00
itten 128d6fe63e Merge pull request 'Audio' (#1) from audio into main
Reviewed-on: http://gitea.lan/itten/go-mxl-pattern-generator/pulls/1
2026-09-16 23:50:45 +03:00
Dmitry Sergeev 72decea866 audio works 2026-09-16 23:50:11 +03:00
Dmitry Sergeev b5c3c82b1e truth fix 2026-09-16 22:47:14 +03:00
Dmitry Sergeev 70c966dbad audio is prepared, next is MXL part 2026-09-16 22:44:27 +03:00
Dmitry Sergeev ce547451a0 audio integration to main 2026-09-16 22:01:53 +03:00
Dmitry Sergeev ce00305c25 audio flowdef 2026-09-16 22:00:09 +03:00
49 changed files with 3791 additions and 458 deletions
+7 -13
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -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,
})
}
+270
View File
@@ -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)
}
}
+79
View File
@@ -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
}
+15
View File
@@ -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)
}
}
+43
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+32
View File
@@ -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),
}
}
+125
View File
@@ -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
// KotelnikovNyquistShannon 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
}
+155
View File
@@ -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")
}
}
+187
View File
@@ -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)
}
+31
View File
@@ -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
View File
@@ -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
}
+173 -9
View File
@@ -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)
}
}
+92
View File
@@ -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
}
+161
View File
@@ -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
}
+84
View File
@@ -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)
}
+74
View File
@@ -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,
}
}
+245
View File
@@ -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
}
+202
View File
@@ -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)
}
}
+147
View File
@@ -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
View File
@@ -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
}
+81 -35
View File
@@ -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])
+37
View File
@@ -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)
}
+95
View File
@@ -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
}
}
+73
View File
@@ -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()
}
+171
View File
@@ -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
View File
@@ -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()
}
+17 -35
View File
@@ -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)
}
}
+63 -40
View File
@@ -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)
}
}
}
+13
View File
@@ -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),
+89
View File
@@ -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)
}
}
+178
View File
@@ -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)
}
}
}
+91
View File
@@ -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
}
+54
View File
@@ -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)
}
}
}
+156
View File
@@ -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
}
+77
View File
@@ -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)
}
})
}
}
+4 -3
View File
@@ -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);
+4 -3
View File
@@ -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);
+4 -3
View File
@@ -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);
+4 -3
View File
@@ -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.
+4 -3
View File
@@ -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);
+12
View File
@@ -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)
}
+3 -2
View File
@@ -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);
+3 -2
View File
@@ -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);
+4 -3
View File
@@ -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);
+3 -2
View File
@@ -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>;
+3 -2
View File
@@ -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>;