Files
TeleportFling/internal/protocol/jpeg.go
T
petere 10fa72b228 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.
2026-09-18 18:36:25 +01:00

175 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (1100).
//
// 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)
}