refactor: extract streaming engine into internal/flinger

Move the capture/encode/send pipeline out of cmd/teleportfling into a
GUI-free Engine so the CLI and the desktop app share one implementation:
- flinger: Config/Engine/Status, audio+video+stats loops, pattern source
- config: JSON persistence at ~/.config/teleportfling/config.json
- cmd/teleportfling: thin CLI wrapper around the engine (same flags)
- golangci: extend exclusions to flinger/config
This commit is contained in:
2026-09-18 20:32:45 +01:00
parent 79beeefdc7
commit ba423eb944
8 changed files with 836 additions and 368 deletions
+5 -1
View File
@@ -27,7 +27,7 @@ linters:
# Wire-format int conversions: the Teleport protocol defines 32-bit fields
# on the wire (uint32) while the reference uses int32 struct fields. These
# casts are intentional and mirror obs-teleport's types.go.
- path: "internal/protocol/"
- path: "(internal/protocol|internal/flinger)/"
linters:
- gosec
text: "G115"
@@ -42,6 +42,10 @@ linters:
linters:
- gosec
text: "G115"
# Config path comes from os.UserConfigDir(); safe to read/write.
- path: "internal/config/"
linters:
- gosec
formatters:
enable:
+21 -361
View File
@@ -5,6 +5,10 @@
// protocol, announcing itself on the LAN multicast group so an OBS instance
// with the obs-teleport plugin can discover and decode the stream.
//
// This is the headless/CLI entry point; the engine lives in
// internal/flinger. A desktop GUI with settings and a system tray is in
// cmd/teleportfling-gui.
//
// Usage:
//
// teleportfling [--name NAME] [--port PORT] [--quality 1..100]
@@ -17,31 +21,14 @@
package main
import (
"errors"
"flag"
"image"
"image/color"
"io"
"log"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
"teleportfling/internal/capture"
"teleportfling/internal/discovery"
"teleportfling/internal/output"
"teleportfling/internal/protocol"
)
const (
// sampleRate and speakers describe the captured/encoded audio stream.
sampleRate = 48000
speakers = 2
// audioChunk sets how much audio we packetize per WAVE message (~10 ms).
audioChunk = 10 * time.Millisecond
"teleportfling/internal/flinger"
)
func main() {
@@ -57,95 +44,28 @@ func main() {
)
flag.Parse()
// Build the sender: TCP listener + multicast announcer.
sender := output.New()
p, err := sender.Listen(addr(*port))
cfg := flinger.Config{
Name: *name,
Port: *port,
Quality: *quality,
FPS: *fps,
Source: *source,
Audio: *withAudio,
StreamIndex: *streamIndex,
}
eng, err := flinger.New(cfg)
if err != nil {
log.Fatalf("output: listen: %v", err)
log.Fatalf("flinger: %v", err)
}
announcer := discovery.Start(*name, p)
var (
totalFrames atomic.Int64
encoder = mustNewEncoder()
start = time.Now()
stop = make(chan struct{})
)
var (
cam capture.Capture
loop frameSource
)
switch *source {
case "screen":
cam, err = capture.OpenPipeWire(*streamIndex, *withAudio)
if err != nil {
log.Fatalf("capture: %v", err)
}
loop = captureLoop{cam}
log.Printf("teleportfling: advertising on %d, capturing screen via PipeWire", p)
case "pattern":
loop = &patternLoop{
w: 1920,
h: 1080,
fps: *fps,
}
log.Printf("teleportfling: advertising on %d, streaming test pattern", p)
default:
log.Fatalf("teleportfling: unknown source %q (want screen or pattern)", *source)
}
eng.Start()
log.Printf("teleportfling: streaming from source %q", cfg.Source)
// Interrupt / SIGTERM handling.
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
// — Audio loop —
audioDone := make(chan struct{})
go func() {
defer close(audioDone)
var src io.ReadCloser
switch {
case cam == nil:
// Pattern source: synthesize silence to keep the audio pipeline alive.
src = capture.NewSilenceSource()
case cam.Audio() != nil:
src = cam.Audio()
default:
log.Printf("teleportfling: system audio unavailable, streaming silence")
src = capture.NewSilenceSource()
}
defer func() { _ = src.Close() }()
audioLoop(sender, src, start, stop)
}()
// — Video loop —
videoDone := make(chan struct{})
go func() {
defer close(videoDone)
videoLoop(sender, encoder, loop, *fps, *quality, start, stop, &totalFrames)
}()
// — Stats ticker —
statsDone := make(chan struct{})
go func() {
defer close(statsDone)
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
log.Printf("stats: %d frames, %d conns",
totalFrames.Load(), sender.NumConns())
case <-stop:
return
}
}
}()
// — Wait for interrupt/duration —
select {
case <-sigc:
log.Printf("teleportfling: stopping…")
@@ -160,266 +80,6 @@ func main() {
log.Printf("teleportfling: duration reached")
}
close(stop)
<-audioDone
<-videoDone
<-statsDone
announcer.Stop()
sender.Close()
encoder.Close()
if cam != nil {
if err := cam.Close(); err != nil {
log.Printf("teleportfling: capture close: %v", err)
}
}
log.Printf("teleportfling: stopped after %s", time.Since(start).Round(time.Millisecond))
}
// audioLoop reads raw PCM from src and emits WAVE packets in audioChunk-sized
// pieces. PCM is assumed interleaved signed-16-bit at sampleRate/speakers.
//
// start is the shared reference clock used by the video loop: audio and video
// timestamps must share one time base, otherwise a constant skew between them
// makes OBS buffer one stream to re-sync the other, adding latency.
func audioLoop(sender *output.Sender, src io.Reader, start time.Time, stop <-chan struct{}) {
framesPerChunk := int(sampleRate) * int(audioChunk) / int(time.Second)
chunkBytes := framesPerChunk * speakers * 2 // S16
buf := make([]byte, chunkBytes)
for {
n, err := io.ReadFull(src, buf)
if n > 0 {
frames := n / (speakers * 2)
ts := uint64(time.Since(start))
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
if perr != nil {
log.Printf("teleportfling: wave: %v", perr)
} else {
sender.Send(packet)
}
}
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
log.Printf("teleportfling: audio: %v", err)
}
select {
case <-stop:
return
default:
}
}
}
}
// videoLoop pulls frames from loop and sends them at fps, encoding each to
// JPEG with the given quality.
func videoLoop(sender *output.Sender, encoder *protocol.JPEGEncoder, loop frameSource, fps, quality int, start time.Time, stop <-chan struct{}, total *atomic.Int64) {
frameInterval := time.Second / time.Duration(fps)
next := start
for {
frame, err := loop.Next()
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
log.Printf("teleportfling: capture: %v", err)
}
select {
case <-stop:
return
default:
}
continue
}
// Drop frames if we're running ahead of the target fps (e.g. a 60 Hz
// monitor captured at 30 fps) to keep timestamps monotonic.
now := time.Now()
if now.Before(next) {
continue
}
next = now.Add(frameInterval)
ts := uint64(now.Sub(start))
buf, err := encodeFrame(encoder, frame, quality)
if err != nil {
log.Printf("teleportfling: jpeg: %v", err)
continue
}
packet, err := protocol.WritePacket(
protocol.Header{Type: protocol.VideoType, Timestamp: ts, Size: int32(len(buf))},
ptr(protocol.DefaultBT709Full()),
nil,
buf,
)
if err != nil {
log.Printf("teleportfling: packet: %v", err)
continue
}
sender.Send(packet)
total.Add(1)
}
}
// encodeFrame compresses a captured frame based on its concrete type.
func encodeFrame(encoder *protocol.JPEGEncoder, frame *capture.VideoFrame, quality int) ([]byte, error) {
if frame.Pix != nil {
// BGRA from the PipeWire backend.
return encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, quality)
}
return nil, errors.New("capture: unsupported frame type")
}
// frameSource abstracts the frame source: real capture or the test pattern.
type frameSource interface {
Next() (*capture.VideoFrame, error)
}
// captureLoop wraps the PipeWire capture backend.
type captureLoop struct {
cam capture.Capture
}
func (c captureLoop) Next() (*capture.VideoFrame, error) {
return c.cam.Video().NextFrame()
}
// patternLoop synthesizes the M1 test pattern (colour bars + moving box).
type patternLoop struct {
w, h int
fps int
seq int64
}
func (p *patternLoop) Next() (*capture.VideoFrame, error) {
img := testPattern(p.w, p.h, int(p.seq))
p.seq++
return ycrcbToBGRA(img), nil
}
// ycrcbToBGRA converts a YCbCr image to a BGRA VideoFrame so both sources
// share the encode path (EncodeBGRA).
func ycrcbToBGRA(img *image.YCbCr) *capture.VideoFrame {
w, h := img.Rect.Dx(), img.Rect.Dy()
frame := &capture.VideoFrame{
Pix: make([]byte, w*h*4),
Width: w,
Height: h,
Stride: w * 4,
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
yi := y*img.YStride + x
ci := (y/2)*img.CStride + x/2
r, g, b := color.YCbCrToRGB(img.Y[yi], img.Cb[ci], img.Cr[ci])
off := (y*w + x) * 4
frame.Pix[off], frame.Pix[off+1], frame.Pix[off+2], frame.Pix[off+3] = b, g, r, 255
}
}
return frame
}
// mustNewEncoder creates a JPEG encoder or panics.
func mustNewEncoder() *protocol.JPEGEncoder {
enc, err := protocol.NewJPEGEncoder()
if err != nil {
log.Fatal(err)
}
return enc
}
// ptr returns a pointer to v, for passing headers to WritePacket.
func ptr[T any](v T) *T { return &v }
// addr formats a port as a listen address.
func addr(port int) string {
return ":" + strconv.Itoa(port)
}
// testPattern renders a standard SMPTE colour bar with a moving white box at
// the given frame index. The result is a *image.YCbCr 4:2:0 image so the
// encoder uses the YUV path — closest to what real PipeWire capture will
// produce.
func testPattern(w, h, frame int) *image.YCbCr {
img := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
// 7 vertical colour bars (grey, yellow, cyan, green, magenta, red, blue).
bars := []color.RGBA{
{R: 191, G: 191, B: 191}, // 75% grey
{R: 191, G: 191, B: 0}, // yellow
{R: 0, G: 191, B: 191}, // cyan
{R: 0, G: 191, B: 0}, // green
{R: 191, G: 0, B: 191}, // magenta
{R: 191, G: 0, B: 0}, // red
{R: 0, G: 0, B: 191}, // blue
}
const barCount = 7
barW := w / barCount
const boxSize = 80
// Moving white box sweeps left→right across the lower black block.
boxMinX := (frame*(w+boxSize)/120)%(w+boxSize) - boxSize/2
buf := make([]color.RGBA, w*h)
for by := 0; by < h; by++ {
rowIsBars := by < h*2/3
for bx := 0; bx < w; bx++ {
var c color.RGBA
switch {
case rowIsBars:
idx := bx / barW
if idx >= barCount {
idx = barCount - 1
}
c = bars[idx]
case by%8 < 4 && bx > w/3 && bx < w*2/3:
// Periodic white band across the lower black block for motion.
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
default:
c = color.RGBA{}
}
// Overlay the moving box on the bottom band.
if bx >= boxMinX && bx < boxMinX+boxSize && by >= h*2/3 {
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
}
buf[by*w+bx] = c
}
}
// Chroma planes: average each 2x2 RGB block, then convert to Cb/Cr.
for by := 0; by < h; by += 2 {
for bx := 0; bx < w; bx += 2 {
var rSum, gSum, bSum uint32
n := uint32(0)
for dy := 0; dy < 2; dy++ {
for dx := 0; dx < 2; dx++ {
xx, yy := bx+dx, by+dy
if xx >= w || yy >= h {
continue
}
px := buf[yy*w+xx]
rSum += uint32(px.R)
gSum += uint32(px.G)
bSum += uint32(px.B)
n++
}
}
_, cb, cr := color.RGBToYCbCr(uint8(rSum/n), uint8(gSum/n), uint8(bSum/n))
img.Cb[(by/2)*img.CStride+bx/2] = cb
img.Cr[(by/2)*img.CStride+bx/2] = cr
}
}
// Luma plane: Y = YCbCr luma of every pixel.
for by := 0; by < h; by++ {
for bx := 0; bx < w; bx++ {
px := buf[by*w+bx]
y, _, _ := color.RGBToYCbCr(px.R, px.G, px.B)
img.Y[by*img.YStride+bx] = y
}
}
return img
eng.Stop()
log.Printf("teleportfling: stopped")
}
+33 -1
View File
@@ -3,12 +3,44 @@ module teleportfling
go 1.26.0
require (
fyne.io/fyne/v2 v2.8.1
github.com/schollz/peerdiscovery v1.7.6
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a
)
require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+72 -5
View File
@@ -1,16 +1,76 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
fyne.io/fyne/v2 v2.8.1 h1:EztGuE2W3Qhd0cWVmU+h5rkzNezUD1To6UqsoLQYUIM=
fyne.io/fyne/v2 v2.8.1/go.mod h1:kpeuFrClm0fiAgJYr2soTfwKMT5rzNcSKzmgGjxvHOY=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/schollz/peerdiscovery v1.7.6 h1:HJjU1cXcNGfZgenC/vbry9F6CH9B8f+QYcTipZLbtDg=
github.com/schollz/peerdiscovery v1.7.6/go.mod h1:iTa0MWSPy49jJ2HcXL5oSSnFsd6olEUorAFljxbnj2I=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a h1:gfpAKu/sFOOG2TEAtYOcv2kMkTHjTtANtVH9rkGtuu0=
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a/go.mod h1:SuTkH1JeLAuYESPe0rmpmqmprr+Q2Z/Dfh2t4oHlCMM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -19,6 +79,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -73,6 +135,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -81,5 +145,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+113
View File
@@ -0,0 +1,113 @@
// Package config loads and saves the teleportfling settings file.
//
// The file is JSON at ~/.config/teleportfling/config.json and holds the
// user-visible knobs exposed by the GUI (name, port, quality, fps, source,
// audio, monitor). Secrets and runtime state are deliberately excluded.
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"teleportfling/internal/flinger"
)
// 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"`
}
// Default returns the default configuration.
func Default() Config {
c := flinger.DefaultConfig()
return Config{
Name: c.Name,
Port: c.Port,
Quality: c.Quality,
FPS: c.FPS,
Source: c.Source,
Audio: c.Audio,
StreamIndex: c.StreamIndex,
}
}
// ToFlinger converts a persisted config to the engine config.
func (c Config) ToFlinger() flinger.Config {
return flinger.Config{
Name: c.Name,
Port: c.Port,
Quality: c.Quality,
FPS: c.FPS,
Source: c.Source,
Audio: c.Audio,
StreamIndex: c.StreamIndex,
}
}
// pathVar is the config file location; overridable in tests.
var pathVar = func() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "teleportfling", "config.json")
}()
// Path returns the config file location.
func Path() string {
return pathVar
}
// Load reads the config file, returning Default when it does not exist.
func Load() (Config, error) {
p := Path()
data, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Default(), nil
}
return Config{}, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return Config{}, err
}
// Fill any zero values with defaults so a hand-edited file still works.
d := Default()
if c.Port == 0 {
c.Port = d.Port
}
if c.Quality == 0 {
c.Quality = d.Quality
}
if c.FPS == 0 {
c.FPS = d.FPS
}
if c.Source == "" {
c.Source = d.Source
}
return c, nil
}
// Save writes the config file, creating the directory if needed.
func Save(c Config) error {
p := Path()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o600)
}
+77
View File
@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestLoadDefaultsWhenMissing verifies Load returns defaults when the file
// does not exist.
func TestLoadDefaultsWhenMissing(t *testing.T) {
// Point at a non-existent directory so we never touch the real config.
old := pathVar
pathVar = filepath.Join(t.TempDir(), "nope", "config.json")
defer func() { pathVar = old }()
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Port != 9756 {
t.Errorf("default port = %d, want 9756", c.Port)
}
}
// TestSaveLoadRoundTrip writes a config and reads it back.
func TestSaveLoadRoundTrip(t *testing.T) {
old := pathVar
pathVar = filepath.Join(t.TempDir(), "config.json")
defer func() { pathVar = old }()
want := Config{
Name: "Studio",
Port: 9898,
Quality: 95,
FPS: 60,
Source: "pattern",
Audio: false,
StreamIndex: 1,
}
if err := Save(want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if got != want {
t.Errorf("round trip mismatch:\n got %+v\nwant %+v", got, want)
}
}
// TestLoadFillsZeroValues ensures a partial file gets defaults filled.
func TestLoadFillsZeroValues(t *testing.T) {
old := pathVar
pathVar = filepath.Join(t.TempDir(), "config.json")
defer func() { pathVar = old }()
if err := os.WriteFile(pathVar, []byte(`{"name":"Partial"}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Name != "Partial" {
t.Errorf("name = %q, want Partial", c.Name)
}
if c.Port != 9756 {
t.Errorf("port = %d, want filled default 9756", c.Port)
}
if c.Quality != 80 {
t.Errorf("quality = %d, want filled default 80", c.Quality)
}
}
+423
View File
@@ -0,0 +1,423 @@
// Package flinger implements the TeleportFling streaming engine: it captures
// a screen (and optionally system audio), encodes video to JPEG, packetizes
// both into the Teleport protocol, and broadcasts them to connected OBS
// receivers.
//
// The engine is deliberately GUI-free so it can run headless (CLI/daemon) or
// be driven by a desktop app. Callers create an Engine with a Config, call
// Start, poll Status, and call Stop when done.
package flinger
import (
"errors"
"image"
"image/color"
"io"
"log"
"strconv"
"sync/atomic"
"time"
"teleportfling/internal/capture"
"teleportfling/internal/discovery"
"teleportfling/internal/output"
"teleportfling/internal/protocol"
)
const (
// sampleRate and speakers describe the captured/encoded audio stream.
sampleRate = 48000
speakers = 2
// audioChunk sets how much audio we packetize per WAVE message (~10 ms).
audioChunk = 10 * time.Millisecond
)
// Config configures the streaming engine.
type Config struct {
// Name is the announce name advertised to receivers (empty → hostname).
Name string
// Port is the TCP listening port for receivers.
Port int
// Quality is the JPEG quality, 1100.
Quality int
// FPS is the target video frame rate.
FPS int
// Source is "screen" (PipeWire) or "pattern" (synthetic test signal).
Source string
// Audio enables system audio capture and streaming.
Audio bool
// StreamIndex selects which monitor to capture (screen source only).
StreamIndex int
}
// DefaultConfig returns the recommended defaults.
func DefaultConfig() Config {
return Config{
Port: 9756,
Quality: 80,
FPS: 30,
Source: "screen",
Audio: true,
}
}
// Status is a point-in-time snapshot of the running engine.
type Status struct {
Running bool
Frames int64
Conns int
}
// Engine owns the capture, encode and send pipeline.
type Engine struct {
cfg Config
sender *output.Sender
announcer *discovery.Announcer
encoder *protocol.JPEGEncoder
cam capture.Capture
loop frameSource
start time.Time
stop chan struct{}
frames atomic.Int64
}
// New creates an engine from cfg. Capture is opened eagerly so that
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
func New(cfg Config) (*Engine, error) {
e := &Engine{cfg: cfg}
sender := output.New()
if _, err := sender.Listen(addr(cfg.Port)); err != nil {
return nil, err
}
enc, err := protocol.NewJPEGEncoder()
if err != nil {
sender.Close()
return nil, err
}
e.sender = sender
e.encoder = enc
switch cfg.Source {
case "screen":
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio)
if err != nil {
e.encoder.Close()
sender.Close()
return nil, err
}
e.cam = cam
e.loop = captureLoop{cam}
case "pattern":
e.loop = &patternLoop{w: 1920, h: 1080}
default:
e.encoder.Close()
sender.Close()
return nil, errors.New("unknown source " + cfg.Source)
}
return e, nil
}
// Start begins the audio, video and stats loops and starts announcing the
// stream. It is idempotent.
func (e *Engine) Start() {
if e.stop != nil {
return
}
e.start = time.Now()
e.stop = make(chan struct{})
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
var src io.ReadCloser
switch {
case e.cam == nil:
src = capture.NewSilenceSource()
case e.cam.Audio() != nil:
src = e.cam.Audio()
default:
log.Printf("flinger: system audio unavailable, streaming silence")
src = capture.NewSilenceSource()
}
go e.audioLoop(src)
go e.videoLoop()
go e.statsLoop()
}
// Stop halts the loops, stops announcing and closes all resources. It is
// idempotent. After Stop the engine must not be restarted.
func (e *Engine) Stop() {
if e.stop == nil {
return
}
close(e.stop)
// Give the loops a moment to observe the stop signal.
time.Sleep(50 * time.Millisecond)
if e.announcer != nil {
e.announcer.Stop()
}
e.sender.Close()
e.encoder.Close()
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
}
// Status returns a snapshot of the running engine.
func (e *Engine) Status() Status {
return Status{
Running: e.stop != nil,
Frames: e.frames.Load(),
Conns: e.sender.NumConns(),
}
}
// audioLoop reads raw PCM and emits WAVE packets. start is the shared
// reference clock used by the video loop so audio and video timestamps stay
// aligned on the receiver.
func (e *Engine) audioLoop(src io.ReadCloser) {
defer func() { _ = src.Close() }()
framesPerChunk := int(sampleRate) * int(audioChunk) / int(time.Second)
chunkBytes := framesPerChunk * speakers * 2 // S16
buf := make([]byte, chunkBytes)
for {
n, err := io.ReadFull(src, buf)
if n > 0 {
frames := n / (speakers * 2)
ts := uint64(time.Since(e.start))
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
if perr != nil {
log.Printf("flinger: wave: %v", perr)
} else {
e.sender.Send(packet)
}
}
if err != nil {
select {
case <-e.stop:
return
default:
}
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
log.Printf("flinger: audio: %v", err)
}
}
}
}
// videoLoop pulls frames and sends them at the configured fps.
func (e *Engine) videoLoop() {
frameInterval := time.Second / time.Duration(e.cfg.FPS)
next := e.start
for {
frame, err := e.loop.Next()
if err != nil {
select {
case <-e.stop:
return
default:
}
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
log.Printf("flinger: capture: %v", err)
}
continue
}
// Drop frames when running ahead of the target fps to keep
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
now := time.Now()
if now.Before(next) {
continue
}
next = now.Add(frameInterval)
ts := uint64(now.Sub(e.start))
buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality)
if err != nil {
log.Printf("flinger: jpeg: %v", err)
continue
}
packet, err := protocol.WritePacket(
protocol.Header{Type: protocol.VideoType, Timestamp: ts, Size: int32(len(buf))},
ptr(protocol.DefaultBT709Full()),
nil,
buf,
)
if err != nil {
log.Printf("flinger: packet: %v", err)
continue
}
e.sender.Send(packet)
e.frames.Add(1)
}
}
// statsLoop logs a periodic summary.
func (e *Engine) statsLoop() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
st := e.Status()
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
case <-e.stop:
return
}
}
}
// frameSource abstracts the frame source: real capture or the test pattern.
type frameSource interface {
Next() (*capture.VideoFrame, error)
}
// captureLoop wraps the PipeWire capture backend.
type captureLoop struct {
cam capture.Capture
}
func (c captureLoop) Next() (*capture.VideoFrame, error) {
return c.cam.Video().NextFrame()
}
// patternLoop synthesizes the M1 test pattern (colour bars + moving box).
type patternLoop struct {
w, h int
seq int64
}
func (p *patternLoop) Next() (*capture.VideoFrame, error) {
img := testPattern(p.w, p.h, int(p.seq))
p.seq++
return ycrcbToBGRA(img), nil
}
// ycrcbToBGRA converts a YCbCr image to a BGRA VideoFrame so both sources
// share the encode path (EncodeBGRA).
func ycrcbToBGRA(img *image.YCbCr) *capture.VideoFrame {
w, h := img.Rect.Dx(), img.Rect.Dy()
frame := &capture.VideoFrame{
Pix: make([]byte, w*h*4),
Width: w,
Height: h,
Stride: w * 4,
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
yi := y*img.YStride + x
ci := (y/2)*img.CStride + x/2
r, g, b := color.YCbCrToRGB(img.Y[yi], img.Cb[ci], img.Cr[ci])
off := (y*w + x) * 4
frame.Pix[off], frame.Pix[off+1], frame.Pix[off+2], frame.Pix[off+3] = b, g, r, 255
}
}
return frame
}
// ptr returns a pointer to v, for passing headers to WritePacket.
func ptr[T any](v T) *T { return &v }
// addr formats a port as a listen address.
func addr(port int) string {
return ":" + strconv.Itoa(port)
}
// testPattern renders a standard SMPTE colour bar with a moving white box at
// the given frame index.
func testPattern(w, h, frame int) *image.YCbCr {
img := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
// 7 vertical colour bars (grey, yellow, cyan, green, magenta, red, blue).
bars := []color.RGBA{
{R: 191, G: 191, B: 191}, // 75% grey
{R: 191, G: 191, B: 0}, // yellow
{R: 0, G: 191, B: 191}, // cyan
{R: 0, G: 191, B: 0}, // green
{R: 191, G: 0, B: 191}, // magenta
{R: 191, G: 0, B: 0}, // red
{R: 0, G: 0, B: 191}, // blue
}
const barCount = 7
barW := w / barCount
const boxSize = 80
// Moving white box sweeps left→right across the lower black block.
boxMinX := (frame*(w+boxSize)/120)%(w+boxSize) - boxSize/2
buf := make([]color.RGBA, w*h)
for by := 0; by < h; by++ {
rowIsBars := by < h*2/3
for bx := 0; bx < w; bx++ {
var c color.RGBA
switch {
case rowIsBars:
idx := bx / barW
if idx >= barCount {
idx = barCount - 1
}
c = bars[idx]
case by%8 < 4 && bx > w/3 && bx < w*2/3:
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
default:
c = color.RGBA{}
}
if bx >= boxMinX && bx < boxMinX+boxSize && by >= h*2/3 {
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
}
buf[by*w+bx] = c
}
}
// Chroma planes: average each 2x2 RGB block, then convert to Cb/Cr.
for by := 0; by < h; by += 2 {
for bx := 0; bx < w; bx += 2 {
var rSum, gSum, bSum uint32
n := uint32(0)
for dy := 0; dy < 2; dy++ {
for dx := 0; dx < 2; dx++ {
xx, yy := bx+dx, by+dy
if xx >= w || yy >= h {
continue
}
px := buf[yy*w+xx]
rSum += uint32(px.R)
gSum += uint32(px.G)
bSum += uint32(px.B)
n++
}
}
_, cb, cr := color.RGBToYCbCr(uint8(rSum/n), uint8(gSum/n), uint8(bSum/n))
img.Cb[(by/2)*img.CStride+bx/2] = cb
img.Cr[(by/2)*img.CStride+bx/2] = cr
}
}
// Luma plane: Y = YCbCr luma of every pixel.
for by := 0; by < h; by++ {
for bx := 0; bx < w; bx++ {
px := buf[by*w+bx]
y, _, _ := color.RGBToYCbCr(px.R, px.G, px.B)
img.Y[by*img.YStride+bx] = y
}
}
return img
}
+92
View File
@@ -0,0 +1,92 @@
package flinger
import (
"net"
"testing"
"time"
)
// TestEnginePatternStartStop runs the engine with the synthetic pattern source
// and verifies it produces frames on the wire and stops cleanly.
func TestEnginePatternStartStop(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 0 // ephemeral
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
eng.Start()
defer eng.Stop()
// Connect a raw receiver and read until the engine reports frames sent.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
// The video loop sends ~30fps; wait for the counter to advance.
deadline := time.Now().Add(3 * time.Second)
_ = conn.SetReadDeadline(deadline)
// Drain whatever arrives while waiting for the frame counter to move.
go func() {
buf := make([]byte, 64*1024)
for {
if _, err := conn.Read(buf); err != nil {
return
}
}
}()
for time.Now().Before(deadline) {
if eng.Status().Frames > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if eng.Status().Frames == 0 {
t.Error("expected frames to be counted")
}
}
// TestEngineConfigDefaults verifies DefaultConfig is sane.
func TestEngineConfigDefaults(t *testing.T) {
c := DefaultConfig()
if c.Port != 9756 {
t.Errorf("default port = %d, want 9756", c.Port)
}
if c.Source != "screen" {
t.Errorf("default source = %q, want screen", c.Source)
}
if c.Quality < 1 || c.Quality > 100 {
t.Errorf("default quality %d out of range", c.Quality)
}
}
// TestNewRejectsBadSource ensures invalid sources fail fast.
func TestNewRejectsBadSource(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "bogus"
if _, err := New(cfg); err == nil {
t.Error("expected error for unknown source")
}
}
func itoa(v int) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}