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:
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Audio bool `json:"audio"`
|
Audio bool `json:"audio"`
|
||||||
StreamIndex int `json:"stream_index"`
|
StreamIndex int `json:"stream_index"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
// Announce is a *bool so an absent JSON key (older config files) keeps
|
// Announce is a *bool so an absent JSON key (older config files) keeps
|
||||||
// the default instead of silently disabling announcements.
|
// the default instead of silently disabling announcements.
|
||||||
Announce *bool `json:"announce"`
|
Announce *bool `json:"announce"`
|
||||||
@@ -40,6 +41,7 @@ func Default() Config {
|
|||||||
Source: c.Source,
|
Source: c.Source,
|
||||||
Audio: c.Audio,
|
Audio: c.Audio,
|
||||||
StreamIndex: c.StreamIndex,
|
StreamIndex: c.StreamIndex,
|
||||||
|
Scale: c.Scale,
|
||||||
Announce: &announce,
|
Announce: &announce,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,7 @@ func (c Config) ToFlinger() flinger.Config {
|
|||||||
Source: c.Source,
|
Source: c.Source,
|
||||||
Audio: c.Audio,
|
Audio: c.Audio,
|
||||||
StreamIndex: c.StreamIndex,
|
StreamIndex: c.StreamIndex,
|
||||||
|
Scale: c.Scale,
|
||||||
Announce: announce,
|
Announce: announce,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,9 @@ func LoadFrom(path string) (Config, error) {
|
|||||||
if c.Source == "" {
|
if c.Source == "" {
|
||||||
c.Source = d.Source
|
c.Source = d.Source
|
||||||
}
|
}
|
||||||
|
if c.Scale <= 0 || c.Scale > 1 {
|
||||||
|
c.Scale = d.Scale
|
||||||
|
}
|
||||||
if c.Announce == nil {
|
if c.Announce == nil {
|
||||||
announce := true
|
announce := true
|
||||||
c.Announce = &announce
|
c.Announce = &announce
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type Config struct {
|
|||||||
Audio bool
|
Audio bool
|
||||||
// StreamIndex selects which monitor to capture (screen source only).
|
// StreamIndex selects which monitor to capture (screen source only).
|
||||||
StreamIndex int
|
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.
|
// Announce controls whether the stream is advertised via UDP multicast.
|
||||||
// When disabled, receivers must connect by IP manually.
|
// When disabled, receivers must connect by IP manually.
|
||||||
Announce bool
|
Announce bool
|
||||||
@@ -63,6 +66,7 @@ func DefaultConfig() Config {
|
|||||||
FPS: 30,
|
FPS: 30,
|
||||||
Source: "screen",
|
Source: "screen",
|
||||||
Audio: true,
|
Audio: true,
|
||||||
|
Scale: 1.0,
|
||||||
Announce: true,
|
Announce: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,9 @@ func (c Config) Validate() error {
|
|||||||
if c.StreamIndex < 0 {
|
if c.StreamIndex < 0 {
|
||||||
return errors.New("stream index must be >= 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +124,10 @@ type Engine struct {
|
|||||||
|
|
||||||
frames atomic.Int64
|
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
|
errMu sync.RWMutex
|
||||||
lastErr error
|
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
|
// 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
|
// and JPEG quality are read from the live config so SetConfig takes effect
|
||||||
// without restarting.
|
// without restarting.
|
||||||
@@ -388,7 +465,8 @@ func (e *Engine) videoLoop() {
|
|||||||
next = now.Add(frameInterval)
|
next = now.Add(frameInterval)
|
||||||
|
|
||||||
ts := uint64(now.Sub(e.start))
|
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 {
|
if err != nil {
|
||||||
e.setErr(err)
|
e.setErr(err)
|
||||||
log.Printf("flinger: jpeg: %v", err)
|
log.Printf("flinger: jpeg: %v", err)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"teleportfling/internal/capture"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEnginePatternStartStop runs the engine with the synthetic pattern source
|
// 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 {
|
func itoa(v int) string {
|
||||||
if v == 0 {
|
if v == 0 {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ func itoa(v int) string { return strconv.Itoa(v) }
|
|||||||
|
|
||||||
func atoi(s string) (int, error) { return strconv.Atoi(s) }
|
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.
|
// appID is the Fyne application ID used for preferences/settings storage.
|
||||||
const appID = "io.teleportfling"
|
const appID = "io.teleportfling"
|
||||||
|
|
||||||
@@ -62,6 +90,7 @@ type App struct {
|
|||||||
qualitySel *widget.Select
|
qualitySel *widget.Select
|
||||||
fpsSel *widget.Select
|
fpsSel *widget.Select
|
||||||
presetSel *widget.Select
|
presetSel *widget.Select
|
||||||
|
scaleSel *widget.Select
|
||||||
nameEnt *widget.Entry
|
nameEnt *widget.Entry
|
||||||
audioChk *widget.Check
|
audioChk *widget.Check
|
||||||
announceChk *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 = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
|
||||||
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
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
|
// Preset: one-click quality/fps combos. Choosing one sets the Quality and
|
||||||
// Frame rate selectors and applies them (live if running). Created after
|
// Frame rate selectors and applies them (live if running). Created after
|
||||||
// the quality/fps selects so applyPreset's references are valid.
|
// the quality/fps selects so applyPreset's references are valid.
|
||||||
@@ -183,6 +216,7 @@ func (g *App) buildUI() {
|
|||||||
{Text: "Preset", Widget: g.presetSel},
|
{Text: "Preset", Widget: g.presetSel},
|
||||||
{Text: "Quality", Widget: g.qualitySel},
|
{Text: "Quality", Widget: g.qualitySel},
|
||||||
{Text: "Frame rate", Widget: g.fpsSel},
|
{Text: "Frame rate", Widget: g.fpsSel},
|
||||||
|
{Text: "Scale", Widget: g.scaleSel},
|
||||||
{Text: "", Widget: g.audioChk},
|
{Text: "", Widget: g.audioChk},
|
||||||
{Text: "", Widget: g.announceChk},
|
{Text: "", Widget: g.announceChk},
|
||||||
},
|
},
|
||||||
@@ -287,6 +321,7 @@ func (g *App) applyLiveSettings(string) {
|
|||||||
Source: g.cfg.Source,
|
Source: g.cfg.Source,
|
||||||
Audio: g.audioChk.Checked,
|
Audio: g.audioChk.Checked,
|
||||||
StreamIndex: g.cfg.StreamIndex,
|
StreamIndex: g.cfg.StreamIndex,
|
||||||
|
Scale: parseScale(g.scaleSel.Selected),
|
||||||
Announce: announce,
|
Announce: announce,
|
||||||
}
|
}
|
||||||
if err := g.eng.SetConfig(cfg); err != nil {
|
if err := g.eng.SetConfig(cfg); err != nil {
|
||||||
@@ -298,6 +333,7 @@ func (g *App) applyLiveSettings(string) {
|
|||||||
g.cfg.FPS = fps
|
g.cfg.FPS = fps
|
||||||
g.cfg.Audio = cfg.Audio
|
g.cfg.Audio = cfg.Audio
|
||||||
g.cfg.Name = cfg.Name
|
g.cfg.Name = cfg.Name
|
||||||
|
g.cfg.Scale = cfg.Scale
|
||||||
ann := announce
|
ann := announce
|
||||||
g.cfg.Announce = &ann
|
g.cfg.Announce = &ann
|
||||||
}
|
}
|
||||||
@@ -329,6 +365,7 @@ func (g *App) start() {
|
|||||||
Source: g.srcSel.Selected,
|
Source: g.srcSel.Selected,
|
||||||
Audio: g.audioChk.Checked,
|
Audio: g.audioChk.Checked,
|
||||||
StreamIndex: g.selectedMonitorIndex(),
|
StreamIndex: g.selectedMonitorIndex(),
|
||||||
|
Scale: parseScale(g.scaleSel.Selected),
|
||||||
Announce: &announce,
|
Announce: &announce,
|
||||||
}
|
}
|
||||||
if g.configPath != "" {
|
if g.configPath != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user