diff --git a/assets/embed.go b/assets/embed.go new file mode 100644 index 0000000..8eca1cf --- /dev/null +++ b/assets/embed.go @@ -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 diff --git a/internal/generator/text.go b/internal/generator/text.go index 72604eb..a9e2bf9 100644 --- a/internal/generator/text.go +++ b/internal/generator/text.go @@ -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 } diff --git a/internal/generator/text_test.go b/internal/generator/text_test.go index 8bfe3b2..45c06f9 100644 --- a/internal/generator/text_test.go +++ b/internal/generator/text_test.go @@ -1,17 +1,17 @@ package generator import ( - "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) } diff --git a/internal/generator/wgpu.go b/internal/generator/wgpu.go index df8b02f..dc0a3eb 100644 --- a/internal/generator/wgpu.go +++ b/internal/generator/wgpu.go @@ -6,7 +6,6 @@ import ( "context" "encoding/binary" "fmt" - "os" "time" "github.com/gogpu/gputypes" @@ -47,7 +46,7 @@ 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), @@ -75,13 +74,8 @@ 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) diff --git a/internal/generator/wgpu_move_test.go b/internal/generator/wgpu_move_test.go index 9d3114b..379cd81 100644 --- a/internal/generator/wgpu_move_test.go +++ b/internal/generator/wgpu_move_test.go @@ -1,13 +1,18 @@ package generator import ( - "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) } diff --git a/internal/generator/wgpu_test.go b/internal/generator/wgpu_test.go index 1ab662f..cc7f27a 100644 --- a/internal/generator/wgpu_test.go +++ b/internal/generator/wgpu_test.go @@ -1,13 +1,18 @@ package generator import ( - "path/filepath" "testing" + + "mxl-pattern-generator/kernels" ) 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) } diff --git a/internal/video/pattern.go b/internal/video/pattern.go index ab7aa64..d63bbe1 100644 --- a/internal/video/pattern.go +++ b/internal/video/pattern.go @@ -1,6 +1,11 @@ package video -import "sort" +import ( + "fmt" + "sort" + + "mxl-pattern-generator/kernels" +) type PatternInfo struct { Name string @@ -9,52 +14,60 @@ type PatternInfo struct { type pattern struct { PatternInfo - kernelPath string + kernelSource string } var patterns = map[string]pattern{ "ebu75": { - PatternInfo: PatternInfo{Name: "ebu75", Description: "EBU 75% Color Bar Signal"}, - kernelPath: "kernels/static/ebu75.wgsl", + 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"}, - kernelPath: "kernels/dynamic/ebu75.wgsl", + PatternInfo: PatternInfo{Name: "ebu75-move", Description: "EBU 75% Color Bar Signal with moving square"}, + kernelSource: mustReadKernel("dynamic/ebu75.wgsl"), }, "ebu100": { - PatternInfo: PatternInfo{Name: "ebu100", Description: "EBU 100% Color Bar Signal"}, - kernelPath: "kernels/static/ebu100.wgsl", + 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"}, - kernelPath: "kernels/dynamic/ebu100.wgsl", + PatternInfo: PatternInfo{Name: "ebu100-move", Description: "EBU 100% Color Bar Signal with moving square"}, + kernelSource: mustReadKernel("dynamic/ebu100.wgsl"), }, "smpte": { - PatternInfo: PatternInfo{Name: "smpte", Description: "SMPTE RP-219 Color Bar Signal"}, - kernelPath: "kernels/static/smpteBars.wgsl", + 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"}, - kernelPath: "kernels/dynamic/smpteBars.wgsl", + PatternInfo: PatternInfo{Name: "smpte-move", Description: "SMPTE RP-219 Color Bar Signal with moving square"}, + kernelSource: mustReadKernel("dynamic/smpteBars.wgsl"), }, "gray-bars": { - PatternInfo: PatternInfo{Name: "gray-bars", Description: "13-step grayscale bars (Y 64..940)"}, - kernelPath: "kernels/static/yBars.wgsl", + 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"}, - kernelPath: "kernels/dynamic/yBars.wgsl", + PatternInfo: PatternInfo{Name: "gray-bars-move", Description: "13-step grayscale bars (Y 64..940) with moving square"}, + kernelSource: mustReadKernel("dynamic/yBars.wgsl"), }, "gray-ramp": { - PatternInfo: PatternInfo{Name: "gray-ramp", Description: "Y gradient (black -> 100% white)"}, - kernelPath: "kernels/static/yRamp.wgsl", + 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"}, - kernelPath: "kernels/dynamic/yRamp.wgsl", + PatternInfo: PatternInfo{Name: "gray-ramp-move", Description: "Y gradient with moving square"}, + kernelSource: mustReadKernel("dynamic/yRamp.wgsl"), }, } +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 diff --git a/internal/video/pattern_test.go b/internal/video/pattern_test.go index 9bce8ba..9b738da 100644 --- a/internal/video/pattern_test.go +++ b/internal/video/pattern_test.go @@ -40,8 +40,8 @@ func TestPatternRegistryKeysMatchNames(t *testing.T) { if pattern.Name != name { t.Errorf("pattern map key %q does not match pattern name %q", name, pattern.Name) } - if pattern.kernelPath == "" { - t.Errorf("pattern %q has an empty kernel path", name) + if pattern.kernelSource == "" { + t.Errorf("pattern %q has empty kernel source", name) } } } diff --git a/internal/video/runner.go b/internal/video/runner.go index 500d0d7..5df83f0 100644 --- a/internal/video/runner.go +++ b/internal/video/runner.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "log" + + "mxl-pattern-generator/assets" "mxl-pattern-generator/internal/generator" "github.com/qvest-digital/go-mxl/mxl" @@ -18,7 +20,7 @@ func Run(ctx context.Context, inst *mxl.Instance, cfg Config) (runErr error) { } // TODO: fall back to a CPU generator if GPU initialization fails. - gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath) + gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelSource) if err != nil { return fmt.Errorf("initialize wgpu video generator: %w", err) } @@ -102,7 +104,7 @@ func buildTextOverlay(cfg Config) (overlay *generator.TextOverlay, resultErr err return nil, nil } - face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48) + face, err := generator.NewFace(assets.JetBrainsMono, 48) if err != nil { return nil, fmt.Errorf("load text overlay font: %w", err) } diff --git a/internal/video/runner_test.go b/internal/video/runner_test.go index 08dbe21..a40e717 100644 --- a/internal/video/runner_test.go +++ b/internal/video/runner_test.go @@ -34,9 +34,6 @@ func TestBuildTextOverlayDisabled(t *testing.T) { } func TestBuildTextOverlayPositioning(t *testing.T) { - // Production assets are resolved from the repository root. - t.Chdir("../..") - tests := []struct { name string overlay OverlayConfig @@ -59,8 +56,6 @@ func TestBuildTextOverlayPositioning(t *testing.T) { } func TestBuildTextOverlayRejectsInvalidPosition(t *testing.T) { - t.Chdir("../..") - tests := []struct { name string overlay OverlayConfig diff --git a/kernels/embed.go b/kernels/embed.go new file mode 100644 index 0000000..d77fd78 --- /dev/null +++ b/kernels/embed.go @@ -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) +}