diff --git a/.golangci.yml b/.golangci.yml index fed25c6..fbcd494 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/cmd/teleportfling/main.go b/cmd/teleportfling/main.go index 65041ce..500ebbd 100644 --- a/cmd/teleportfling/main.go +++ b/cmd/teleportfling/main.go @@ -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") } diff --git a/go.mod b/go.mod index 98393d8..2dcfadc 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 5a2aa76..50e5ab4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f7690e1 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5c146a3 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/flinger/flinger.go b/internal/flinger/flinger.go new file mode 100644 index 0000000..5346cfa --- /dev/null +++ b/internal/flinger/flinger.go @@ -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, 1–100. + 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 +} diff --git a/internal/flinger/flinger_test.go b/internal/flinger/flinger_test.go new file mode 100644 index 0000000..77a21cd --- /dev/null +++ b/internal/flinger/flinger_test.go @@ -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:]) +}