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:
+5
-1
@@ -27,7 +27,7 @@ linters:
|
|||||||
# Wire-format int conversions: the Teleport protocol defines 32-bit fields
|
# Wire-format int conversions: the Teleport protocol defines 32-bit fields
|
||||||
# on the wire (uint32) while the reference uses int32 struct fields. These
|
# on the wire (uint32) while the reference uses int32 struct fields. These
|
||||||
# casts are intentional and mirror obs-teleport's types.go.
|
# casts are intentional and mirror obs-teleport's types.go.
|
||||||
- path: "internal/protocol/"
|
- path: "(internal/protocol|internal/flinger)/"
|
||||||
linters:
|
linters:
|
||||||
- gosec
|
- gosec
|
||||||
text: "G115"
|
text: "G115"
|
||||||
@@ -42,6 +42,10 @@ linters:
|
|||||||
linters:
|
linters:
|
||||||
- gosec
|
- gosec
|
||||||
text: "G115"
|
text: "G115"
|
||||||
|
# Config path comes from os.UserConfigDir(); safe to read/write.
|
||||||
|
- path: "internal/config/"
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|||||||
+21
-361
@@ -5,6 +5,10 @@
|
|||||||
// protocol, announcing itself on the LAN multicast group so an OBS instance
|
// protocol, announcing itself on the LAN multicast group so an OBS instance
|
||||||
// with the obs-teleport plugin can discover and decode the stream.
|
// 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:
|
// Usage:
|
||||||
//
|
//
|
||||||
// teleportfling [--name NAME] [--port PORT] [--quality 1..100]
|
// teleportfling [--name NAME] [--port PORT] [--quality 1..100]
|
||||||
@@ -17,31 +21,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"teleportfling/internal/capture"
|
"teleportfling/internal/flinger"
|
||||||
"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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -57,95 +44,28 @@ func main() {
|
|||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Build the sender: TCP listener + multicast announcer.
|
cfg := flinger.Config{
|
||||||
sender := output.New()
|
Name: *name,
|
||||||
p, err := sender.Listen(addr(*port))
|
Port: *port,
|
||||||
|
Quality: *quality,
|
||||||
|
FPS: *fps,
|
||||||
|
Source: *source,
|
||||||
|
Audio: *withAudio,
|
||||||
|
StreamIndex: *streamIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
eng, err := flinger.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("output: listen: %v", err)
|
log.Fatalf("flinger: %v", err)
|
||||||
}
|
}
|
||||||
announcer := discovery.Start(*name, p)
|
|
||||||
|
|
||||||
var (
|
eng.Start()
|
||||||
totalFrames atomic.Int64
|
log.Printf("teleportfling: streaming from source %q", cfg.Source)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interrupt / SIGTERM handling.
|
// Interrupt / SIGTERM handling.
|
||||||
sigc := make(chan os.Signal, 1)
|
sigc := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
|
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 {
|
select {
|
||||||
case <-sigc:
|
case <-sigc:
|
||||||
log.Printf("teleportfling: stopping…")
|
log.Printf("teleportfling: stopping…")
|
||||||
@@ -160,266 +80,6 @@ func main() {
|
|||||||
log.Printf("teleportfling: duration reached")
|
log.Printf("teleportfling: duration reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
close(stop)
|
eng.Stop()
|
||||||
<-audioDone
|
log.Printf("teleportfling: stopped")
|
||||||
<-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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,44 @@ module teleportfling
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.8.1
|
||||||
github.com/schollz/peerdiscovery v1.7.6
|
github.com/schollz/peerdiscovery v1.7.6
|
||||||
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a
|
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/net v0.59.0 // indirect
|
||||||
golang.org/x/sys v0.48.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HJjU1cXcNGfZgenC/vbry9F6CH9B8f+QYcTipZLbtDg=
|
||||||
github.com/schollz/peerdiscovery v1.7.6/go.mod h1:iTa0MWSPy49jJ2HcXL5oSSnFsd6olEUorAFljxbnj2I=
|
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/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.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.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 h1:gfpAKu/sFOOG2TEAtYOcv2kMkTHjTtANtVH9rkGtuu0=
|
||||||
go2tv.app/screencast v0.0.0-20260807191623-4cba6136251a/go.mod h1:SuTkH1JeLAuYESPe0rmpmqmprr+Q2Z/Dfh2t4oHlCMM=
|
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=
|
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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
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/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=
|
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/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.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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user