Add modular capture backends, resolution profiles, Wayland support

This commit is contained in:
Hoid 2026-02-14 10:00:19 +00:00
parent 3b363192f2
commit 80ba9b1b90
16 changed files with 2266 additions and 45 deletions

View file

@ -0,0 +1,283 @@
// Package resolution provides a profile system for managing screen regions across different resolutions.
//
// Each game plugin can register resolution profiles that define named screen regions
// for different resolutions. This allows the engine to work with games at various
// resolutions without hardcoding pixel coordinates.
package resolution
import (
"fmt"
"image"
"sort"
)
// Profile defines screen regions for a specific resolution.
type Profile struct {
Width int `yaml:"width"` // Screen width in pixels
Height int `yaml:"height"` // Screen height in pixels
Label string `yaml:"label"` // Human-readable label (e.g., "1080p", "720p")
Regions map[string]image.Rectangle `yaml:"regions"` // Named screen regions
}
// Registry holds profiles per game per resolution.
type Registry struct {
// profiles[gameID][resolution_key] = Profile
profiles map[string]map[string]*Profile
}
// NewRegistry creates a new resolution profile registry.
func NewRegistry() *Registry {
return &Registry{
profiles: make(map[string]map[string]*Profile),
}
}
// Register adds a resolution profile for a specific game.
func (r *Registry) Register(gameID string, profile *Profile) error {
if profile == nil {
return fmt.Errorf("profile cannot be nil")
}
if profile.Width <= 0 || profile.Height <= 0 {
return fmt.Errorf("invalid resolution: %dx%d", profile.Width, profile.Height)
}
if r.profiles[gameID] == nil {
r.profiles[gameID] = make(map[string]*Profile)
}
key := resolutionKey(profile.Width, profile.Height)
r.profiles[gameID][key] = profile
return nil
}
// Get returns the profile for a game and resolution, or error if unsupported.
func (r *Registry) Get(gameID string, width, height int) (*Profile, error) {
gameProfiles, exists := r.profiles[gameID]
if !exists {
return nil, fmt.Errorf("no profiles registered for game %q", gameID)
}
key := resolutionKey(width, height)
profile, exists := gameProfiles[key]
if !exists {
return nil, fmt.Errorf("no profile for game %q at resolution %dx%d", gameID, width, height)
}
return profile, nil
}
// GetRegion returns a named region for a game and resolution.
func (r *Registry) GetRegion(gameID string, width, height int, regionName string) (image.Rectangle, error) {
profile, err := r.Get(gameID, width, height)
if err != nil {
return image.Rectangle{}, err
}
region, exists := profile.Regions[regionName]
if !exists {
return image.Rectangle{}, fmt.Errorf("region %q not found in profile for %s at %dx%d", regionName, gameID, width, height)
}
return region, nil
}
// SupportedResolutions returns resolutions available for a game.
func (r *Registry) SupportedResolutions(gameID string) []image.Point {
gameProfiles, exists := r.profiles[gameID]
if !exists {
return nil
}
var resolutions []image.Point
for _, profile := range gameProfiles {
resolutions = append(resolutions, image.Point{X: profile.Width, Y: profile.Height})
}
// Sort by resolution (width first, then height)
sort.Slice(resolutions, func(i, j int) bool {
if resolutions[i].X != resolutions[j].X {
return resolutions[i].X < resolutions[j].X
}
return resolutions[i].Y < resolutions[j].Y
})
return resolutions
}
// ListGames returns all game IDs that have registered profiles.
func (r *Registry) ListGames() []string {
var games []string
for gameID := range r.profiles {
games = append(games, gameID)
}
sort.Strings(games)
return games
}
// GetProfiles returns all profiles for a specific game.
func (r *Registry) GetProfiles(gameID string) []*Profile {
gameProfiles, exists := r.profiles[gameID]
if !exists {
return nil
}
var profiles []*Profile
for _, profile := range gameProfiles {
profiles = append(profiles, profile)
}
// Sort by resolution
sort.Slice(profiles, func(i, j int) bool {
if profiles[i].Width != profiles[j].Width {
return profiles[i].Width < profiles[j].Width
}
return profiles[i].Height < profiles[j].Height
})
return profiles
}
// ValidateProfile checks if a profile is valid and complete.
func (r *Registry) ValidateProfile(profile *Profile) error {
if profile == nil {
return fmt.Errorf("profile is nil")
}
if profile.Width <= 0 || profile.Height <= 0 {
return fmt.Errorf("invalid resolution: %dx%d", profile.Width, profile.Height)
}
if profile.Label == "" {
return fmt.Errorf("profile label is required")
}
if profile.Regions == nil {
return fmt.Errorf("profile regions map is nil")
}
// Validate that all regions are within screen bounds
screenBounds := image.Rect(0, 0, profile.Width, profile.Height)
for regionName, region := range profile.Regions {
if !region.In(screenBounds) {
return fmt.Errorf("region %q is outside screen bounds: %v not in %v", regionName, region, screenBounds)
}
if region.Empty() {
return fmt.Errorf("region %q is empty: %v", regionName, region)
}
}
return nil
}
// RegisterMultiple registers multiple profiles for a game.
func (r *Registry) RegisterMultiple(gameID string, profiles []*Profile) error {
for _, profile := range profiles {
if err := r.ValidateProfile(profile); err != nil {
return fmt.Errorf("invalid profile %s: %w", profile.Label, err)
}
if err := r.Register(gameID, profile); err != nil {
return fmt.Errorf("failed to register profile %s: %w", profile.Label, err)
}
}
return nil
}
// Clone creates a deep copy of a profile.
func (p *Profile) Clone() *Profile {
clone := &Profile{
Width: p.Width,
Height: p.Height,
Label: p.Label,
Regions: make(map[string]image.Rectangle),
}
for name, region := range p.Regions {
clone.Regions[name] = region
}
return clone
}
// HasRegion checks if a profile contains a named region.
func (p *Profile) HasRegion(name string) bool {
_, exists := p.Regions[name]
return exists
}
// ListRegions returns all region names in the profile.
func (p *Profile) ListRegions() []string {
var names []string
for name := range p.Regions {
names = append(names, name)
}
sort.Strings(names)
return names
}
// AspectRatio returns the aspect ratio of the profile resolution.
func (p *Profile) AspectRatio() float64 {
return float64(p.Width) / float64(p.Height)
}
// IsWidescreen returns true if the profile is widescreen (16:9 or wider).
func (p *Profile) IsWidescreen() bool {
return p.AspectRatio() >= 16.0/9.0
}
// ScaleFrom creates a new profile by scaling regions from another resolution.
func (p *Profile) ScaleFrom(source *Profile) *Profile {
if source == nil {
return nil
}
scaleX := float64(p.Width) / float64(source.Width)
scaleY := float64(p.Height) / float64(source.Height)
scaled := &Profile{
Width: p.Width,
Height: p.Height,
Label: p.Label,
Regions: make(map[string]image.Rectangle),
}
for name, region := range source.Regions {
scaled.Regions[name] = image.Rect(
int(float64(region.Min.X)*scaleX),
int(float64(region.Min.Y)*scaleY),
int(float64(region.Max.X)*scaleX),
int(float64(region.Max.Y)*scaleY),
)
}
return scaled
}
// resolutionKey generates a unique key for a resolution.
func resolutionKey(width, height int) string {
return fmt.Sprintf("%dx%d", width, height)
}
// ParseResolution parses a resolution string like "1920x1080".
func ParseResolution(s string) (width, height int, err error) {
n, err := fmt.Sscanf(s, "%dx%d", &width, &height)
if err != nil {
return 0, 0, fmt.Errorf("invalid resolution format %q: %w", s, err)
}
if n != 2 {
return 0, 0, fmt.Errorf("invalid resolution format %q: expected WIDTHxHEIGHT", s)
}
if width <= 0 || height <= 0 {
return 0, 0, fmt.Errorf("invalid resolution: %dx%d", width, height)
}
return width, height, nil
}
// FormatResolution formats width and height as a resolution string.
func FormatResolution(width, height int) string {
return fmt.Sprintf("%dx%d", width, height)
}