feat: implement M1 teleport protocol sender

Stream a synthetic test pattern and silent PCM audio over the OBS
Teleport protocol:
- protocol: wire format (Header/ImageHeader/WaveHeader), BT.709 full
  range colour matrix, JPEG encode via turbojpeg cgo, WAVE packet builder
- output: TCP sender with per-connection buffered channels and drop-on-overflow
- discovery: multicast announce via peerdiscovery
- cmd: teleportfling CLI with flags, test-pattern frame generator

Verified end-to-end: OBS discovers and renders the stream with correct
colours and motion.
This commit is contained in:
2026-09-18 18:36:25 +01:00
parent b8b2ba8da1
commit 10fa72b228
11 changed files with 1438 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
package protocol
import (
"bytes"
"image"
"image/color"
"testing"
)
// minimalRGB builds a tiny RGBA test image with a known gradient.
func minimalRGB(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, color.RGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
}
}
return img
}
// minimalYCbCr builds a 420 subsampled image with a vertical colour split.
func minimalYCbCr(w, h int) *image.YCbCr {
img := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b := uint8(0), uint8(0), uint8(0)
switch {
case x < w/3:
r = 200
case x < w*2/3:
g = 200
default:
b = 200
}
img.Y[y*img.YStride+x], _, _ = color.RGBToYCbCr(r, g, b)
}
}
// Subsample chroma from the known dominant colours per region.
for y := 0; y < h/2; y++ {
for x := 0; x < w/2; x++ {
idx := (y*img.CStride + x)
switch {
case x < w/6:
_, cb, cr := color.RGBToYCbCr(200, 0, 0)
img.Cb[idx], img.Cr[idx] = cb, cr
case x < w*2/6:
_, cb, cr := color.RGBToYCbCr(0, 200, 0)
img.Cb[idx], img.Cr[idx] = cb, cr
default:
_, cb, cr := color.RGBToYCbCr(0, 0, 200)
img.Cb[idx], img.Cr[idx] = cb, cr
}
}
}
return img
}
// TestJPEGEncodeRGBA checks the RGBA path produces a decodable JPEG.
func TestJPEGEncodeRGBA(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalRGB(64, 48)
buf, err := enc.Encode(img, 80)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(buf) == 0 {
t.Fatal("empty JPEG output")
}
if !bytes.Equal(buf[:2], []byte{0xFF, 0xD8}) {
t.Errorf("bad JPEG SOI marker: %x", buf[:2])
}
if !bytes.Equal(buf[len(buf)-2:], []byte{0xFF, 0xD9}) {
t.Errorf("bad JPEG EOI marker")
}
}
// TestJPEGEncodeYCbCr checks the YUV path for all three subsampling modes.
func TestJPEGEncodeYCbCr(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalYCbCr(64, 48)
if _, err := enc.Encode(img, 80); err != nil {
t.Fatalf("Encode (420): %v", err)
}
img422 := minimalYCbCr(64, 48)
img422.SubsampleRatio = image.YCbCrSubsampleRatio422
if _, err := enc.Encode(img422, 80); err != nil {
t.Fatalf("Encode (422): %v", err)
}
img444 := minimalYCbCr(64, 48)
img444.SubsampleRatio = image.YCbCrSubsampleRatio444
if _, err := enc.Encode(img444, 80); err != nil {
t.Fatalf("Encode (444): %v", err)
}
}
// TestJPEGEncodeQualityClamp ensures out-of-range qualities are clamped.
func TestJPEGEncodeQualityClamp(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalRGB(16, 16)
for _, q := range []int{-5, 0, 101, 200} {
if buf, err := enc.Encode(img, q); err != nil || len(buf) == 0 {
t.Errorf("quality %d: err=%v len=%d", q, err, len(buf))
}
}
}