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:
@@ -0,0 +1,174 @@
|
||||
package protocol
|
||||
|
||||
/*
|
||||
#cgo pkg-config: libturbojpeg
|
||||
#include <turbojpeg.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static tjhandle new_compressor(void) {
|
||||
return tj3Init(TJINIT_COMPRESS);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// JPEGEncoder wraps a TurboJPEG tj3 compressor. Not safe for concurrent
|
||||
// use; callers must serialise or use one per goroutine.
|
||||
type JPEGEncoder struct {
|
||||
ctx C.tjhandle
|
||||
}
|
||||
|
||||
// NewJPEGEncoder initialises a TJ3 compressor. Must be freed via Close().
|
||||
func NewJPEGEncoder() (*JPEGEncoder, error) {
|
||||
ctx := C.new_compressor()
|
||||
if ctx == nil {
|
||||
return nil, errors.New("turbojpeg: tj3Init failed")
|
||||
}
|
||||
// Allow turbojpeg to allocate the output buffer itself (no NOREALLOC).
|
||||
// This avoids the need to pin a Go output buffer and simplifies the
|
||||
// API: the caller receives a Go-owned copy and the C buffer is freed.
|
||||
return &JPEGEncoder{ctx: ctx}, nil
|
||||
}
|
||||
|
||||
// Close destroys the underlying compressor.
|
||||
func (e *JPEGEncoder) Close() {
|
||||
if e.ctx != nil {
|
||||
C.tj3Destroy(e.ctx)
|
||||
e.ctx = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Encode compresses img to JPEG at the given quality (1–100).
|
||||
//
|
||||
// Supported source types:
|
||||
// - *image.YCbCr – compressed via the YUV path (420/422/444, matching
|
||||
// obs-teleport exactly). SubsampleRatio is honoured.
|
||||
// - *image.RGBA – compressed as RGB via TJPF_RGBA, 444 subsampling.
|
||||
// - any other image.Image – converted to *image.RGBA then encoded as above.
|
||||
//
|
||||
// The returned byte slice is owned by the caller and must not be reused
|
||||
// after the encoder is closed.
|
||||
func (e *JPEGEncoder) Encode(img image.Image, quality int) ([]byte, error) {
|
||||
if quality < 1 {
|
||||
quality = 1
|
||||
}
|
||||
if quality > 100 {
|
||||
quality = 100
|
||||
}
|
||||
|
||||
C.tj3Set(e.ctx, C.TJPARAM_QUALITY, C.int(quality))
|
||||
|
||||
switch src := img.(type) {
|
||||
case *image.YCbCr:
|
||||
return e.encodeYCbCr(src)
|
||||
case *image.RGBA:
|
||||
return e.encodeRGBA(src)
|
||||
default:
|
||||
return e.encodeGeneric(img)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeRGBA compresses a Go RGBA image (pixel layout [R,G,B,A] per 4 bytes).
|
||||
// TJPF_RGBA tells turbojpeg the exact layout; colourspace is RGB.
|
||||
func (e *JPEGEncoder) encodeRGBA(img *image.RGBA) ([]byte, error) {
|
||||
w := img.Rect.Dx()
|
||||
h := img.Rect.Dy()
|
||||
|
||||
subsamp := C.int(C.TJSAMP_444)
|
||||
C.tj3Set(e.ctx, C.TJPARAM_SUBSAMP, subsamp)
|
||||
C.tj3Set(e.ctx, C.TJPARAM_COLORSPACE, C.TJCS_RGB)
|
||||
|
||||
size := C.tj3JPEGBufSize(C.int(w), C.int(h), subsamp)
|
||||
buf := make([]byte, int(size))
|
||||
srcPtr := unsafe.Pointer(&img.Pix[0])
|
||||
dstPtr := (*C.uchar)(&buf[0])
|
||||
|
||||
var pin runtime.Pinner
|
||||
pin.Pin(srcPtr)
|
||||
pin.Pin(dstPtr)
|
||||
defer pin.Unpin()
|
||||
|
||||
jpegSize := size
|
||||
rc := C.tj3Compress8(e.ctx, (*C.uchar)(srcPtr), C.int(w), 0, C.int(h), C.TJPF_RGBA, &dstPtr, &jpegSize)
|
||||
if rc != 0 {
|
||||
return nil, errors.New("turbojpeg RGBA compress failed")
|
||||
}
|
||||
return buf[:int(jpegSize)], nil
|
||||
}
|
||||
|
||||
// encodeYCbCr compresses a YCbCr image via the turbojpeg YUV compressor.
|
||||
// SubsampleRatio selects the chroma subsampling: 420, 422, or 444.
|
||||
//
|
||||
// turbojpeg's tj3CompressFromYUV8 expects the Y, Cb and Cr planes packed
|
||||
// contiguously in a single buffer (Y, then Cb, then Cr). Go's image.YCbCr
|
||||
// keeps them in three independent slices, so we copy them into a packed
|
||||
// scratch buffer first — matching obs-teleport's ToJPEG behaviour.
|
||||
func (e *JPEGEncoder) encodeYCbCr(img *image.YCbCr) ([]byte, error) {
|
||||
w := img.Rect.Dx()
|
||||
h := img.Rect.Dy()
|
||||
|
||||
var subsamp C.int
|
||||
switch img.SubsampleRatio {
|
||||
case image.YCbCrSubsampleRatio420:
|
||||
subsamp = C.TJSAMP_420
|
||||
case image.YCbCrSubsampleRatio422:
|
||||
subsamp = C.TJSAMP_422
|
||||
case image.YCbCrSubsampleRatio444:
|
||||
subsamp = C.TJSAMP_444
|
||||
default:
|
||||
// Fall back to 444; this matches obs-teleport's default for non-standard ratios.
|
||||
subsamp = C.TJSAMP_444
|
||||
}
|
||||
|
||||
C.tj3Set(e.ctx, C.TJPARAM_SUBSAMP, subsamp)
|
||||
C.tj3Set(e.ctx, C.TJPARAM_COLORSPACE, C.TJCS_YCbCr)
|
||||
|
||||
size := C.tj3JPEGBufSize(C.int(w), C.int(h), subsamp)
|
||||
buf := make([]byte, int(size))
|
||||
|
||||
// Pack the planes contiguously for the compressor.
|
||||
yuv := make([]byte, 0, len(img.Y)+len(img.Cb)+len(img.Cr))
|
||||
yuv = append(yuv, img.Y...)
|
||||
yuv = append(yuv, img.Cb...)
|
||||
yuv = append(yuv, img.Cr...)
|
||||
|
||||
srcPtr := unsafe.Pointer(&yuv[0])
|
||||
dstPtr := (*C.uchar)(&buf[0])
|
||||
|
||||
var pin runtime.Pinner
|
||||
pin.Pin(srcPtr)
|
||||
pin.Pin(dstPtr)
|
||||
defer pin.Unpin()
|
||||
|
||||
jpegSize := size
|
||||
rc := C.tj3CompressFromYUV8(e.ctx, (*C.uchar)(srcPtr), C.int(w), 1, C.int(h), &dstPtr, &jpegSize)
|
||||
if rc != 0 {
|
||||
return nil, errors.New("turbojpeg YUV compress failed")
|
||||
}
|
||||
return buf[:int(jpegSize)], nil
|
||||
}
|
||||
|
||||
// encodeGeneric is the fallback for unsupported image types: draw into
|
||||
// RGBA and encode via the RGBA path.
|
||||
func (e *JPEGEncoder) encodeGeneric(img image.Image) ([]byte, error) {
|
||||
b := img.Bounds()
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, b2, a := img.At(x, y).RGBA()
|
||||
rgba.SetRGBA(x-b.Min.X, y-b.Min.Y, color.RGBA{
|
||||
R: uint8(r >> 8),
|
||||
G: uint8(g >> 8),
|
||||
B: uint8(b2 >> 8),
|
||||
A: uint8(a >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
return e.encodeRGBA(rgba)
|
||||
}
|
||||
Reference in New Issue
Block a user