diff --git a/README.md b/README.md index 871e3da..f06274d 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,7 @@ Flags: Use "/tmp/go-build2502186757/b001/exe/streamctl [command] --help" for more information about a command. ``` + +# See also + +* [OBS Blade](https://github.com/Kounex/obs_blade) is a quick and nice tool to control your OBS remotely. \ No newline at end of file diff --git a/go.mod b/go.mod index 4dc8f38..41e4777 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ replace github.com/andreykaipov/goobs v1.4.1 => github.com/xaionaro-go/goobs v0. replace github.com/adrg/libvlc-go/v3 v3.1.5 => github.com/xaionaro-go/libvlc-go/v3 v3.0.0-20241011194409-0fe4e2a9d901 -replace fyne.io/fyne/v2 v2.5.0 => github.com/xaionaro-go/fyne/v2 v2.0.0-20241020235352-fd61e4920f24 +replace fyne.io/fyne/v2 v2.5.4 => github.com/xaionaro-go/fyne/v2 v2.0.0-20250215180758-399edb421067 replace code.cloudfoundry.org/bytefmt => github.com/cloudfoundry/bytefmt v0.0.0-20211005130812-5bb3c17173e5 @@ -146,6 +146,7 @@ require ( github.com/mmcloughlin/profile v0.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect @@ -219,7 +220,7 @@ require ( ) require ( - fyne.io/fyne/v2 v2.5.3 + fyne.io/fyne/v2 v2.5.4 github.com/AgustinSRG/go-child-process-manager v1.0.1 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 github.com/abhinavxd/youtube-live-chat-downloader/v2 v2.0.3 diff --git a/go.sum b/go.sum index cb8665b..e4d34c1 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,6 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8= -fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo= fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/AgustinSRG/go-child-process-manager v1.0.1 h1:wZpPE0LAXc5rzlU028CDt/K/Woc2IhgCV4/g+9x2WHo= @@ -686,6 +684,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicklaw5/helix/v2 v2.30.1-0.20240715193454-0151ccccf980 h1:PhRVFPI7eXzLajhhZuReQUy2cuViKqkASRKXV7o2G7I= github.com/nicklaw5/helix/v2 v2.30.1-0.20240715193454-0151ccccf980/go.mod h1:e1GsZq4NDk9sQlPJ0Nr3+14R9cizqg09VAk7/IonpOU= github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= @@ -936,6 +936,8 @@ github.com/xaionaro-go/audio v0.0.0-20250111142716-aa10611bd8a0 h1:m3CEneWBmYz2m github.com/xaionaro-go/audio v0.0.0-20250111142716-aa10611bd8a0/go.mod h1:vo8MOC0grCD/ZUSP06SxXJBeDukqBCC1uD2PHmEH9YM= github.com/xaionaro-go/datacounter v1.0.4 h1:+QMZLmu73R5WGkQfUPwlXF/JFN+Weo4iuDZkiL2wVm8= github.com/xaionaro-go/datacounter v1.0.4/go.mod h1:Sf9vBevuV6w5iE6K3qJ9pWVKcyS60clWBUSQLjt5++c= +github.com/xaionaro-go/fyne/v2 v2.0.0-20250215180758-399edb421067 h1:58GgTbQcOCjv1ZZ46m6WQ8zqv0KEJe5C6D5Ls1oSHvc= +github.com/xaionaro-go/fyne/v2 v2.0.0-20250215180758-399edb421067/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo= github.com/xaionaro-go/go-rtmp v0.0.0-20241009130244-1e3160f27f42 h1:izCjREd+62HDF9FRYqUI7dgJNdUxAIysEuqed8lBcDY= github.com/xaionaro-go/go-rtmp v0.0.0-20241009130244-1e3160f27f42/go.mod h1:IuQWd+hy/tLuvuqFX0N9SMZrzOprM8Jvvdu+42RJwk4= github.com/xaionaro-go/goobs v0.0.0-20241103210141-030e538ac440 h1:hzQ+65oWq54XAqheyJ9E6wt+WH75051w+eLP5zWlD68= diff --git a/pkg/streamd/config/dashboard_source_image.go b/pkg/streamd/config/dashboard_source_image.go index 1350c10..df94314 100644 --- a/pkg/streamd/config/dashboard_source_image.go +++ b/pkg/streamd/config/dashboard_source_image.go @@ -8,23 +8,27 @@ import ( "time" "github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc" + "github.com/xaionaro-go/recoder" "github.com/xaionaro-go/streamctl/pkg/streamtypes" ) type DashboardSourceImageType string const ( - DashboardSourceImageTypeUndefined = DashboardSourceImageType("") - DashboardSourceImageTypeDummy = DashboardSourceImageType("dummy") - DashboardSourceImageTypeOBSVideo = DashboardSourceImageType("obs_video") // rename to `obs_screenshot` - DashboardSourceImageTypeOBSVolume = DashboardSourceImageType("obs_volume") + DashboardSourceImageTypeUndefined = DashboardSourceImageType("") + DashboardSourceImageTypeDummy = DashboardSourceImageType("dummy") + DashboardSourceImageTypeStreamScreenshot = DashboardSourceImageType("stream_screenshot") + DashboardSourceImageTypeOBSScreenshot = DashboardSourceImageType("obs_screenshot") + DashboardSourceImageTypeOBSVolume = DashboardSourceImageType("obs_volume") ) func (mst DashboardSourceImageType) New() SourceImage { switch mst { case DashboardSourceImageTypeDummy: return &DashboardSourceImageDummy{} - case DashboardSourceImageTypeOBSVideo: + case DashboardSourceImageTypeStreamScreenshot: + return &DashboardSourceImageStreamScreenshot{} + case DashboardSourceImageTypeOBSScreenshot: return &DashboardSourceImageOBSScreenshot{} case DashboardSourceImageTypeOBSVolume: return &DashboardSourceImageOBSVolume{} @@ -42,14 +46,6 @@ const ( ImageFormatWebP = ImageFormat("webp") ) -type GetImageByteser interface { - GetImageBytes( - ctx context.Context, - obsServer obs_grpc.OBSServer, - el DashboardElementConfig, - ) ([]byte, string, time.Time, error) -} - type Duration time.Duration func (d Duration) MarshalJSON() ([]byte, error) { @@ -80,17 +76,30 @@ func (d *Duration) UnmarshalJSON(b []byte) error { var _ SourceImage = (*DashboardSourceImageDummy)(nil) +type ImageDataProvider interface { + GetOBSServer(context.Context) (obs_grpc.OBSServer, error) + GetOBSState(context.Context) (*streamtypes.OBSState, error) + GetCurrentStreamFrame(context.Context, streamtypes.StreamID) ([]byte, recoder.VideoCodec, error) +} + type SourceImage interface { GetImage( ctx context.Context, - obsServer obs_grpc.OBSServer, el DashboardElementConfig, - obsState *streamtypes.OBSState, + imageDataProvider ImageDataProvider, ) (image.Image, time.Time, error) SourceType() DashboardSourceImageType } +type GetImageBytes interface { + GetImageBytes( + ctx context.Context, + el DashboardElementConfig, + imageDataProvider ImageDataProvider, + ) ([]byte, string, time.Time, error) +} + type serializableSourceImage struct { Type DashboardSourceImageType `yaml:"type"` Config map[string]any `yaml:"config,omitempty"` diff --git a/pkg/streamd/config/dashboard_source_image_dummy.go b/pkg/streamd/config/dashboard_source_image_dummy.go index 3235a36..90fb25f 100644 --- a/pkg/streamd/config/dashboard_source_image_dummy.go +++ b/pkg/streamd/config/dashboard_source_image_dummy.go @@ -4,18 +4,14 @@ import ( "context" "image" "time" - - "github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc" - "github.com/xaionaro-go/streamctl/pkg/streamtypes" ) type DashboardSourceImageDummy struct{} func (*DashboardSourceImageDummy) GetImage( ctx context.Context, - obsServer obs_grpc.OBSServer, el DashboardElementConfig, - obsState *streamtypes.OBSState, + _ ImageDataProvider, ) (image.Image, time.Time, error) { img := image.NewRGBA(image.Rectangle{ Min: image.Point{ diff --git a/pkg/streamd/config/dashboard_source_image_obs.go b/pkg/streamd/config/dashboard_source_image_obs.go new file mode 100644 index 0000000..b89e1bf --- /dev/null +++ b/pkg/streamd/config/dashboard_source_image_obs.go @@ -0,0 +1,27 @@ +package config + +import ( + "context" + "image" + "time" + + "github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc" + "github.com/xaionaro-go/streamctl/pkg/streamtypes" +) + +type GetImageBytesFromOBSer interface { + GetImageBytesFromOBS( + ctx context.Context, + obsServer obs_grpc.OBSServer, + el DashboardElementConfig, + ) ([]byte, string, time.Time, error) +} + +type GetImageFromOBSer interface { + GetImageFromOBS( + ctx context.Context, + obsServer obs_grpc.OBSServer, + el DashboardElementConfig, + obsState *streamtypes.OBSState, + ) (image.Image, time.Time, error) +} diff --git a/pkg/streamd/config/dashboard_source_image_obs_screenshot.go b/pkg/streamd/config/dashboard_source_image_obs_screenshot.go index 44692f7..2da8b64 100644 --- a/pkg/streamd/config/dashboard_source_image_obs_screenshot.go +++ b/pkg/streamd/config/dashboard_source_image_obs_screenshot.go @@ -26,20 +26,21 @@ type DashboardSourceImageOBSScreenshot struct { } var _ SourceImage = (*DashboardSourceImageOBSScreenshot)(nil) -var _ GetImageByteser = (*DashboardSourceImageOBSScreenshot)(nil) +var _ GetImageFromOBSer = (*DashboardSourceImageOBSScreenshot)(nil) +var _ GetImageBytesFromOBSer = (*DashboardSourceImageOBSScreenshot)(nil) func (*DashboardSourceImageOBSScreenshot) SourceType() DashboardSourceImageType { - return DashboardSourceImageTypeOBSVideo + return DashboardSourceImageTypeOBSScreenshot } func obsGetImage( ctx context.Context, - getImageByteser GetImageByteser, + getImageByteser GetImageBytesFromOBSer, obsServer obs_grpc.OBSServer, el DashboardElementConfig, _ *streamtypes.OBSState, ) (image.Image, time.Time, error) { - b, mimeType, nextUpdateTS, err := getImageByteser.GetImageBytes(ctx, obsServer, el) + b, mimeType, nextUpdateTS, err := getImageByteser.GetImageBytesFromOBS(ctx, obsServer, el) if err != nil { return nil, nextUpdateTS, fmt.Errorf("unable to get the image from OBS: %w", err) } @@ -62,7 +63,7 @@ func obsGetImage( return img, nextUpdateTS, nil } -func (s *DashboardSourceImageOBSScreenshot) GetImage( +func (s *DashboardSourceImageOBSScreenshot) GetImageFromOBS( ctx context.Context, obsServer obs_grpc.OBSServer, el DashboardElementConfig, @@ -71,7 +72,7 @@ func (s *DashboardSourceImageOBSScreenshot) GetImage( return obsGetImage(ctx, s, obsServer, el, obsState) } -func (s *DashboardSourceImageOBSScreenshot) GetImageBytes( +func (s *DashboardSourceImageOBSScreenshot) GetImageBytesFromOBS( ctx context.Context, obsServer obs_grpc.OBSServer, el DashboardElementConfig, @@ -133,3 +134,31 @@ func (s *DashboardSourceImageOBSScreenshot) GetImageBytes( ) return imgBytes, mimeType, time.Now().Add(time.Duration(s.UpdateInterval)), nil } + +func (s *DashboardSourceImageOBSScreenshot) GetImageBytes( + ctx context.Context, + el DashboardElementConfig, + dataProvider ImageDataProvider, +) ([]byte, string, time.Time, error) { + obsServer, err := dataProvider.GetOBSServer(ctx) + if err != nil { + return nil, "", time.Time{}, fmt.Errorf("unable to get the OBS server: %w", err) + } + return s.GetImageBytesFromOBS(ctx, obsServer, el) +} + +func (s *DashboardSourceImageOBSScreenshot) GetImage( + ctx context.Context, + el DashboardElementConfig, + dataProvider ImageDataProvider, +) (image.Image, time.Time, error) { + obsServer, err := dataProvider.GetOBSServer(ctx) + if err != nil { + return nil, time.Time{}, fmt.Errorf("unable to get the OBS server: %w", err) + } + obsState, err := dataProvider.GetOBSState(ctx) + if err != nil { + return nil, time.Time{}, fmt.Errorf("unable to get the OBS state: %w", err) + } + return s.GetImageFromOBS(ctx, obsServer, el, obsState) +} diff --git a/pkg/streamd/config/dashboard_source_image_obs_volume.go b/pkg/streamd/config/dashboard_source_image_obs_volume.go index d18aa52..d506b0b 100644 --- a/pkg/streamd/config/dashboard_source_image_obs_volume.go +++ b/pkg/streamd/config/dashboard_source_image_obs_volume.go @@ -23,7 +23,7 @@ type DashboardSourceImageOBSVolume struct { var _ SourceImage = (*DashboardSourceImageOBSVolume)(nil) -func (s *DashboardSourceImageOBSVolume) GetImage( +func (s *DashboardSourceImageOBSVolume) GetImageFromOBS( ctx context.Context, obsServer obs_grpc.OBSServer, el DashboardElementConfig, @@ -94,3 +94,19 @@ func (s *DashboardSourceImageOBSVolume) GetImage( func (*DashboardSourceImageOBSVolume) SourceType() DashboardSourceImageType { return DashboardSourceImageTypeOBSVolume } + +func (s *DashboardSourceImageOBSVolume) GetImage( + ctx context.Context, + el DashboardElementConfig, + dataProvider ImageDataProvider, +) (image.Image, time.Time, error) { + obsServer, err := dataProvider.GetOBSServer(ctx) + if err != nil { + return nil, time.Time{}, fmt.Errorf("unable to get the OBS server: %w", err) + } + obsState, err := dataProvider.GetOBSState(ctx) + if err != nil { + return nil, time.Time{}, fmt.Errorf("unable to get the OBS state: %w", err) + } + return s.GetImageFromOBS(ctx, obsServer, el, obsState) +} diff --git a/pkg/streamd/config/dashboard_source_image_stream_screenshot.go b/pkg/streamd/config/dashboard_source_image_stream_screenshot.go new file mode 100644 index 0000000..8248467 --- /dev/null +++ b/pkg/streamd/config/dashboard_source_image_stream_screenshot.go @@ -0,0 +1,37 @@ +package config + +import ( + "context" + "fmt" + "image" + "time" +) + +type DashboardSourceImageStreamScreenshot struct { + Name string `yaml:"name" json:"name"` + Width float64 `yaml:"width" json:"width"` + Height float64 `yaml:"height" json:"height"` + UpdateInterval Duration `yaml:"update_interval" json:"update_interval"` +} + +var _ SourceImage = (*DashboardSourceImageOBSScreenshot)(nil) +var _ GetImageBytesFromOBSer = (*DashboardSourceImageOBSScreenshot)(nil) + +func (*DashboardSourceImageStreamScreenshot) SourceType() DashboardSourceImageType { + return DashboardSourceImageTypeStreamScreenshot +} + +func (s *DashboardSourceImageStreamScreenshot) GetImage( + ctx context.Context, + el DashboardElementConfig, + dp ImageDataProvider, +) (image.Image, time.Time, error) { + return nil, time.Time{}, fmt.Errorf("not implemented") +} + +func (s *DashboardSourceImageStreamScreenshot) GetImageBytes( + ctx context.Context, + el DashboardElementConfig, +) ([]byte, string, time.Time, error) { + return nil, "", time.Time{}, fmt.Errorf("not implemented") +} diff --git a/pkg/streamd/image_data_provider.go b/pkg/streamd/image_data_provider.go new file mode 100644 index 0000000..336036f --- /dev/null +++ b/pkg/streamd/image_data_provider.go @@ -0,0 +1,47 @@ +package streamd + +import ( + "context" + "fmt" + + "github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc" + "github.com/xaionaro-go/recoder" + "github.com/xaionaro-go/streamctl/pkg/streamd/config" + "github.com/xaionaro-go/streamctl/pkg/streamtypes" +) + +type imageDataProvider struct { + OBSServer obs_grpc.OBSServer + OBSState *OBSState +} + +var _ config.ImageDataProvider = (*imageDataProvider)(nil) + +func newImageDataProvider( + obsServer obs_grpc.OBSServer, + obsState *OBSState, +) *imageDataProvider { + return &imageDataProvider{ + OBSServer: obsServer, + OBSState: obsState, + } +} + +func (img *imageDataProvider) GetOBSServer( + ctx context.Context, +) (obs_grpc.OBSServer, error) { + return img.OBSServer, nil +} + +func (img *imageDataProvider) GetOBSState( + ctx context.Context, +) (*streamtypes.OBSState, error) { + return img.OBSState, nil +} + +func (img *imageDataProvider) GetCurrentStreamFrame( + ctx context.Context, + streamID streamtypes.StreamID, +) ([]byte, recoder.VideoCodec, error) { + return nil, 0, fmt.Errorf("not implemented") +} diff --git a/pkg/streamd/image_taker.go b/pkg/streamd/image_taker.go new file mode 100644 index 0000000..2054421 --- /dev/null +++ b/pkg/streamd/image_taker.go @@ -0,0 +1,174 @@ +package streamd + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/chai2010/webp" + "github.com/facebookincubator/go-belt/tool/logger" + "github.com/xaionaro-go/observability" + "github.com/xaionaro-go/streamctl/pkg/streamd/config" + "github.com/xaionaro-go/streamctl/pkg/streamd/consts" + "github.com/xaionaro-go/xsync" +) + +func (d *StreamD) getImageBytes( + ctx context.Context, + elName string, + el config.DashboardElementConfig, + dataProvider config.ImageDataProvider, +) ([]byte, time.Time, error) { + + src := el.Source + if getImageByteser, ok := src.(config.GetImageBytes); ok { + bytes, _, nextUpdateAt, err := getImageByteser.GetImageBytes(ctx, el, dataProvider) + if err != nil { + return nil, time.Now().Add(time.Second), fmt.Errorf("unable to get the image from the source using GetImageByteser: %w", err) + } + return bytes, nextUpdateAt, nil + } + + img, nextUpdateAt, err := el.Source.GetImage(ctx, el, dataProvider) + if err != nil { + return nil, time.Now().Add(time.Second), fmt.Errorf("unable to get the image from the source: %w", err) + } + + if imgHash, err := newImageHash(img); err == nil { + if imgOldHash, ok := d.ImageHash.Swap(elName, imgHash); ok { + if imgHash == imgOldHash { + return nil, nextUpdateAt, ErrNotChanged + } + } + } + + for _, filter := range el.Filters { + img = filter.Filter(ctx, img) + } + + var out bytes.Buffer + err = webp.Encode(&out, img, &webp.Options{ + Lossless: el.ImageLossless, + Quality: float32(el.ImageQuality), + Exact: false, + }) + if err != nil { + return nil, time.Now().Add(time.Second), fmt.Errorf("unable to encode the image: %w", err) + } + + return out.Bytes(), nextUpdateAt, nil +} + +func (d *StreamD) initImageTaker(ctx context.Context) error { + observability.Go(ctx, func() { + defer logger.Debugf(ctx, "/imageTaker") + ch, err := d.SubscribeToDashboardChanges(ctx) + if err != nil { + logger.Errorf(ctx, "unable to subscribe to dashboard changes: %v", err) + return + } + for { + select { + case <-ctx.Done(): + return + case <-ch: + d.restartImageTaker(ctx) + } + } + }) + + return d.restartImageTaker(ctx) +} + +func (d *StreamD) restartImageTaker(ctx context.Context) error { + return xsync.DoA1R1(ctx, &d.imageTakerLocker, d.restartImageTakerNoLock, ctx) +} + +func (d *StreamD) restartImageTakerNoLock(ctx context.Context) error { + if d.imageTakerCancel != nil { + d.imageTakerCancel() + d.imageTakerCancel = nil + d.imageTakerWG.Wait() + } + + ctx, cancelFn := context.WithCancel(ctx) + d.imageTakerCancel = cancelFn + + for elName, el := range d.Config.Dashboard.Elements { + if el.Source == nil { + continue + } + if _, ok := el.Source.(*config.DashboardSourceImageDummy); ok { + continue + } + { + elName, el := elName, el + _ = el + d.imageTakerWG.Add(1) + observability.Go(ctx, func() { + defer d.imageTakerWG.Done() + logger.Debugf(ctx, "taker of image '%s'", elName) + defer logger.Debugf(ctx, "/taker of image '%s'", elName) + + obsServer, obsServerClose, err := d.OBS(ctx) + if obsServerClose != nil { + defer obsServerClose() + } + if err != nil { + logger.Errorf(ctx, "unable to init connection with OBS: %v", err) + return + } + + imageDataProvider := newImageDataProvider(obsServer, &d.OBSState) + + for { + var ( + imgBytes []byte + nextUpdateAt time.Time + err error + ) + + waitUntilNextIteration := func() bool { + if nextUpdateAt.IsZero() { + return false + } + select { + case <-ctx.Done(): + return false + case <-time.After(time.Until(nextUpdateAt)): + return true + } + } + + imgBytes, nextUpdateAt, err = d.getImageBytes(ctx, elName, el, imageDataProvider) + if err != nil { + if err != ErrNotChanged { + logger.Tracef(ctx, "the image have not changed of '%s'", elName) + } else { + logger.Errorf(ctx, "unable to get the image of '%s': %v", elName, err) + } + if !waitUntilNextIteration() { + return + } + continue + } + + err = d.SetVariable(ctx, consts.VarKeyImage(consts.ImageID(elName)), imgBytes) + if err != nil { + logger.Errorf(ctx, "unable to save the image of '%s': %v", elName, err) + if !waitUntilNextIteration() { + return + } + continue + } + + if !waitUntilNextIteration() { + return + } + } + }) + } + } + return nil +} diff --git a/pkg/streamd/obs.go b/pkg/streamd/obs.go index 2a97425..fd79451 100644 --- a/pkg/streamd/obs.go +++ b/pkg/streamd/obs.go @@ -1,22 +1,15 @@ package streamd import ( - "bytes" "context" "errors" "fmt" - "time" "github.com/andreykaipov/goobs" - "github.com/chai2010/webp" "github.com/facebookincubator/go-belt/tool/logger" "github.com/xaionaro-go/obs-grpc-proxy/pkg/obsgrpcproxy" "github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc" - "github.com/xaionaro-go/observability" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/obs" - "github.com/xaionaro-go/streamctl/pkg/streamd/config" - "github.com/xaionaro-go/streamctl/pkg/streamd/consts" - "github.com/xaionaro-go/streamctl/pkg/streamtypes" "github.com/xaionaro-go/xsync" ) @@ -59,164 +52,6 @@ func (d *StreamD) OBS( var ErrNotChanged = errors.New("not changed") -func (d *StreamD) getOBSImageBytes( - ctx context.Context, - obsServer obs_grpc.OBSServer, - elName string, - el config.DashboardElementConfig, - obsState *streamtypes.OBSState, -) ([]byte, time.Time, error) { - - src := el.Source - if getImageByteser, ok := src.(config.GetImageByteser); ok { - bytes, _, nextUpdateAt, err := getImageByteser.GetImageBytes(ctx, obsServer, el) - if err != nil { - return nil, time.Now().Add(time.Second), fmt.Errorf("unable to get the image from the source using GetImageByteser: %w", err) - } - return bytes, nextUpdateAt, nil - } - - img, nextUpdateAt, err := el.Source.GetImage(ctx, obsServer, el, obsState) - if err != nil { - return nil, time.Now().Add(time.Second), fmt.Errorf("unable to get the image from the source: %w", err) - } - - if imgHash, err := newImageHash(img); err == nil { - if imgOldHash, ok := d.ImageHash.Swap(elName, imgHash); ok { - if imgHash == imgOldHash { - return nil, nextUpdateAt, ErrNotChanged - } - } - } - - for _, filter := range el.Filters { - img = filter.Filter(ctx, img) - } - - var out bytes.Buffer - err = webp.Encode(&out, img, &webp.Options{ - Lossless: el.ImageLossless, - Quality: float32(el.ImageQuality), - Exact: false, - }) - if err != nil { - return nil, time.Now().Add(time.Second), fmt.Errorf("unable to encode the image: %w", err) - } - - return out.Bytes(), nextUpdateAt, nil -} - -func (d *StreamD) initImageTaker(ctx context.Context) error { - observability.Go(ctx, func() { - defer logger.Debugf(ctx, "/imageTaker") - ch, err := d.SubscribeToDashboardChanges(ctx) - if err != nil { - logger.Errorf(ctx, "unable to subscribe to dashboard changes: %v", err) - return - } - for { - select { - case <-ctx.Done(): - return - case <-ch: - d.restartImageTaker(ctx) - } - } - }) - - return d.restartImageTaker(ctx) -} - -func (d *StreamD) restartImageTaker(ctx context.Context) error { - return xsync.DoA1R1(ctx, &d.imageTakerLocker, d.restartImageTakerNoLock, ctx) -} - -func (d *StreamD) restartImageTakerNoLock(ctx context.Context) error { - if d.imageTakerCancel != nil { - d.imageTakerCancel() - d.imageTakerCancel = nil - d.imageTakerWG.Wait() - } - - ctx, cancelFn := context.WithCancel(ctx) - d.imageTakerCancel = cancelFn - - for elName, el := range d.Config.Dashboard.Elements { - if el.Source == nil { - continue - } - if _, ok := el.Source.(*config.DashboardSourceImageDummy); ok { - continue - } - { - elName, el := elName, el - _ = el - d.imageTakerWG.Add(1) - observability.Go(ctx, func() { - defer d.imageTakerWG.Done() - logger.Debugf(ctx, "taker of image '%s'", elName) - defer logger.Debugf(ctx, "/taker of image '%s'", elName) - - obsServer, obsServerClose, err := d.OBS(ctx) - if obsServerClose != nil { - defer obsServerClose() - } - if err != nil { - logger.Errorf(ctx, "unable to init connection with OBS: %v", err) - return - } - - for { - var ( - imgBytes []byte - nextUpdateAt time.Time - err error - ) - - waitUntilNextIteration := func() bool { - if nextUpdateAt.IsZero() { - return false - } - select { - case <-ctx.Done(): - return false - case <-time.After(time.Until(nextUpdateAt)): - return true - } - } - - imgBytes, nextUpdateAt, err = d.getOBSImageBytes(ctx, obsServer, elName, el, &d.OBSState) - if err != nil { - if err != ErrNotChanged { - logger.Tracef(ctx, "the image have not changed of '%s'", elName) - } else { - logger.Errorf(ctx, "unable to get the image of '%s': %v", elName, err) - } - if !waitUntilNextIteration() { - return - } - continue - } - - err = d.SetVariable(ctx, consts.VarKeyImage(consts.ImageID(elName)), imgBytes) - if err != nil { - logger.Errorf(ctx, "unable to save the image of '%s': %v", elName, err) - if !waitUntilNextIteration() { - return - } - continue - } - - if !waitUntilNextIteration() { - return - } - } - }) - } - } - return nil -} - type SceneElementIdentifier struct { Name *string UUID *string diff --git a/pkg/streampanel/dashboard.go b/pkg/streampanel/dashboard.go index 1c1a5dd..dc13293 100644 --- a/pkg/streampanel/dashboard.go +++ b/pkg/streampanel/dashboard.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "image/color" + "image/draw" "runtime" "sort" "strconv" @@ -35,6 +36,7 @@ import ( streamdconfig "github.com/xaionaro-go/streamctl/pkg/streamd/config" streamdconsts "github.com/xaionaro-go/streamctl/pkg/streamd/consts" "github.com/xaionaro-go/streamctl/pkg/streampanel/consts" + "github.com/xaionaro-go/streamctl/pkg/ximage" xfyne "github.com/xaionaro-go/xfyne/widget" "github.com/xaionaro-go/xsync" ) @@ -64,11 +66,20 @@ type dashboardWindow struct { lastWinSize fyne.Size lastOrientation fyne.DeviceOrientation screenshotContainer *fyne.Container - layersContainer *fyne.Container + imagesLocker xsync.RWMutex + imagesLayerObj *canvas.Raster + images []imageInfo + renderedImagesLayer *image.NRGBA streamStatus map[streamcontrol.PlatformName]*widget.Label streamStatusLocker xsync.Mutex } +type imageInfo struct { + ElementName string + streamdconfig.DashboardElementConfig + Image *image.NRGBA +} + func (w *dashboardWindow) renderStreamStatus(ctx context.Context) { w.streamStatusLocker.Do(ctx, func() { streamDClient, ok := w.StreamD.(*client.Client) @@ -187,18 +198,198 @@ func (p *Panel) newDashboardWindow( nil, streamInfoItems, ) - w.layersContainer = container.NewStack() + w.imagesLayerObj = canvas.NewRaster(w.imagesLayer) w.Window.SetContent(container.NewStack( bgFyne, w.screenshotContainer, - w.layersContainer, + w.imagesLayerObj, streamInfoContainer, )) w.Window.Show() return w } +func (w *dashboardWindow) imagesLayer(width, height int) (_ret image.Image) { + ctx := context.TODO() + logger.Tracef(ctx, "imagesLayer(%d, %d)", width, height) + defer func() { logger.Tracef(ctx, "/imagesLayer(%d, %d): size:%v", width, height, _ret.Bounds()) }() + return xsync.DoR1(xsync.WithNoLogging(ctx, true), &w.imagesLocker, func() image.Image { + return w.renderImagesNoLock(ctx, width, height) + }) +} + +func (w *dashboardWindow) renderImagesNoLock( + ctx context.Context, + width, height int, +) image.Image { + if w.renderedImagesLayer == nil { + w.renderedImagesLayer = image.NewNRGBA(image.Rectangle{ + Min: image.Point{}, + Max: image.Point{ + X: width, + Y: height, + }, + }) + } + + dstImg := w.renderedImagesLayer + size := dstImg.Bounds().Size() + if size.X != width || size.Y != height { + w.renderedImagesLayer = image.NewNRGBA(image.Rectangle{ + Min: image.Point{}, + Max: image.Point{ + X: width, + Y: height, + }, + }) + dstImg = w.renderedImagesLayer + size = dstImg.Bounds().Size() + } + + canvasRatio := float64(size.X) / float64(size.Y) + + transformedImages := make([]*ximage.Transform, 0, len(w.images)) + for idx, img := range w.images { + if img.Image == nil { + continue + } + imgSize := img.Image.Bounds().Size() + imgRatio := float64(imgSize.X) / float64(imgSize.Y) + imgCanvasRatio := imgRatio / canvasRatio + imgWidth := img.Width + imgHeight := img.Height + switch { + case imgCanvasRatio == 1: + case imgCanvasRatio > 1: + imgHeight /= imgCanvasRatio + case imgCanvasRatio < 1: + imgWidth *= imgCanvasRatio + } + var xMin, yMin float64 + switch img.AlignX { + case streamdconsts.AlignXLeft: + xMin = img.OffsetX + case streamdconsts.AlignXMiddle: + xMin = (100-imgWidth)/2 + img.OffsetX + case streamdconsts.AlignXRight: + xMin = (100 - imgWidth) + img.OffsetX + } + xMax := xMin + imgWidth + switch img.AlignY { + case streamdconsts.AlignYTop: + yMin = img.OffsetY + case streamdconsts.AlignYMiddle: + yMin = (100-imgHeight)/2 + img.OffsetY + case streamdconsts.AlignYBottom: + xMin = (100 - imgHeight) + img.OffsetY + } + yMax := yMin + imgHeight + rectangle := ximage.RectangleFloat64{ + Min: ximage.PointFloat64{ + X: xMin / 100, + Y: yMin / 100, + }, + Max: ximage.PointFloat64{ + X: xMax / 100, + Y: yMax / 100, + }, + } + logger.Tracef(ctx, "transformation rectangle for %d (%#+v) is %v", idx, img.DashboardElementConfig, rectangle) + transformedImages = append(transformedImages, ximage.NewTransform(img.Image, color.NRGBAModel, rectangle)) + } + for idx := range dstImg.Pix { + dstImg.Pix[idx] = 0 + } + for y := 0; y < height; y++ { + yF := (float64(y) + 0.5) / float64(height) + idxY := (y - dstImg.Rect.Min.Y) * dstImg.Stride + var xBoundaries []float64 + for _, tImg := range transformedImages { + xBoundaries = append( + xBoundaries, + (float64(tImg.To.Min.X)+0.5)/float64(width), + (float64(tImg.To.Max.X)+0.5)/float64(width), + ) + } + boundaryIdx := -1 + nextSwitchX := float64(-1) + var tImg *ximage.Transform + var dup float64 + for x := 0; x < width; x++ { + idxX := (x - dstImg.Rect.Min.X) * 4 + idx := idxY + idxX + if dstImg.Pix[idx+3] != 0 { + continue + } + xF := (float64(x) + 0.5) / float64(width) + if xF > nextSwitchX { + tImg = nil + dup = 0 + for _, _tImg := range transformedImages { + c := _tImg.To.AtFloat64(xF, yF) + if c == nil { + continue + } + tImg = _tImg + dstSize := dstImg.Bounds().Size() + dup = min( + (float64(dstSize.X)*tImg.To.Size().X)/(float64(tImg.ImageSize.X)), + (float64(dstSize.Y)*tImg.To.Size().Y)/(float64(tImg.ImageSize.Y)), + ) + break + } + boundaryIdx++ + if boundaryIdx < len(xBoundaries) { + nextSwitchX = xBoundaries[boundaryIdx] + } + } + if tImg == nil { + continue + } + srcX, srcY, ok := tImg.Coords(xF, yF) + if !ok { + continue + } + srcImg := tImg.Image.(*image.NRGBA) + srcOffset := srcImg.PixOffset(srcX, srcY) + if srcOffset < 0 || srcOffset+4 >= len(srcImg.Pix) { + continue + } + + dstOffset := dstImg.PixOffset(x, y) + if dstOffset < 0 || dstOffset+4 >= len(dstImg.Pix) { + continue + } + copy( + dstImg.Pix[dstOffset:dstOffset+4:dstOffset+4], + srcImg.Pix[srcOffset:srcOffset+4:srcOffset+4], + ) + if dup > 1 { + xE := int(float64(x+1)*dup) - int(float64(x)*dup) - 1 + yE := int(float64(y+1)*dup) - int(float64(y)*dup) - 1 + + for i := 0; i <= yE; i++ { + for j := 0; j <= xE; j++ { + if i == 0 && j == 0 { + continue + } + dstOffset := dstImg.PixOffset(x+j, y+i) + if dstOffset < 0 || dstOffset+4 >= len(dstImg.Pix) { + continue + } + copy( + dstImg.Pix[dstOffset:dstOffset+4:dstOffset+4], + srcImg.Pix[srcOffset:srcOffset+4:srcOffset+4], + ) + } + } + } + } + } + return dstImg +} + func (w *dashboardWindow) startUpdating( ctx context.Context, ) { @@ -326,15 +517,9 @@ func (w *dashboardWindow) updateImagesNoLock( logger.Debugf(ctx, "window size changed %#+v -> %#+v", lastWinSize, winSize) } - type elementType struct { - ElementName string - streamdconfig.DashboardElementConfig - NewImage *canvas.Image - } - - elements := make([]elementType, 0, len(dashboardCfg.Elements)) + elements := make([]imageInfo, 0, len(dashboardCfg.Elements)) for elName, el := range dashboardCfg.Elements { - elements = append(elements, elementType{ + elements = append(elements, imageInfo{ ElementName: elName, DashboardElementConfig: el, }) @@ -346,16 +531,30 @@ func (w *dashboardWindow) updateImagesNoLock( return elements[i].ElementName < elements[j].ElementName }) + elementsMap := map[string]*imageInfo{} + for idx := range elements { + item := &elements[idx] + elementsMap[item.ElementName] = item + } + + for _, item := range w.images { + obj := elementsMap[item.ElementName] + if obj == nil { + continue + } + obj.Image = item.Image + } + w.imagesLocker.Do(xsync.WithNoLogging(ctx, true), func() { + w.images = elements + }) + + var changeCount atomic.Uint64 var wg sync.WaitGroup for idx := range elements { wg.Add(1) { el := &elements[idx] - var oldObj fyne.CanvasObject - if len(w.layersContainer.Objects) > idx { - oldObj = w.layersContainer.Objects[idx] - } observability.Go(ctx, func() { defer wg.Done() img, changed, err := w.getImage(ctx, streamdconsts.ImageID(el.ElementName)) @@ -374,33 +573,13 @@ func (w *dashboardWindow) updateImagesNoLock( lastWinSize, winSize, ) - imgSize := image.Point{ - X: int(winSize.Width * float32(el.Width) / 100), - Y: int(winSize.Height * float32(el.Height) / 100), - } - offset := image.Point{ - X: int(winSize.Width * float32(el.OffsetX) / 100), - Y: int(winSize.Height * float32(el.OffsetY) / 100), - } - var oldImg image.Image - if oldCanvasImg, ok := oldObj.(*canvas.Image); ok { - oldImg = oldCanvasImg.Image - } - img = imgFillTo( - ctx, - oldImg, - img, - image.Point{X: int(winSize.Width), Y: int(winSize.Height)}, - imgSize, - offset, - el.AlignX, - el.AlignY, - ) - imgFyne := canvas.NewImageFromImage(img) - imgFyne.FillMode = canvas.ImageFillContain - imgFyne.SetMinSize(fyne.NewSize(1, 1)) - logger.Tracef(ctx, "image '%s' size: %#+v", el.ElementName, img.Bounds().Size()) - el.NewImage = imgFyne + b := img.Bounds() + m := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(m, m.Bounds(), img, b.Min, draw.Src) + w.imagesLocker.Do(xsync.WithNoLogging(ctx, true), func() { + el.Image = m + }) + changeCount.Add(1) }) } } @@ -442,37 +621,19 @@ func (w *dashboardWindow) updateImagesNoLock( ) img = adjust.Brightness(img, -0.5) imgFyne := canvas.NewImageFromImage(img) - imgFyne.FillMode = canvas.ImageFillContain + imgFyne.FillMode = canvas.ImageFillOriginal logger.Tracef(ctx, "screenshot image size: %#+v", img.Bounds().Size()) w.screenshotContainer.Objects = w.screenshotContainer.Objects[:0] w.screenshotContainer.Objects = append(w.screenshotContainer.Objects, imgFyne) w.screenshotContainer.Refresh() + changeCount.Add(1) }) wg.Wait() - if len(w.layersContainer.Objects) != len(elements) { - w.layersContainer.Objects = w.layersContainer.Objects[:0] - img := image.NewRGBA(image.Rectangle{ - Max: image.Point{ - X: 1, - Y: 1, - }, - }) - for len(w.layersContainer.Objects) < len(elements) { - w.layersContainer.Objects = append( - w.layersContainer.Objects, - canvas.NewImageFromImage(img), - ) - } + if changeCount.Load() > 0 { + w.imagesLayerObj.Refresh() } - for idx, el := range elements { - if el.NewImage == nil { - continue - } - w.layersContainer.Objects[idx] = el.NewImage - } - w.layersContainer.Refresh() } func (p *Panel) newDashboardSettingsWindow(ctx context.Context) { @@ -970,7 +1131,7 @@ func (p *Panel) editDashboardElementWindow( var volumeColorActiveParsed color.Color if volumeColorActiveParsed, err = colorx.Parse(obsVolumeSource.ColorActive); err != nil { - volumeColorActiveParsed = color.RGBA{R: 0, G: 255, B: 0, A: 255} + volumeColorActiveParsed = color.NRGBA{R: 0, G: 255, B: 0, A: 255} } volumeColorActive := colorpicker.NewColorSelectModalRect( w, @@ -985,7 +1146,7 @@ func (p *Panel) editDashboardElementWindow( var volumeColorPassiveParsed color.Color if volumeColorPassiveParsed, err = colorx.Parse(obsVolumeSource.ColorPassive); err != nil { - volumeColorPassiveParsed = color.RGBA{R: 0, G: 0, B: 0, A: 0} + volumeColorPassiveParsed = color.NRGBA{R: 0, G: 0, B: 0, A: 0} } volumeColorPassive := colorpicker.NewColorSelectModalRect( w, @@ -1011,12 +1172,13 @@ func (p *Panel) editDashboardElementWindow( ) sourceTypeSelect := widget.NewSelect([]string{ - string(streamdconfig.DashboardSourceImageTypeOBSVideo), + string(streamdconfig.DashboardSourceImageTypeStreamScreenshot), + string(streamdconfig.DashboardSourceImageTypeOBSScreenshot), string(streamdconfig.DashboardSourceImageTypeOBSVolume), string(streamdconfig.DashboardSourceImageTypeDummy), }, func(s string) { switch streamdconfig.DashboardSourceImageType(s) { - case streamdconfig.DashboardSourceImageTypeOBSVideo: + case streamdconfig.DashboardSourceImageTypeOBSScreenshot: sourceOBSVolumeConfig.Hide() sourceOBSVideoConfig.Show() case streamdconfig.DashboardSourceImageTypeOBSVolume: @@ -1027,7 +1189,7 @@ func (p *Panel) editDashboardElementWindow( sourceOBSVolumeConfig.Hide() } }) - sourceTypeSelect.SetSelected(string(streamdconfig.DashboardSourceImageTypeOBSVideo)) + sourceTypeSelect.SetSelected(string(streamdconfig.DashboardSourceImageTypeOBSScreenshot)) saveButton := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() { if elementName.Text == "" { @@ -1035,7 +1197,7 @@ func (p *Panel) editDashboardElementWindow( return } switch streamdconfig.DashboardSourceImageType(sourceTypeSelect.Selected) { - case streamdconfig.DashboardSourceImageTypeOBSVideo: + case streamdconfig.DashboardSourceImageTypeOBSScreenshot: cfg.Source = obsVideoSource case streamdconfig.DashboardSourceImageTypeOBSVolume: cfg.Source = obsVolumeSource diff --git a/pkg/streampanel/image.go b/pkg/streampanel/image.go index 29e2473..236bfe8 100644 --- a/pkg/streampanel/image.go +++ b/pkg/streampanel/image.go @@ -163,7 +163,11 @@ func imgFillTo( offset image.Point, alignX streamdconsts.AlignX, alignY streamdconsts.AlignY, -) image.Image { +) (_ret image.Image) { + logger.Tracef(ctx, "imgFillTo(ctx, %T, %T, %v, %v, %v, %v, %v)", dstReuse, src, canvasSize, outSize, offset, alignX, alignY) + defer func() { + logger.Tracef(ctx, "/imgFillTo(ctx, %T, %T, %v, %v, %v, %v, %v): %T:%s", dstReuse, src, canvasSize, outSize, offset, alignX, alignY, _ret, _ret.Bounds()) + }() sizeCur := src.Bounds().Size() ratioCur := float64(sizeCur.X) / float64(sizeCur.Y) diff --git a/pkg/ximage/transform.go b/pkg/ximage/transform.go new file mode 100644 index 0000000..2665e16 --- /dev/null +++ b/pkg/ximage/transform.go @@ -0,0 +1,108 @@ +package ximage + +import ( + "image" + "image/color" + "math" +) + +type PointFloat64 struct { + X float64 + Y float64 +} + +type RectangleFloat64 struct { + Min PointFloat64 + Max PointFloat64 +} + +func (t *RectangleFloat64) Size() PointFloat64 { + return PointFloat64{ + X: math.Abs(t.Max.X - t.Min.X), + Y: math.Abs(t.Max.Y - t.Min.Y), + } +} + +func (t *RectangleFloat64) At(x, y int) color.Color { + return t.AtFloat64(float64(x), float64(y)) +} + +func (t *RectangleFloat64) AtFloat64(x, y float64) color.Color { + if x < t.Min.X || x > t.Max.X { + return nil + } + if y < t.Min.Y || y > t.Max.Y { + return nil + } + return color.Gray{Y: 255} +} + +type Transform struct { + image.Image + ImageBounds image.Rectangle + ImageSize image.Point + ColorModelValue color.Model + To RectangleFloat64 +} + +var _ image.Image = (*Transform)(nil) + +func NewTransform( + img image.Image, + colorModel color.Model, + transform RectangleFloat64, +) *Transform { + if img == nil { + return nil + } + return &Transform{ + Image: img, + ImageBounds: img.Bounds(), + ImageSize: img.Bounds().Size(), + ColorModelValue: colorModel, + To: transform, + } +} + +func (t *Transform) ColorModel() color.Model { + return t.ColorModelValue +} + +func (t *Transform) Bounds() image.Rectangle { + return image.Rectangle{ + Min: image.Point{ + X: int(t.To.Min.X), + Y: int(t.To.Min.Y), + }, + Max: image.Point{ + X: int(t.To.Max.X), + Y: int(t.To.Max.Y), + }, + } +} + +func (t *Transform) At(x, y int) color.Color { + return t.AtFloat64(float64(x), float64(y)) +} + +func (t *Transform) Coords(x, y float64) (int, int, bool) { + if x < t.To.Min.X || x > t.To.Max.X { + return 0, 0, false + } + if y < t.To.Min.Y || y > t.To.Max.Y { + return 0, 0, false + } + x = (x - t.To.Min.X) / (t.To.Max.X - t.To.Min.X) + y = (y - t.To.Min.Y) / (t.To.Max.Y - t.To.Min.Y) + srcX := t.ImageBounds.Min.X + int(x*float64(t.ImageSize.X)) + srcY := t.ImageBounds.Min.Y + int(y*float64(t.ImageSize.Y)) + return srcX, srcY, true +} + +func (t *Transform) AtFloat64(x, y float64) color.Color { + srcX, srcY, ok := t.Coords(x, y) + if !ok { + return nil + } + return t.Image.At(srcX, srcY) +}