From aab2006e78a628874f4169a1492296d79f0bc508 Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Sat, 19 Sep 2026 20:33:40 +0100 Subject: [PATCH] feat: screen capture scaling / downsampling Add a Scale option (1.0 native, 0.5 half, etc.) that downsamples the captured BGRA frame with bilinear interpolation before JPEG encoding. Config carries the scale factor; the GUI exposes a Scale dropdown (100%/75%/50%/25%) that applies live via SetConfig. Verified: OBS displays the image at the reduced resolution matching the selected scale, without restarting the stream. --- internal/config/config.go | 20 +++++--- internal/flinger/flinger.go | 80 +++++++++++++++++++++++++++++++- internal/flinger/flinger_test.go | 55 ++++++++++++++++++++++ internal/gui/gui.go | 37 +++++++++++++++ 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index ead9682..10192c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,13 +16,14 @@ import ( // Config mirrors flinger.Config with JSON tags for persistence. type Config struct { - Name string `json:"name"` - Port int `json:"port"` - Quality int `json:"quality"` - FPS int `json:"fps"` - Source string `json:"source"` - Audio bool `json:"audio"` - StreamIndex int `json:"stream_index"` + Name string `json:"name"` + Port int `json:"port"` + Quality int `json:"quality"` + FPS int `json:"fps"` + Source string `json:"source"` + Audio bool `json:"audio"` + StreamIndex int `json:"stream_index"` + Scale float64 `json:"scale"` // Announce is a *bool so an absent JSON key (older config files) keeps // the default instead of silently disabling announcements. Announce *bool `json:"announce"` @@ -40,6 +41,7 @@ func Default() Config { Source: c.Source, Audio: c.Audio, StreamIndex: c.StreamIndex, + Scale: c.Scale, Announce: &announce, } } @@ -58,6 +60,7 @@ func (c Config) ToFlinger() flinger.Config { Source: c.Source, Audio: c.Audio, StreamIndex: c.StreamIndex, + Scale: c.Scale, Announce: announce, } } @@ -111,6 +114,9 @@ func LoadFrom(path string) (Config, error) { if c.Source == "" { c.Source = d.Source } + if c.Scale <= 0 || c.Scale > 1 { + c.Scale = d.Scale + } if c.Announce == nil { announce := true c.Announce = &announce diff --git a/internal/flinger/flinger.go b/internal/flinger/flinger.go index 4aa376e..58c4d41 100644 --- a/internal/flinger/flinger.go +++ b/internal/flinger/flinger.go @@ -50,6 +50,9 @@ type Config struct { Audio bool // StreamIndex selects which monitor to capture (screen source only). StreamIndex int + // Scale downscales the captured frame before encoding. 1.0 = native + // resolution, 0.5 = half width/height, etc. Must be in (0, 1]. + Scale float64 // Announce controls whether the stream is advertised via UDP multicast. // When disabled, receivers must connect by IP manually. Announce bool @@ -63,6 +66,7 @@ func DefaultConfig() Config { FPS: 30, Source: "screen", Audio: true, + Scale: 1.0, Announce: true, } } @@ -84,6 +88,9 @@ func (c Config) Validate() error { if c.StreamIndex < 0 { return errors.New("stream index must be >= 0") } + if c.Scale <= 0 || c.Scale > 1 { + return errors.New("scale must be in (0, 1]") + } return nil } @@ -117,6 +124,10 @@ type Engine struct { frames atomic.Int64 + // scaleBuf is the cached downscaled frame, reallocated only when the + // configured scale or source dimensions change. + scaleBuf *capture.VideoFrame + errMu sync.RWMutex lastErr error @@ -348,6 +359,72 @@ func (e *Engine) audioLoop(src io.ReadCloser) { } } +// scaleFrame downscales frame by the configured factor, returning the original +// when scale is 1.0 (native). The result is a cached buffer reused across +// frames, reallocated only when the dimensions or scale change. +func (e *Engine) scaleFrame(frame *capture.VideoFrame, scale float64) *capture.VideoFrame { + if scale >= 1.0 || frame == nil { + return frame + } + + sw := int(float64(frame.Width) * scale) + sh := int(float64(frame.Height) * scale) + if sw < 1 { + sw = 1 + } + if sh < 1 { + sh = 1 + } + + // Reuse the cached buffer if it matches the target size. + if e.scaleBuf == nil || e.scaleBuf.Width != sw || e.scaleBuf.Height != sh { + e.scaleBuf = &capture.VideoFrame{ + Pix: make([]byte, sw*sh*4), + Width: sw, + Height: sh, + Stride: sw * 4, + } + } + + scaleBGRA(frame, e.scaleBuf) + return e.scaleBuf +} + +// scaleBGRA bilinearly downsamples an interleaved BGRA frame into dst. +func scaleBGRA(src, dst *capture.VideoFrame) { + sw, sh := float64(src.Width), float64(src.Height) + for y := 0; y < dst.Height; y++ { + srcY := (float64(y) + 0.5) * sh / float64(dst.Height) + y0 := int(srcY) + if y0 >= src.Height-1 { + y0 = src.Height - 2 + } + yFrac := srcY - float64(y0) + row0 := y0 * src.Stride + row1 := (y0 + 1) * src.Stride + di := y * dst.Stride + + for x := 0; x < dst.Width; x++ { + srcX := (float64(x) + 0.5) * sw / float64(dst.Width) + x0 := int(srcX) + if x0 >= src.Width-1 { + x0 = src.Width - 2 + } + xFrac := srcX - float64(x0) + p00 := row0 + x0*4 + p01 := row0 + (x0+1)*4 + p10 := row1 + x0*4 + p11 := row1 + (x0+1)*4 + + for c := 0; c < 4; c++ { + top := float64(src.Pix[p00+c])*(1-xFrac) + float64(src.Pix[p01+c])*xFrac + bot := float64(src.Pix[p10+c])*(1-xFrac) + float64(src.Pix[p11+c])*xFrac + dst.Pix[di+x*4+c] = uint8(top*(1-yFrac) + bot*yFrac) + } + } + } +} + // videoLoop pulls frames and sends them at the configured fps. The frame rate // and JPEG quality are read from the live config so SetConfig takes effect // without restarting. @@ -388,7 +465,8 @@ func (e *Engine) videoLoop() { next = now.Add(frameInterval) ts := uint64(now.Sub(e.start)) - buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, cfg.Quality) + enc := e.scaleFrame(frame, cfg.Scale) + buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality) if err != nil { e.setErr(err) log.Printf("flinger: jpeg: %v", err) diff --git a/internal/flinger/flinger_test.go b/internal/flinger/flinger_test.go index d9bd1de..e88f7fb 100644 --- a/internal/flinger/flinger_test.go +++ b/internal/flinger/flinger_test.go @@ -5,6 +5,8 @@ import ( "net" "testing" "time" + + "teleportfling/internal/capture" ) // TestEnginePatternStartStop runs the engine with the synthetic pattern source @@ -186,6 +188,59 @@ func TestFormatBitrate(t *testing.T) { } } +// TestScaleBGRA verifies downsampling produces the expected dimensions and +// preserves the dominant colour of a solid frame. +func TestScaleBGRA(t *testing.T) { + src := &capture.VideoFrame{ + Pix: make([]byte, 100*80*4), + Width: 100, + Height: 80, + Stride: 100 * 4, + } + // Fill with solid red (BGRA: B=0, G=0, R=255). + for i := 0; i+4 <= len(src.Pix); i += 4 { + src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 0, 0, 255, 255 + } + + dst := &capture.VideoFrame{ + Pix: make([]byte, 50*40*4), + Width: 50, + Height: 40, + Stride: 50 * 4, + } + scaleBGRA(src, dst) + + if dst.Width != 50 || dst.Height != 40 { + t.Errorf("dst dims = %dx%d, want 50x40", dst.Width, dst.Height) + } + // Check a few pixels are solid red. + for _, idx := range []int{0, 4, 100, 200} { + if dst.Pix[idx] != 0 || dst.Pix[idx+1] != 0 || dst.Pix[idx+2] != 255 { + t.Errorf("pixel %d not red: B=%d G=%d R=%d", idx, dst.Pix[idx], dst.Pix[idx+1], dst.Pix[idx+2]) + } + } +} + +// TestEngineScalePreservesNative verifies scaleFrame returns the original +// frame at scale 1.0. +func TestEngineScalePreservesNative(t *testing.T) { + cfg := DefaultConfig() + cfg.Source = "pattern" + cfg.Port = 19760 + eng, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + frame := &capture.VideoFrame{Pix: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16} + if got := eng.scaleFrame(frame, 1.0); got != frame { + t.Error("scale 1.0 should return the original frame") + } + if got := eng.scaleFrame(frame, 0.5); got == frame { + t.Error("scale 0.5 should return a new frame") + } +} + func itoa(v int) string { if v == 0 { return "0" diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 21cdab2..fd235a9 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -36,6 +36,34 @@ func itoa(v int) string { return strconv.Itoa(v) } func atoi(s string) (int, error) { return strconv.Atoi(s) } +// scaleLabel renders a scale factor as a percentage option. +func scaleLabel(scale float64) string { + switch { + case scale >= 0.95: + return "100%" + case scale >= 0.7: + return "75%" + case scale >= 0.45: + return "50%" + default: + return "25%" + } +} + +// parseScale converts a scale option label back to a factor. +func parseScale(label string) float64 { + switch label { + case "75%": + return 0.75 + case "50%": + return 0.5 + case "25%": + return 0.25 + default: + return 1.0 + } +} + // appID is the Fyne application ID used for preferences/settings storage. const appID = "io.teleportfling" @@ -62,6 +90,7 @@ type App struct { qualitySel *widget.Select fpsSel *widget.Select presetSel *widget.Select + scaleSel *widget.Select nameEnt *widget.Entry audioChk *widget.Check announceChk *widget.Check @@ -148,6 +177,10 @@ func (g *App) buildUI() { g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings) g.fpsSel.SetSelected(itoa(g.cfg.FPS)) + // Scale: downsampling factor (1.0 = native, 0.5 = half, etc). + g.scaleSel = widget.NewSelect([]string{"100%", "75%", "50%", "25%"}, g.applyLiveSettings) + g.scaleSel.SetSelected(scaleLabel(g.cfg.Scale)) + // Preset: one-click quality/fps combos. Choosing one sets the Quality and // Frame rate selectors and applies them (live if running). Created after // the quality/fps selects so applyPreset's references are valid. @@ -183,6 +216,7 @@ func (g *App) buildUI() { {Text: "Preset", Widget: g.presetSel}, {Text: "Quality", Widget: g.qualitySel}, {Text: "Frame rate", Widget: g.fpsSel}, + {Text: "Scale", Widget: g.scaleSel}, {Text: "", Widget: g.audioChk}, {Text: "", Widget: g.announceChk}, }, @@ -287,6 +321,7 @@ func (g *App) applyLiveSettings(string) { Source: g.cfg.Source, Audio: g.audioChk.Checked, StreamIndex: g.cfg.StreamIndex, + Scale: parseScale(g.scaleSel.Selected), Announce: announce, } if err := g.eng.SetConfig(cfg); err != nil { @@ -298,6 +333,7 @@ func (g *App) applyLiveSettings(string) { g.cfg.FPS = fps g.cfg.Audio = cfg.Audio g.cfg.Name = cfg.Name + g.cfg.Scale = cfg.Scale ann := announce g.cfg.Announce = &ann } @@ -329,6 +365,7 @@ func (g *App) start() { Source: g.srcSel.Selected, Audio: g.audioChk.Checked, StreamIndex: g.selectedMonitorIndex(), + Scale: parseScale(g.scaleSel.Selected), Announce: &announce, } if g.configPath != "" {