feat: add tile as videochannel visual codec via -video-codec tile

This commit is contained in:
zarazaex69
2026-04-30 06:55:34 +03:00
parent 01494504e2
commit ad7c9a5c3e
13 changed files with 155 additions and 28 deletions

View File

@@ -38,9 +38,11 @@ type config struct {
videoHW string
videoQRSize int
videoQRRecovery string
videoCodec string
vp8FPS int
vp8BatchSize int
videoCodec string
videoTileModule int
videoTileRS int
vp8FPS int
vp8BatchSize int
}
func main() {
@@ -118,7 +120,9 @@ func parseFlags() config {
flag.StringVar(&cfg.videoHW, "video-hw", "", "Hardware acceleration (none, nvenc)")
flag.IntVar(&cfg.videoQRSize, "video-qr-size", 0, "Video QR code fragment size (videochannel only)")
flag.StringVar(&cfg.videoQRRecovery, "video-qr-recovery", "low", "QR error correction: low (7%), medium (15%), high (25%), highest (30%)")
flag.StringVar(&cfg.videoCodec, "video-codec", "qrcode", "Visual codec: qrcode (slow, stable) or b (fast, unstable)")
flag.StringVar(&cfg.videoCodec, "video-codec", "qrcode", "Visual codec: qrcode or tile")
flag.IntVar(&cfg.videoTileModule, "video-tile-module", 0, "Tile module size in pixels 1..270 (videochannel tile only, default 4)")
flag.IntVar(&cfg.videoTileRS, "video-tile-rs", 0, "Tile Reed-Solomon parity percent 0..200 (videochannel tile only, default 20)")
flag.IntVar(&cfg.vp8FPS, "vp8-fps", 0, "VP8 frames per second (vp8channel only, default 25)")
flag.IntVar(&cfg.vp8BatchSize, "vp8-batch", 0, "VP8 frames per tick (vp8channel only, default 1)")
flag.Parse()
@@ -175,9 +179,11 @@ func toSessionConfig(cfg config) session.Config {
VideoHW: cfg.videoHW,
VideoQRSize: cfg.videoQRSize,
VideoQRRecovery: cfg.videoQRRecovery,
VideoCodec: cfg.videoCodec,
VP8FPS: cfg.vp8FPS,
VP8BatchSize: cfg.vp8BatchSize,
VideoCodec: cfg.videoCodec,
VideoTileModule: cfg.videoTileModule,
VideoTileRS: cfg.videoTileRS,
VP8FPS: cfg.vp8FPS,
VP8BatchSize: cfg.vp8BatchSize,
}
}

1
go.mod
View File

@@ -36,6 +36,7 @@ require (
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect

2
go.sum
View File

@@ -87,6 +87,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View File

@@ -48,7 +48,7 @@ var (
ErrVideoFPSRequired = errors.New("video fps required for videochannel (use -video-fps)")
ErrVideoBitrateRequired = errors.New("video bitrate required for videochannel (use -video-bitrate)")
ErrVideoHWRequired = errors.New("video hardware acceleration required for videochannel (use -video-hw none/nvenc)")
ErrVideoCodecInvalid = errors.New("invalid video codec for videochannel (use -video-codec qrcode)")
ErrVideoCodecInvalid = errors.New("invalid video codec for videochannel (use -video-codec qrcode or -video-codec tile)")
// VP8channel errors
ErrVP8FPSRequired = errors.New("vp8 fps required for vp8channel (use -vp8-fps)")
@@ -79,9 +79,11 @@ type Config struct {
VideoHW string
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VP8FPS int
VP8BatchSize int
VideoCodec string
VideoTileModule int
VideoTileRS int
VP8FPS int
VP8BatchSize int
}
// RegisterDefaults registers built-in providers and transports.
@@ -179,7 +181,7 @@ func Validate(cfg Config) error {
if cfg.VideoHW == "" {
return ErrVideoHWRequired
}
if cfg.VideoCodec != "" && cfg.VideoCodec != "qrcode" {
if cfg.VideoCodec != "" && cfg.VideoCodec != "qrcode" && cfg.VideoCodec != "tile" {
return ErrVideoCodecInvalid
}
}
@@ -229,6 +231,8 @@ func Run(ctx context.Context, cfg Config) error {
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VideoTileModule,
cfg.VideoTileRS,
cfg.VP8FPS,
cfg.VP8BatchSize,
)
@@ -252,6 +256,8 @@ func Run(ctx context.Context, cfg Config) error {
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VideoTileModule,
cfg.VideoTileRS,
cfg.VP8FPS,
cfg.VP8BatchSize,
)

View File

@@ -63,10 +63,12 @@ func Run(
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
) error {
return RunWithReady(ctx, linkName, transportName, carrierName, roomURL, keyHex, localAddr, dnsServer, socksUser, socksPass, nil, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, vp8FPS, vp8BatchSize)
return RunWithReady(ctx, linkName, transportName, carrierName, roomURL, keyHex, localAddr, dnsServer, socksUser, socksPass, nil, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, videoTileModule, videoTileRS, vp8FPS, vp8BatchSize)
}
// RunWithReady is like Run but accepts a callback that is called when the client is ready.
@@ -90,6 +92,8 @@ func RunWithReady(
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
) error {
@@ -119,7 +123,7 @@ func RunWithReady(
const linkCount = 1
for i := range linkCount {
if err := c.addLink(runCtx, linkName, transportName, carrierName, roomURL, i, cancel, dnsServer, "", 0, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, vp8FPS, vp8BatchSize); err != nil {
if err := c.addLink(runCtx, linkName, transportName, carrierName, roomURL, i, cancel, dnsServer, "", 0, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, videoTileModule, videoTileRS, vp8FPS, vp8BatchSize); err != nil {
return fmt.Errorf("addLink failed: %w", err)
}
}
@@ -225,6 +229,8 @@ func (c *Client) addLink(
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
) error {
@@ -245,6 +251,8 @@ func (c *Client) addLink(
VideoQRSize: videoQRSize,
VideoQRRecovery: videoQRRecovery,
VideoCodec: videoCodec,
VideoTileModule: videoTileModule,
VideoTileRS: videoTileRS,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
})

View File

@@ -31,6 +31,8 @@ func New(ctx context.Context, cfg link.Config) (link.Link, error) {
VideoQRSize: cfg.VideoQRSize,
VideoQRRecovery: cfg.VideoQRRecovery,
VideoCodec: cfg.VideoCodec,
VideoTileModule: cfg.VideoTileModule,
VideoTileRS: cfg.VideoTileRS,
VP8FPS: cfg.VP8FPS,
VP8BatchSize: cfg.VP8BatchSize,
})

View File

@@ -41,6 +41,8 @@ type Config struct {
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoTileModule int
VideoTileRS int
VP8FPS int
VP8BatchSize int
}

View File

@@ -82,6 +82,8 @@ func Run(
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
) error {
@@ -108,7 +110,7 @@ func Run(
const linkCount = 1
for i := range linkCount {
if err := s.addLink(runCtx, linkName, transportName, carrierName, roomURL, i, cancel, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, vp8FPS, vp8BatchSize); err != nil {
if err := s.addLink(runCtx, linkName, transportName, carrierName, roomURL, i, cancel, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, videoTileModule, videoTileRS, vp8FPS, vp8BatchSize); err != nil {
return fmt.Errorf("addLink failed: %w", err)
}
}
@@ -197,6 +199,8 @@ func (s *Server) addLink(
videoQRSize int,
videoQRRecovery string,
videoCodec string,
videoTileModule int,
videoTileRS int,
vp8FPS int,
vp8BatchSize int,
) error {
@@ -217,6 +221,8 @@ func (s *Server) addLink(
VideoQRSize: videoQRSize,
VideoQRRecovery: videoQRRecovery,
VideoCodec: videoCodec,
VideoTileModule: videoTileModule,
VideoTileRS: videoTileRS,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
})

View File

@@ -49,6 +49,8 @@ type Config struct {
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoTileModule int
VideoTileRS int
VP8FPS int
VP8BatchSize int
}

View File

@@ -65,6 +65,8 @@ type streamTransport struct {
videoQRSize int
videoQRRecovery string
videoCodec string
videoTileModule int
videoTileRS int
}
// New creates a visual videochannel transport backed by a carrier-specific provider.
@@ -102,6 +104,16 @@ func New(ctx context.Context, cfg transport.Config) (transport.Transport, error)
qrSize = defaultFragmentSize
}
tileModule := cfg.VideoTileModule
if tileModule <= 0 {
tileModule = 4
}
tileRS := cfg.VideoTileRS
if tileRS < 0 {
tileRS = 20
}
tr := &streamTransport{
stream: stream,
track: track,
@@ -122,6 +134,8 @@ func New(ctx context.Context, cfg transport.Config) (transport.Transport, error)
videoQRSize: qrSize,
videoQRRecovery: cfg.VideoQRRecovery,
videoCodec: cfg.VideoCodec,
videoTileModule: tileModule,
videoTileRS: tileRS,
}
if err := stream.AddTrack(track); err != nil {
@@ -281,7 +295,7 @@ func (p *streamTransport) writerLoop() {
var rawFrame []byte
var err error
rawFrame, err = renderVisualFrame(payload, p.videoW, p.videoH, p.videoQRRecovery)
rawFrame, err = renderVisualFrame(payload, p.videoW, p.videoH, p.videoCodec, p.videoQRRecovery, p.videoTileModule, p.videoTileRS)
if err != nil {
logger.Debugf("videochannel render error: %v", err)
continue
@@ -390,7 +404,7 @@ func (p *streamTransport) handleRemoteTrack(track *webrtc.TrackRemote, _ *webrtc
func (p *streamTransport) handleFrame(frame []byte) {
var payload []byte
var err error
payload, err = extractVisualPayload(frame, p.videoW, p.videoH)
payload, err = extractVisualPayload(frame, p.videoW, p.videoH, p.videoCodec, p.videoTileModule, p.videoTileRS)
if err != nil || len(payload) == 0 {
if err != nil {
logger.Debugf("videochannel extract visual payload error: %v", err)

View File

@@ -7,12 +7,12 @@ import (
func TestVisualRoundTrip(t *testing.T) {
payload := []byte("hello over visual videochannel")
frame, err := renderVisualFrame(payload, 320, 240, "low")
frame, err := renderVisualFrame(payload, 320, 240, "qrcode", "low", 4, 20)
if err != nil {
t.Fatalf("renderVisualFrame failed: %v", err)
}
got, err := extractVisualPayload(frame, 320, 240)
got, err := extractVisualPayload(frame, 320, 240, "qrcode", 4, 20)
if err != nil {
t.Fatalf("extractVisualPayload failed: %v", err)
}
@@ -22,17 +22,45 @@ func TestVisualRoundTrip(t *testing.T) {
}
func TestIdleFrameIgnored(t *testing.T) {
frame, err := renderVisualFrame(nil, 320, 240, "low")
frame, err := renderVisualFrame(nil, 320, 240, "qrcode", "low", 4, 20)
if err != nil {
t.Fatalf("renderVisualFrame failed: %v", err)
}
got, err := extractVisualPayload(frame, 320, 240)
got, err := extractVisualPayload(frame, 320, 240, "qrcode", 4, 20)
if err == nil && len(got) != 0 {
t.Fatalf("expected idle frame to be ignored, got=%q", got)
}
}
func TestTileVisualRoundTrip(t *testing.T) {
payload := []byte("hello over tile videochannel")
frame, err := renderVisualFrame(payload, 1080, 1080, "tile", "", 4, 20)
if err != nil {
t.Fatalf("renderVisualFrame tile failed: %v", err)
}
got, err := extractVisualPayload(frame, 1080, 1080, "tile", 4, 20)
if err != nil {
t.Fatalf("extractVisualPayload tile failed: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload mismatch: got=%q want=%q", got, payload)
}
}
func TestTileIdleFrameIgnored(t *testing.T) {
frame, err := renderVisualFrame(nil, 1080, 1080, "tile", "", 4, 20)
if err != nil {
t.Fatalf("renderVisualFrame tile failed: %v", err)
}
got, err := extractVisualPayload(frame, 1080, 1080, "tile", 4, 20)
if err == nil && len(got) != 0 {
t.Fatalf("expected tile idle frame to be ignored, got=%q", got)
}
}
func TestTransportFrameRoundTrip(t *testing.T) {
encoded := encodeDataFrame(42, 0xdeadbeef, 1024, 1, 3, []byte("chunk"))
decoded, err := decodeTransportFrame(encoded)

View File

@@ -5,6 +5,7 @@ import (
"strings"
grqr "github.com/zarazaex69/gr/qr"
grtile "github.com/zarazaex69/gr/tile"
)
func eccLevel(level string) grqr.ECCLevel {
@@ -20,7 +21,14 @@ func eccLevel(level string) grqr.ECCLevel {
}
}
func renderVisualFrame(payload []byte, width, height int, recoveryLevel string) ([]byte, error) {
func renderVisualFrame(payload []byte, width, height int, codec, recoveryLevel string, tileModule, tileRS int) ([]byte, error) {
if codec == "tile" {
return renderTileFrame(payload, tileModule, tileRS)
}
return renderQRFrame(payload, width, height, recoveryLevel)
}
func renderQRFrame(payload []byte, width, height int, recoveryLevel string) ([]byte, error) {
if len(payload) == 0 {
frame := make([]byte, width*height)
for i := range frame {
@@ -29,7 +37,7 @@ func renderVisualFrame(payload []byte, width, height int, recoveryLevel string)
return frame, nil
}
codec, err := grqr.New(grqr.Config{
c, err := grqr.New(grqr.Config{
FrameW: width,
FrameH: height,
Margin: 2,
@@ -39,15 +47,39 @@ func renderVisualFrame(payload []byte, width, height int, recoveryLevel string)
return nil, fmt.Errorf("qr codec: %w", err)
}
return codec.Encode(payload)
return c.Encode(payload)
}
func extractVisualPayload(frame []byte, width, height int) ([]byte, error) {
func renderTileFrame(payload []byte, tileModule, tileRS int) ([]byte, error) {
if len(payload) == 0 {
frame := make([]byte, grtile.FrameW*grtile.FrameH)
for i := range frame {
frame[i] = 0xff
}
return frame, nil
}
c, err := grtile.New(grtile.Config{Module: tileModule, RSPercent: tileRS})
if err != nil {
return nil, fmt.Errorf("tile codec: %w", err)
}
return c.Encode(payload, 0, 1)
}
func extractVisualPayload(frame []byte, width, height int, codec string, tileModule, tileRS int) ([]byte, error) {
if codec == "tile" {
return extractTilePayload(frame, tileModule, tileRS)
}
return extractQRPayload(frame, width, height)
}
func extractQRPayload(frame []byte, width, height int) ([]byte, error) {
if len(frame) != width*height {
return nil, fmt.Errorf("unexpected frame size: %d (expected %dx%d=%d)", len(frame), width, height, width*height)
}
codec, err := grqr.New(grqr.Config{
c, err := grqr.New(grqr.Config{
FrameW: width,
FrameH: height,
Margin: 2,
@@ -56,7 +88,7 @@ func extractVisualPayload(frame []byte, width, height int) ([]byte, error) {
return nil, fmt.Errorf("qr codec: %w", err)
}
data, err := codec.Decode(frame)
data, err := c.Decode(frame)
if err != nil {
if strings.Contains(err.Error(), "NotFoundException") || strings.Contains(err.Error(), "not found") {
return nil, nil
@@ -66,3 +98,21 @@ func extractVisualPayload(frame []byte, width, height int) ([]byte, error) {
return data, nil
}
func extractTilePayload(frame []byte, tileModule, tileRS int) ([]byte, error) {
if len(frame) != grtile.FrameW*grtile.FrameH {
return nil, nil
}
c, err := grtile.New(grtile.Config{Module: tileModule, RSPercent: tileRS})
if err != nil {
return nil, fmt.Errorf("tile codec: %w", err)
}
result, err := c.Decode(frame)
if err != nil {
return nil, nil
}
return result.Payload, nil
}

View File

@@ -123,7 +123,7 @@ func Start(roomID, keyHex string, socksPort int, socksUser, socksPass string) er
close(localReady)
})
},
0, 0, 0, "", "", 0, "", "", 0, 0,
0, 0, 0, "", "", 0, "", "", 0, 0, 0, 0,
)
mu.Lock()