Optimize Dashboard images display

This commit is contained in:
Dmitrii Okunev
2025-02-15 20:20:29 +00:00
parent 407183ce7f
commit 964a16ee42
15 changed files with 718 additions and 267 deletions

View File

@@ -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.

5
go.mod
View File

@@ -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

6
go.sum
View File

@@ -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=

View File

@@ -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"`

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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")
}

174
pkg/streamd/image_taker.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

108
pkg/ximage/transform.go Normal file
View File

@@ -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)
}