diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 3285c5fe4..e7e2e3b18 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -69,6 +69,15 @@ const ( pseudoEncDesktopName = -307 pseudoEncExtendedDesktopSize = -308 + // Quality/Compression level pseudo-encodings (TightVNC extension). The + // client picks one value from each range to tune JPEG quality and zlib + // effort. 0 is lowest quality / fastest, 9 is highest quality / best + // compression. + pseudoEncQualityLevelMin = -32 + pseudoEncQualityLevelMax = -23 + pseudoEncCompressLevelMin = -256 + pseudoEncCompressLevelMax = -247 + // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. tightFillSubenc = 0x80 @@ -427,16 +436,65 @@ type tightState struct { // colorSeen is reused by sampledColorCount per rect; cleared via the Go // runtime's map-clear fast path to avoid a fresh allocation each call. colorSeen map[uint32]struct{} + // jpegQualityOverride forces a fixed JPEG quality on every rect when + // non-zero (set from the client's QualityLevel pseudo-encoding). Zero + // falls back to the area-based tiers in tightQualityFor. + jpegQualityOverride int + // qualityLevel and compressLevel are the 0..9 levels currently applied, + // or -1 if the client did not express a preference. Used to decide + // whether a SetEncodings refresh needs to recreate the tight state. + qualityLevel int + compressLevel int } func newTightState() *tightState { + return newTightStateWithLevels(-1, -1) +} + +// newTightStateWithLevels builds a tightState whose zlib stream and JPEG +// quality reflect the client's QualityLevel / CompressLevel pseudo-encodings. +// Pass -1 for either level to keep our defaults (BestSpeed zlib and the +// area-tiered JPEG quality in tightQualityFor). +func newTightStateWithLevels(qualityLevel, compressLevel int) *tightState { return &tightState{ - jpegBuf: &bytes.Buffer{}, - zlib: newZlibState(), - colorSeen: make(map[uint32]struct{}, 64), + jpegBuf: &bytes.Buffer{}, + zlib: newZlibStateLevel(zlibLevelFor(compressLevel)), + colorSeen: make(map[uint32]struct{}, 64), + jpegQualityOverride: jpegQualityForLevel(qualityLevel), + qualityLevel: qualityLevel, + compressLevel: compressLevel, } } +// jpegQualityForLevel maps a 0..9 client preference to a JPEG quality value. +// Returns 0 when no preference is set (-1), letting the encoder fall back to +// the area-based tiers. +func jpegQualityForLevel(level int) int { + if level < 0 { + return 0 + } + if level > 9 { + level = 9 + } + // 0 -> 30, 9 -> 93. Linear so adjacent steps are perceptually similar. + return 30 + level*7 +} + +// zlibLevelFor maps a 0..9 client preference to a zlib compression level. +// Level 0 ("no compression") would emit larger output than input on most +// rects, so we floor to BestSpeed (1). -1 (no preference) also picks +// BestSpeed: matches the historical default before the pseudo-encoding +// was honoured. +func zlibLevelFor(level int) int { + if level < 1 { + return zlib.BestSpeed + } + if level > zlib.BestCompression { + return zlib.BestCompression + } + return level +} + // encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform // content, JPEG for photo-like rects above a size and color-count threshold, // and Basic+zlib otherwise. Returns the rect header + Tight body (no @@ -496,7 +554,11 @@ func encodeTightFill(x, y, w, h int, r, g, b byte) []byte { func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) { t.jpegBuf.Reset() sub := img.SubImage(image.Rect(img.Rect.Min.X+x, img.Rect.Min.Y+y, img.Rect.Min.X+x+w, img.Rect.Min.Y+y+h)) - if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: tightQualityFor(w * h)}); err != nil { + q := t.jpegQualityOverride + if q == 0 { + q = tightQualityFor(w * h) + } + if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: q}); err != nil { return nil, false } jpegBytes := t.jpegBuf.Bytes() @@ -610,8 +672,12 @@ type zlibState struct { } func newZlibState() *zlibState { + return newZlibStateLevel(zlib.BestSpeed) +} + +func newZlibStateLevel(level int) *zlibState { buf := &bytes.Buffer{} - w, _ := zlib.NewWriterLevel(buf, zlib.BestSpeed) + w, _ := zlib.NewWriterLevel(buf, level) return &zlibState{buf: buf, w: w} } diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index e59fb2edd..be26f2d31 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -62,6 +62,12 @@ type session struct { clientSupportsQEMUKey bool clientSupportsExtClipboard bool extClipCapsSent bool + // clientJPEGQuality and clientZlibLevel hold the 0..9 levels the client + // advertised via the QualityLevel / CompressLevel pseudo-encodings, or + // -1 when the client has not expressed a preference. Applied to the + // tight encoder state after every SetEncodings. + clientJPEGQuality int + clientZlibLevel int // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -92,6 +98,8 @@ func (s *session) addr() string { return s.conn.RemoteAddr().String() } func (s *session) serve() { defer s.conn.Close() s.pf = defaultClientPixelFormat() + s.clientJPEGQuality = -1 + s.clientZlibLevel = -1 s.encodeCh = make(chan fbRequest, 1) if err := s.handshake(); err != nil { @@ -335,11 +343,21 @@ func (s *session) handleSetEncodings() error { encs = append(encs, "ext-clipboard") case encTight: s.useTight = true - if s.tight == nil { - s.tight = newTightState() - } encs = append(encs, "tight") } + switch { + case enc >= pseudoEncQualityLevelMin && enc <= pseudoEncQualityLevelMax: + s.clientJPEGQuality = int(enc - pseudoEncQualityLevelMin) + encs = append(encs, fmt.Sprintf("quality=%d", s.clientJPEGQuality)) + case enc >= pseudoEncCompressLevelMin && enc <= pseudoEncCompressLevelMax: + s.clientZlibLevel = int(enc - pseudoEncCompressLevelMin) + encs = append(encs, fmt.Sprintf("compress=%d", s.clientZlibLevel)) + } + } + if s.useTight && (s.tight == nil || + s.tight.qualityLevel != s.clientJPEGQuality || + s.tight.compressLevel != s.clientZlibLevel) { + s.tight = newTightStateWithLevels(s.clientJPEGQuality, s.clientZlibLevel) } sendExtClipCaps := s.clientSupportsExtClipboard && !s.extClipCapsSent if sendExtClipCaps { @@ -877,7 +895,8 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } return nil case extClipActionProvide: - return s.handleExtClipProvide(flags, rest) + s.handleExtClipProvide(flags, rest) + return nil default: s.log.Debugf("unknown ext clipboard action 0x%x", action) return nil @@ -885,22 +904,21 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } // handleExtClipProvide decodes a Provide payload and pushes the recovered -// text into the host clipboard. Errors and other unsupported formats (RTF, -// HTML, etc.) are swallowed so a malformed message doesn't tear down the -// session. -func (s *session) handleExtClipProvide(flags uint32, payload []byte) error { +// text into the host clipboard. Decode errors and unsupported formats (RTF, +// HTML, etc.) are logged and dropped so a malformed message doesn't tear +// down the session. +func (s *session) handleExtClipProvide(flags uint32, payload []byte) { if len(payload) == 0 { - return nil + return } text, err := parseExtClipProvideText(flags, payload) if err != nil { s.log.Debugf("parse ext clipboard provide: %v", err) - return nil + return } if text != "" { s.injector.SetClipboard(text) } - return nil } // sendExtClipProvideText answers an inbound Request(text) with the current