Back to blog
Hardware Codecs, SVC, and Why Your Settings Kept Disappearing

Hardware Codecs, SVC, and Why Your Settings Kept Disappearing

How Gryt got H.264 and AV1 hardware encoding, scalable video coding that adapts quality per viewer, and a fix for the most frustrating bug in the desktop app.

Sivert·
webrtctechnicalscreen-sharingdesktop

What is in this update

This update touches three separate areas that shipped within days of each other: hardware-accelerated codec selection, SVC layer-aware forwarding in the SFU, and a fix for the desktop app silently losing all your data between restarts.

The codec problem

When Gryt first got screen sharing, it used whatever codec the browser picked. That usually meant VP9. It's a fine codec and it compresses well, but consumer GPUs almost never hardware-encode it, so your CPU was doing all the work. Share a game at 1080p 30fps and you could feel it.

The fix was easy once I understood what was going on. WebRTC lets you tell the browser which codec you'd rather have, with setCodecPreferences on the transceiver. Put H.264 first and the browser negotiates H.264 and hands the encoding to your GPU: NVENC on NVIDIA, Quick Sync on Intel, AMF on AMD. CPU usage drops to nearly nothing.

But H.264 isn't always the best choice. AV1 compresses a lot better at the same bitrate, and newer GPUs (RTX 40 series, Intel Arc, AMD RX 7000+) can hardware-encode it. So instead of hardcoding H.264, you get to pick: Auto (H.264), H.264, VP9, or AV1. Auto checks what your browser supports with RTCRtpSender.getCapabilities("video") and goes for H.264, because everything can play it. If you know your hardware does AV1, switch to it and get better quality for the same bandwidth.

One codec isn't on the list: H.265/HEVC. It's everywhere in video streaming, but WebRTC doesn't support it and the browser APIs won't let you negotiate it. AV1 does the same job with better compression and browsers that actually support it, so nothing is lost.

All of this sits behind an "Advanced" toggle in the screen share dialog. The defaults are good, so most people never open it. But if you want to change the codec, the bitrate or the degradation preference, it's there.

Gaming mode

Alongside the codec work, I added a "Gaming mode" toggle (on by default) that bundles several encoder hints for fast-moving content:

  • Cursor hiding. The cursor is stripped from the capture, so nobody watching sees a frozen cursor sitting over a game that hides it
  • Content hint. Set to "motion" instead of "detail", which tells the encoder to keep the framerate up rather than the sharpness
  • Degradation preference. Set to "maintain-framerate" so WebRTC drops resolution before framerate when bandwidth is tight
  • Bitrate boost. A 1.5x multiplier on the auto-estimated bitrate (capped at 20 Mbps) to give the encoder more headroom

For a presentation or anything text-heavy, turn gaming mode off and it goes back to resolution first. For games and video the difference is big.

SVC: one stream, multiple quality levels

Scalable Video Coding lets the encoder produce temporal layers within a single stream. With L1T3 (the default), the encoder outputs three framerate tiers in one RTP stream:

  • T0: Base layer at 7.5 fps
  • T0 + T1: 15 fps
  • T0 + T1 + T2: Full 30 fps

The SFU parses the Dependency Descriptor RTP header extension on each incoming packet to figure out which temporal layer it belongs to. For each receiver, it decides how many layers to forward based on that receiver's available bandwidth. Someone on a fast connection gets the full 30 fps. Someone on a slow connection gets 7.5 fps instead of a pixelated mess.

Before SVC, the SFU did blind byte-copy relay, so every receiver got identical packets regardless of their bandwidth. If one viewer had a bad connection, they would experience packet loss, which triggers PLI (Picture Loss Indication) requests back to the sender, which forces keyframes, which hurts quality for everyone. With SVC, constrained viewers just get fewer temporal layers. Clean frames, no packet loss storms, no impact on other viewers.

The adaptation happens automatically. The SFU reads REMB (Receiver Estimated Maximum Bitrate) feedback from each viewer and adjusts their temporal layer subscription:

  • Below 1 Mbps: T0 only (7.5 fps)
  • Below 3 Mbps: T0 + T1 (15 fps)
  • Above 3 Mbps: All layers (30 fps)

There's also a set_layer WebSocket event for manual control, which could drive a quality picker later.

Performance impact

On the client side: none. Hardware encoders (NVENC, Quick Sync, AMF) support temporal SVC natively for VP9, AV1, and H.264. The encoder does the same amount of work whether SVC is on or off.

On the SFU side: parsing the 3-byte Dependency Descriptor header per RTP packet adds nanoseconds of overhead. The per-receiver fanout replaces one Write() call with N Write() calls, but the total I/O is the same. Memory overhead is one lightweight track object per receiver per video track. In a 10-person room with 2 video tracks, that's 20 extra objects, which is negligible.

Where SVC actually saves something is downstream bandwidth. Instead of pushing 30 fps at somebody who can't take it, and causing retransmits and congestion, the SFU sends each viewer only what they can absorb.

The disappearing data bug

This one was frustrating to track down and probably affected more users than I realized.

The desktop app stores authentication tokens, theme settings, server configurations, and other preferences in localStorage. This works fine most of the time. But localStorage in Electron is origin-bound, tied to the specific http://127.0.0.1:port URL that the app loads. If for any reason that port changes, or the Chromium profile gets reset, or storage is cleared during an update, everything is gone. You are signed out, your servers are missing, your settings are back to defaults.

The annoying part is that it didn't fail consistently. It could work for weeks and then silently lose everything on the next launch. The kind of bug that makes you doubt yourself, because you can't reproduce it on demand.

The fix was to stop relying on localStorage as the source of truth. The desktop app now backs every localStorage entry with a JSON file (gryt-global.json) in the Electron userData directory, the same directory that survives across updates and reinstalls.

On startup, before React even renders, the app loads the file store via IPC and restores everything into localStorage. Then it patches localStorage.setItem and localStorage.removeItem so that every future write is automatically synced to the file in real time. The file store is the source of truth; localStorage is just a fast in-memory cache.

On first launch after the update, a one-time migration runs that copies all existing localStorage entries into the file store. If you already had data in localStorage from before, it's preserved. If localStorage was empty but the file store had data from a previous session, it gets restored. Either way, nothing is lost.

The best part: zero changes to any of the existing code that reads or writes localStorage. The theme hook, the auth module, the token storage and the settings system all continue to call localStorage.getItem and localStorage.setItem exactly as before. The patching layer handles the persistence transparently.

What comes next

These three features set the stage for a few things I have been wanting to build:

  • A quality selector UI for incoming streams, powered by the set_layer event
  • Spatial SVC (L2T3, L3T3) for resolution scaling in addition to framerate scaling
  • End-to-end encryption via SFrame / insertable streams, which pairs well with the per-receiver track model

For now, screen sharing is faster, it adapts to each viewer's bandwidth, and your settings stop disappearing.


If you want to dig into the implementation details, the SFU documentation covers the Dependency Descriptor parser and layer-aware forwarding, and Performance Tuning has the full screen share settings reference. Questions or feedback? Come hang out on our Gryt server or Discord.