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.
This commit is contained in:
2026-09-19 20:33:40 +01:00
parent 583274d9e6
commit aab2006e78
4 changed files with 184 additions and 8 deletions
+79 -1
View File
@@ -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)
+55
View File
@@ -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"