diff --git a/cmd/olcrtc/main.go b/cmd/olcrtc/main.go index 3a15440..a91c40e 100644 --- a/cmd/olcrtc/main.go +++ b/cmd/olcrtc/main.go @@ -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, } } diff --git a/go.mod b/go.mod index 301a2ac..2f05450 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7cfd2a9..33183c4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/session/session.go b/internal/app/session/session.go index 8c4cca0..77ace18 100644 --- a/internal/app/session/session.go +++ b/internal/app/session/session.go @@ -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, ) diff --git a/internal/client/client.go b/internal/client/client.go index e8dcf0a..51bc4d1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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, }) diff --git a/internal/link/direct/direct.go b/internal/link/direct/direct.go index c270aa3..0bb9cea 100644 --- a/internal/link/direct/direct.go +++ b/internal/link/direct/direct.go @@ -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, }) diff --git a/internal/link/link.go b/internal/link/link.go index 43fd41d..704e388 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -41,6 +41,8 @@ type Config struct { VideoQRSize int VideoQRRecovery string VideoCodec string + VideoTileModule int + VideoTileRS int VP8FPS int VP8BatchSize int } diff --git a/internal/server/server.go b/internal/server/server.go index 5937b53..6db008b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, }) diff --git a/internal/transport/transport.go b/internal/transport/transport.go index c9fdc52..aa0b464 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -49,6 +49,8 @@ type Config struct { VideoQRSize int VideoQRRecovery string VideoCodec string + VideoTileModule int + VideoTileRS int VP8FPS int VP8BatchSize int } diff --git a/internal/transport/videochannel/transport.go b/internal/transport/videochannel/transport.go index f33dece..c5a7ba0 100644 --- a/internal/transport/videochannel/transport.go +++ b/internal/transport/videochannel/transport.go @@ -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) diff --git a/internal/transport/videochannel/transport_test.go b/internal/transport/videochannel/transport_test.go index 92b1252..83e0f57 100644 --- a/internal/transport/videochannel/transport_test.go +++ b/internal/transport/videochannel/transport_test.go @@ -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) diff --git a/internal/transport/videochannel/visual.go b/internal/transport/videochannel/visual.go index d4df65b..82491ed 100644 --- a/internal/transport/videochannel/visual.go +++ b/internal/transport/videochannel/visual.go @@ -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 +} diff --git a/mobile/mobile.go b/mobile/mobile.go index 0aef9a5..de96987 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -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()