Add configurable QR error correction level for video transpor

This commit is contained in:
zarazaex69
2026-04-27 18:53:39 +03:00
parent 98ad27bec5
commit 689441a7f4
14 changed files with 108 additions and 83 deletions

View File

@@ -36,7 +36,8 @@ type config struct {
videoFPS int
videoBitrate string
videoHW string
videoQRSize int
videoQRSize int
videoQRRecovery string
videoCodec string
videoBModule int
videoBColors int
@@ -118,6 +119,7 @@ func parseFlags() config {
flag.StringVar(&cfg.videoBitrate, "video-bitrate", "", "Video bitrate (videochannel only)")
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.IntVar(&cfg.videoBModule, "video-b-module", 4, "B codec pixels per module (default 4)")
flag.IntVar(&cfg.videoBColors, "video-b-colors", 8, "B codec colors (4, 8, 16, 32, 64, 128, 256)")
@@ -175,7 +177,8 @@ func toSessionConfig(cfg config) session.Config {
VideoFPS: cfg.videoFPS,
VideoBitrate: cfg.videoBitrate,
VideoHW: cfg.videoHW,
VideoQRSize: cfg.videoQRSize,
VideoQRSize: cfg.videoQRSize,
VideoQRRecovery: cfg.videoQRRecovery,
VideoCodec: cfg.videoCodec,
VideoBModule: cfg.videoBModule,
VideoBColors: cfg.videoBColors,

2
go.mod
View File

@@ -3,7 +3,6 @@ module github.com/openlibrecommunity/olcrtc
go 1.25.0
require (
github.com/boombuler/barcode v1.1.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/livekit/server-sdk-go/v2 v2.16.2
@@ -11,6 +10,7 @@ require (
github.com/makiuchi-d/gozxing v0.1.1
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v4 v4.2.11
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/zarazaex69/b v0.0.0-20260423064626-c0bd20863b89
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b

4
go.sum
View File

@@ -20,8 +20,6 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -182,6 +180,8 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

View File

@@ -77,7 +77,8 @@ type Config struct {
VideoFPS int
VideoBitrate string
VideoHW string
VideoQRSize int
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoBModule int
VideoBColors int
@@ -228,6 +229,7 @@ func Run(ctx context.Context, cfg Config) error {
cfg.VideoBitrate,
cfg.VideoHW,
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VP8FPS,
cfg.VP8BatchSize,
@@ -250,6 +252,7 @@ func Run(ctx context.Context, cfg Config) error {
cfg.VideoBitrate,
cfg.VideoHW,
cfg.VideoQRSize,
cfg.VideoQRRecovery,
cfg.VideoCodec,
cfg.VP8FPS,
cfg.VP8BatchSize,

View File

@@ -61,11 +61,12 @@ func Run(
videoBitrate string,
videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
vp8FPS int,
vp8BatchSize int,
) error {
return RunWithReady(ctx, linkName, transportName, carrierName, roomURL, keyHex, localAddr, dnsServer, socksUser, socksPass, nil, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoCodec, vp8FPS, vp8BatchSize)
return RunWithReady(ctx, linkName, transportName, carrierName, roomURL, keyHex, localAddr, dnsServer, socksUser, socksPass, nil, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, vp8FPS, vp8BatchSize)
}
// RunWithReady is like Run but accepts a callback that is called when the client is ready.
@@ -87,6 +88,7 @@ func RunWithReady(
videoBitrate string,
videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
vp8FPS int,
vp8BatchSize int,
@@ -117,7 +119,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, 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, vp8FPS, vp8BatchSize); err != nil {
return fmt.Errorf("addLink failed: %w", err)
}
}
@@ -221,28 +223,30 @@ func (c *Client) addLink(
videoWidth, videoHeight, videoFPS int,
videoBitrate, videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
vp8FPS int,
vp8BatchSize int,
) error {
ln, err := link.New(ctx, linkName, link.Config{
Transport: transportName,
Carrier: carrierName,
RoomURL: roomURL,
Name: names.Generate(),
OnData: c.onData,
DNSServer: dnsServer,
ProxyAddr: socksProxyAddr,
ProxyPort: socksProxyPort,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
VideoFPS: videoFPS,
VideoBitrate: videoBitrate,
VideoHW: videoHW,
VideoQRSize: videoQRSize,
VideoCodec: videoCodec,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
Transport: transportName,
Carrier: carrierName,
RoomURL: roomURL,
Name: names.Generate(),
OnData: c.onData,
DNSServer: dnsServer,
ProxyAddr: socksProxyAddr,
ProxyPort: socksProxyPort,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
VideoFPS: videoFPS,
VideoBitrate: videoBitrate,
VideoHW: videoHW,
VideoQRSize: videoQRSize,
VideoQRRecovery: videoQRRecovery,
VideoCodec: videoCodec,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
})
if err != nil {
return fmt.Errorf("failed to create link: %w", err)

View File

@@ -28,8 +28,9 @@ func New(ctx context.Context, cfg link.Config) (link.Link, error) {
VideoFPS: cfg.VideoFPS,
VideoBitrate: cfg.VideoBitrate,
VideoHW: cfg.VideoHW,
VideoQRSize: cfg.VideoQRSize,
VideoCodec: cfg.VideoCodec,
VideoQRSize: cfg.VideoQRSize,
VideoQRRecovery: cfg.VideoQRRecovery,
VideoCodec: cfg.VideoCodec,
VideoBModule: cfg.VideoBModule,
VideoBColors: cfg.VideoBColors,
VP8FPS: cfg.VP8FPS,

View File

@@ -38,8 +38,9 @@ type Config struct {
VideoFPS int
VideoBitrate string
VideoHW string
VideoQRSize int
VideoCodec string
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoBModule int
VideoBColors int
VP8FPS int

View File

@@ -80,6 +80,7 @@ func Run(
videoBitrate string,
videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
vp8FPS int,
vp8BatchSize int,
@@ -107,7 +108,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, videoCodec, vp8FPS, vp8BatchSize); err != nil {
if err := s.addLink(runCtx, linkName, transportName, carrierName, roomURL, i, cancel, videoWidth, videoHeight, videoFPS, videoBitrate, videoHW, videoQRSize, videoQRRecovery, videoCodec, vp8FPS, vp8BatchSize); err != nil {
return fmt.Errorf("addLink failed: %w", err)
}
}
@@ -194,28 +195,30 @@ func (s *Server) addLink(
videoWidth, videoHeight, videoFPS int,
videoBitrate, videoHW string,
videoQRSize int,
videoQRRecovery string,
videoCodec string,
vp8FPS int,
vp8BatchSize int,
) error {
ln, err := link.New(ctx, linkName, link.Config{
Transport: transportName,
Carrier: carrierName,
RoomURL: roomURL,
Name: names.Generate(),
OnData: s.onData,
DNSServer: s.dnsServer,
ProxyAddr: s.socksProxyAddr,
ProxyPort: s.socksProxyPort,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
VideoFPS: videoFPS,
VideoBitrate: videoBitrate,
VideoHW: videoHW,
VideoQRSize: videoQRSize,
VideoCodec: videoCodec,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
Transport: transportName,
Carrier: carrierName,
RoomURL: roomURL,
Name: names.Generate(),
OnData: s.onData,
DNSServer: s.dnsServer,
ProxyAddr: s.socksProxyAddr,
ProxyPort: s.socksProxyPort,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
VideoFPS: videoFPS,
VideoBitrate: videoBitrate,
VideoHW: videoHW,
VideoQRSize: videoQRSize,
VideoQRRecovery: videoQRRecovery,
VideoCodec: videoCodec,
VP8FPS: vp8FPS,
VP8BatchSize: vp8BatchSize,
})
if err != nil {
return fmt.Errorf("failed to create link: %w", err)

View File

@@ -46,8 +46,9 @@ type Config struct {
VideoFPS int
VideoBitrate string
VideoHW string
VideoQRSize int
VideoCodec string
VideoQRSize int
VideoQRRecovery string
VideoCodec string
VideoBModule int
VideoBColors int
VP8FPS int

View File

@@ -62,8 +62,9 @@ type streamTransport struct {
videoFPS int
videoBitrate string
videoHW string
videoQRSize int
videoCodec string
videoQRSize int
videoQRRecovery string
videoCodec string
videoBModule int
videoBColors int
}
@@ -120,8 +121,9 @@ func New(ctx context.Context, cfg transport.Config) (transport.Transport, error)
videoFPS: cfg.VideoFPS,
videoBitrate: cfg.VideoBitrate,
videoHW: cfg.VideoHW,
videoQRSize: qrSize,
videoCodec: cfg.VideoCodec,
videoQRSize: qrSize,
videoQRRecovery: cfg.VideoQRRecovery,
videoCodec: cfg.VideoCodec,
videoBModule: cfg.VideoBModule,
videoBColors: cfg.VideoBColors,
}
@@ -284,9 +286,9 @@ func (p *streamTransport) writerLoop() {
var rawFrame []byte
var err error
if p.videoCodec == "b" {
rawFrame, err = renderVisualFrameB(payload, p.videoW, p.videoH, p.videoBModule, p.videoBColors)
rawFrame, err = renderVisualFrameB(payload, p.videoW, p.videoH, p.videoBModule, p.videoBColors, p.videoQRRecovery)
} else {
rawFrame, err = renderVisualFrame(payload, p.videoW, p.videoH)
rawFrame, err = renderVisualFrame(payload, p.videoW, p.videoH, p.videoQRRecovery)
}
if err != nil {
logger.Debugf("videochannel render error: %v", err)

View File

@@ -6,20 +6,33 @@ import (
"image"
"strings"
barcodedm "github.com/boombuler/barcode/datamatrix"
"github.com/makiuchi-d/gozxing"
zxingdm "github.com/makiuchi-d/gozxing/datamatrix"
zxingqr "github.com/makiuchi-d/gozxing/qrcode"
qrgen "github.com/skip2/go-qrcode"
)
const (
quietZone = 10
)
func renderVisualFrame(payload []byte, width, height int) ([]byte, error) {
func parseRecoveryLevel(level string) qrgen.RecoveryLevel {
switch level {
case "medium":
return qrgen.Medium
case "high":
return qrgen.High
case "highest":
return qrgen.Highest
default:
return qrgen.Low
}
}
func renderVisualFrame(payload []byte, width, height int, recoveryLevel string) ([]byte, error) {
logicalFrameBytes := width * height
frame := make([]byte, logicalFrameBytes)
for i := range frame {
frame[i] = 0xff // White background
frame[i] = 0xff
}
if len(payload) == 0 {
@@ -27,18 +40,16 @@ func renderVisualFrame(payload []byte, width, height int) ([]byte, error) {
}
encoded := base64.StdEncoding.EncodeToString(payload)
dm, err := barcodedm.Encode(encoded)
qr, err := qrgen.New(encoded, parseRecoveryLevel(recoveryLevel))
if err != nil {
return nil, fmt.Errorf("datamatrix encode: %w", err)
return nil, fmt.Errorf("qrcode encode: %w", err)
}
// Use strict integer scaling to keep edges sharp
bounds := dm.Bounds()
dmW := bounds.Dx()
dmH := bounds.Dy()
bitmap := qr.Bitmap()
qrSize := len(bitmap)
scaleW := (width - (quietZone * 2)) / dmW
scaleH := (height - (quietZone * 2)) / dmH
scaleW := (width - (quietZone * 2)) / qrSize
scaleH := (height - (quietZone * 2)) / qrSize
scale := scaleW
if scaleH < scale {
scale = scaleH
@@ -47,16 +58,13 @@ func renderVisualFrame(payload []byte, width, height int) ([]byte, error) {
scale = 1
}
totalW := dmW * scale
totalH := dmH * scale
offsetX := (width - totalW) / 2
offsetY := (height - totalH) / 2
totalSize := qrSize * scale
offsetX := (width - totalSize) / 2
offsetY := (height - totalSize) / 2
for y := 0; y < dmH; y++ {
for x := 0; x < dmW; x++ {
r, _, _, _ := dm.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
if r < 0x8000 {
// Fill scale x scale block
for y := 0; y < qrSize; y++ {
for x := 0; x < qrSize; x++ {
if bitmap[y][x] {
for sy := 0; sy < scale; sy++ {
for sx := 0; sx < scale; sx++ {
pixelX := offsetX + (x * scale) + sx
@@ -83,14 +91,13 @@ func extractVisualPayload(frame []byte, width, height int) ([]byte, error) {
copy(img.Pix, frame)
source := gozxing.NewLuminanceSourceFromImage(img)
// HybridBinarizer is good for noisy images
binarizer := gozxing.NewHybridBinarizer(source)
bmp, err := gozxing.NewBinaryBitmap(binarizer)
if err != nil {
return nil, fmt.Errorf("bitmap: %w", err)
}
reader := zxingdm.NewDataMatrixReader()
reader := zxingqr.NewQRCodeReader()
hints := make(map[gozxing.DecodeHintType]interface{})
hints[gozxing.DecodeHintType_TRY_HARDER] = true
hints[gozxing.DecodeHintType_PURE_BARCODE] = true

View File

@@ -10,7 +10,7 @@ import (
"github.com/zarazaex69/b/go"
)
func renderVisualFrameB(payload []byte, width, height, modulePx, colors int) ([]byte, error) {
func renderVisualFrameB(payload []byte, width, height, modulePx, colors int, _ string) ([]byte, error) {
rgba := make([]byte, width*height*4)
for i := 0; i < len(rgba); i += 4 {
rgba[i] = 0xff

View File

@@ -2,10 +2,10 @@
package videochannel
func renderVisualFrameB(payload []byte, width, height int) ([]byte, error) {
return renderVisualFrame(payload, width, height)
func renderVisualFrameB(payload []byte, width, height, modulePx, colors int, recoveryLevel string) ([]byte, error) {
return renderVisualFrame(payload, width, height, recoveryLevel)
}
func extractVisualPayloadB(frame []byte, width, height int) ([]byte, error) {
func extractVisualPayloadB(frame []byte, width, height, modulePx, colors int) ([]byte, error) {
return extractVisualPayload(frame, width, height)
}

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,
)
mu.Lock()