feat(video): Add 120hz Support#1452
Conversation
Add an opt-in setting that swaps the JetKVM default EDID for one advertising 848x480@120 (preferred) and 1280x720@120. At 120 fps the per-frame display→encode delay drops from ~16.7 ms to ~8.3 ms, halving source-side video latency vs the standard 1080p60 path. The TC358743 capture chip on JetKVM v1 has a hard ~120 Hz vrefresh ceiling (Toshiba spec is 1080p@60; everything above 60 Hz is undocumented territory). 144/240 Hz were tested and do not lock — the chip's internal blocks above the TMDS PHY were never validated past 60 Hz. 120 Hz works reliably across both 480p and 720p; that's what this EDID advertises. Wiring: - internal/native/video.go: new LowLatency120HzEDID constant (CVT-RB, EDID 1.4, single base block, no CEA extension) - config.go: VideoLowLatencyMode bool, with reconciliation on load — toggling only swaps EdidString when it currently holds one of the well-known JetKVM defaults; user-supplied custom EDIDs are preserved - jsonrpc.go: getVideoLowLatencyMode / setVideoLowLatencyMode RPCs - UI: experimental Checkbox in Settings → Video and an extra entry in the EDID preset dropdown Source-side note: enabling the toggle does not switch the source PC's display mode. The user must manually pick 1280x720@120 or 848x480@120 in their OS display settings; the EDID alone just tells the source what's allowed. scripts/edid_gen.py is the generator used to produce the EDID hex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “Low Latency 120 Hz Mode (Experimental)” setting that switches JetKVM’s advertised EDID to a 120 Hz-capable profile (848×480@120 preferred, 1280×720@120) to reduce source-side frame latency, with persistence via config and JSON-RPC.
Changes:
- Added backend config + JSON-RPC APIs to persist and toggle a low-latency 120 Hz EDID.
- Added UI toggle + messaging and a 120 Hz EDID label in the video settings page.
- Added an EDID generator script used to produce/test high-refresh EDIDs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/src/routes/devices.$id.settings.video.tsx | Adds the low-latency toggle UI and introduces a 120 Hz EDID option to the EDID selector. |
| ui/localization/messages/en.json | Adds English strings for the low-latency mode UI and error/success messaging. |
| scripts/edid_gen.py | New script to generate CVT/CVT-RB-based EDIDs for high-refresh testing. |
| jsonrpc.go | Adds getVideoLowLatencyMode / setVideoLowLatencyMode RPCs and toggling logic. |
| internal/native/video.go | Adds the LowLatency120HzEDID constant alongside the existing default EDID. |
| config.go | Adds VideoLowLatencyMode to config and reconciles EDID defaults on load. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| value: lowLatency120HzEdid, | ||
| label: m.video_edid_jetkvm_120hz(), | ||
| }, | ||
| { |
| const receivedEdid = edidResp.result as string; | ||
| const matchingEdid = edids.find(x => x.value.toLowerCase() === receivedEdid.toLowerCase()); | ||
| if (matchingEdid) { | ||
| setEdid(matchingEdid.value.toUpperCase()); | ||
| setCustomEdidValue(null); | ||
| } else { |
| var newEDID string | ||
| switch { | ||
| case enabled && config.EdidString == native.DefaultEDID: | ||
| newEDID = native.LowLatency120HzEDID | ||
| case !enabled && config.EdidString == native.LowLatency120HzEDID: | ||
| newEDID = native.DefaultEDID | ||
| } |
| // Reconcile EdidString with VideoLowLatencyMode when no custom EDID was set. | ||
| // A user with a hand-rolled EdidString keeps it; only the well-known defaults | ||
| // follow the toggle. | ||
| if loadedConfig.VideoLowLatencyMode && loadedConfig.EdidString == native.DefaultEDID { | ||
| loadedConfig.EdidString = native.LowLatency120HzEDID | ||
| } else if !loadedConfig.VideoLowLatencyMode && loadedConfig.EdidString == native.LowLatency120HzEDID { | ||
| loadedConfig.EdidString = native.DefaultEDID | ||
| } |
| import math | ||
| import struct | ||
| import sys |
| pclk_hz = v_total * h_total * refresh | ||
| pclk_khz = (pclk_hz // (CLOCK_STEP_KHZ * 1000)) * CLOCK_STEP_KHZ * 1000 | ||
| if pclk_khz == 0: | ||
| pclk_khz = pclk_hz # fallback | ||
|
|
||
| actual_refresh = pclk_khz / (v_total * h_total) |
Single source of truth for the toggle is now `EdidString`. The separate `VideoLowLatencyMode` config bool is gone, along with the load-time reconciliation logic that could silently revert the EDID dropdown's choice on reboot. Addresses: - cursor[bot] HIGH "Config reconciliation reverts explicit EDID dropdown choices on reboot" — drop `VideoLowLatencyMode` field; `rpcGetVideoLowLatencyMode` now derives state from `EdidString`, `rpcSetVideoLowLatencyMode` only writes `EdidString`. UI toggle is derived from `edid` state. Dropdown ↔ toggle can no longer drift. - Copilot ui/src/routes/.../video.tsx:30 — same root cause; same fix. - Copilot ui/src/routes/.../video.tsx:199 — drop the spurious `.toUpperCase()` on the matched-EDID value so SelectMenuBasic strict equality matches the option's actual `value`. - Copilot jsonrpc.go:276 + config.go:311 — case-insensitive EDID comparisons via `strings.EqualFold`. - Copilot scripts/edid_gen.py:13 — drop unused `import struct`. - Copilot scripts/edid_gen.py:63 — `pclk_khz` was holding Hz; rename to `pclk_hz` and use a `raw_pclk_hz` intermediate for the pre-quantized value. Generator output is byte-identical. - cursor[bot] LOW en.json:1041 "wrong advice when disabling" — split the single `video_low_latency_set_success` (which always told users to switch to 120 Hz) into `video_low_latency_enabled` and `video_low_latency_disabled`; the disabled message tells the user to switch their source back to its usual resolution. Also: small UX cleanup — extracted `applyEDID(...)` helper so the toggle and the dropdown don't double-fire success notifications. Verified locally: `go vet` clean, `tsc --noEmit` clean, `oxlint` 0 errors, `python3 -c "import py_compile; py_compile.compile(...)"` OK, and the regenerated EDID hex matches `LowLatency120HzEDID` byte for byte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IDisposable
left a comment
There was a problem hiding this comment.
Cool idea. I don't think we need to have the optional flag and all the behind-the-scenes flipping about. Just add the new resolutions+frame rate to the monitor EDID chooser :)
… dropdown Drops the experimental "Low Latency 120 Hz Mode" toggle and the LowLatency120HzEDID bundle (which packed both 848x480@120 and 1280x720@120 DTDs into a single EDID) in favor of four standalone single-mode EDIDs added directly to the existing EDID dropdown: - JetKVM 1280x720 @ 120 Hz (low latency) - JetKVM 1280x720 @ 60 Hz - JetKVM 848x480 @ 120 Hz (low latency) - JetKVM 848x480 @ 60 Hz Each EDID advertises exactly one DTD, the monitor range descriptor, and the model name — no CEA extension. Generated by scripts/edid_gen.py. Why the redesign The toggle was special-casing a single EDID bundle and trying to keep two pieces of state (the toggle and the EDID dropdown) in agreement. The reviewer flagged that the load-time reconciliation could revert explicit EDID-dropdown choices on reboot, and even the simpler derive-toggle-state-from-EdidString version was carrying: - a separate i18n string set for toggle-on / toggle-off, - a Checkbox plus an extra dropdown row labeling the same EDID, - special-cased applyEDID plumbing distinct from setEDID, - a config-load reconciliation path. Treating the 120 Hz modes as ordinary EDID choices removes all of that. Picking a 120 Hz EDID writes through setEDID like every other entry; the dropdown is the only source of truth. Bundling 480p120 and 720p120 into one EDID also forced the source PC to choose between two preferred modes. With separate EDIDs the source sees exactly one preferred timing per choice. Changes internal/native/video.go: add EDID720p120, EDID720p60, EDID480p120, EDID480p60. Drop LowLatency120HzEDID. jsonrpc.go: drop rpcGetVideoLowLatencyMode / rpcSetVideoLowLatencyMode and their handler registrations. Drop the now-unused native and strings imports. ui/src/routes/devices.\$id.settings.video.tsx: drop the Checkbox, the derived lowLatencyMode flag, the handleLowLatencyChange handler, the applyEDID helper, and the warning paragraph. Add four new entries to the EDID dropdown. ui/localization/messages/en.json: drop the five video_low_latency_* keys and the single-bundle video_edid_jetkvm_120hz key. Add four new per-mode keys; update video_edid_jetkvm_default to spell out the resolution so the dropdown is internally consistent. Verified locally: go vet clean on all packages, tsc --noEmit clean, oxlint 0 errors, EDID hex round-trips byte-for-byte through scripts/edid_gen.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 993e769. Configure here.
IDisposable
left a comment
There was a problem hiding this comment.
Much better!
The EDID should really be all-caps.
They don't validate, though. Use https://edidcraft.com/?tab=validator-tab
For example 720p120:
internal/native/video.go: the four EDID720p120 / EDID720p60 / EDID480p120 / EDID480p60 constants were never read — the dropdown carries the hex inline. Remove them; nothing else in the Go side referenced them. scripts/edid_gen.py: CVT-RB v1 specifies a fixed 3-line vertical front porch and an aspect-dependent vsync; the back porch absorbs the remainder of v_blank. The previous implementation had it backwards (back porch fixed at RB_V_BACK_PORCH, front porch = remainder), which produced a multi-hundred-line front porch and a 6-line back porch — technically a valid frame, but not CVT-RB v1. If the recomputed back porch falls below the spec minimum, bump v_blank so it does, and let v_total follow. ui/src/routes/devices.\$id.settings.video.tsx: regenerate the three affected EDID hex strings (720p120, 720p60, 480p120). 480p60 is unchanged because its v_blank already left front porch = 3 in the old code path. All four still lock end-to-end on TC358743 hardware. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/edid_gen.py:
- Feature-support byte (offset 24): 0x0A -> 0x0E. Adds the sRGB
color-space bit while keeping the existing flags (digital, RGB 4:4:4,
YCbCr 4:2:2, preferred timing in DTD0). EDID 1.4 already requires
DTD0 to be the preferred timing; tagging sRGB lets the source treat
the chromaticity block as authoritative instead of guessing.
- Established-timings byte (offset 35): 0x00 -> 0x20. Bit 5 advertises
640x480@60 as a VGA-fallback mode some sources fall back to during
early-boot / BIOS. The source still prefers DTD0 for the active
desktop, so this is harmless for the 120 Hz / 60 Hz advertised modes.
ui/src/routes/devices.\$id.settings.video.tsx: regenerate all four
dropdown EDIDs with the new flag bytes and recomputed checksums.
Strings are now uppercase to match the rest of the dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Ahah! It was just because I had a 60hz secondary display plugged in in mirroring mode; Linux doesn't like that (even when the slow display is not the primary monitor), so it was only spitting out 60 unique frames per second. The new 120hz EDID works end to end! It eventually settles on a 16ms jitter buffer! |
The MPP H.264/H.265 rate-control structure has Src/Dst FrameRateNum/Den fields the firmware never set, so MPP defaulted to fps_fix [30/1]. That made the encoder size its bitrate budget for 30 fps and behave unpredictably when 120 fps arrived from the capture chip. Now run_detect_format rounds the v4l2 dv-timings vrefresh to an integer (stored in detected_fps), and run_video_stream passes that value through venc_start -> populate_venc_attr where it's written into both Src and Dst FrameRate fields. GOP is sized to fps/2, which keeps the IDR cadence at ~0.5s for any source refresh — same WebRTC recovery latency at 60 Hz and 120 Hz. run_detect_format now also restarts the streaming pipeline when the rounded fps changes by more than ±1 fps, so an EDID swap that keeps resolution but changes refresh (e.g. 720p60 -> 720p120) actually reconfigures the encoder. The ±1 tolerance absorbs CVT-RB rounding (119.91 fps and 119.87 fps both round to 120). Verified on device: before: fps fix [30/1] -> fix [30/1] gop i [30] after: fps fix [120/1] -> fix [120/1] gop i [60] WebRTC inbound-rtp framesPerSecond now sustains ~120 with 0 dropped packets at 720p120, 19 ms jitter buffer, glass-to-glass delay halved on a 120 Hz panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the four single-mode 480p/720p × 60/120 EDIDs and replace them with one multi-mode JetKVM 720p EDID that advertises both 1280x720@120 (DTD0, preferred) and 1280x720@60 (DTD1). Drop 480p entirely — 848x480 is non-standard and the source PC's display panel UI usually doesn't expose it. The source picks 60 Hz vs 120 Hz via OS-side display settings (`xrandr --rate 60/120` on Linux, Display Settings on Windows) without needing to swap EDIDs. The encoder-fps plumbing from fa36843 already reconfigures MPP on each v4l2 source-change event, so rate swaps work end-to-end against this single EDID. Validated at edidcraft.com: 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The JetKVM v1 default EDID had two empty CTA-extension DTD slots after its existing data blocks (audio, YCbCr 4:2:2, vendor-specific). Use the first slot to advertise 1280x720@120 alongside the existing 1080p60 (DTD0, preferred) and 1280x720@60 (DTD1) base-block timings. Source picks rate via OS display settings (`xrandr --rate 120`, Windows Display Settings, etc.) — no separate "low latency" EDID needed in the dropdown. The encoder-fps plumbing from fa36843 already reconfigures MPP on every v4l2 source-change event, so OS-side rate swaps work end-to-end against this single combined EDID. Migration: config.LoadConfig now also rewrites EdidString to the new default when the user is currently on the previous JetKVM v1 EDID (without the 720p120 DTD), so existing devices auto-pick up the high-refresh option on next boot. Validated at edidcraft.com: 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Python EDID generator was only used during this PR's development phase to probe the TC358743 chip's max vrefresh and produce candidate EDIDs. Now that the only EDID change is one DTD added to the JetKVM default (a fully-validated 18-byte hex string in internal/native/video.go and ui/src/routes/devices.\$id.settings.video.tsx), the generator is no longer referenced by any code path. Drop it from the PR to keep the diff focused on the runtime change. Available in git history if anyone wants to extend the EDID set later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit fa36843. Empirically the MPP encoder forwards every input frame whether or not Src/DstFrameRateNum/Den are set in the rate control struct — those fields appear to only size the bitrate budget, not gate frame submission. With this code in, dmesg shows fps fix [120/1] -> fix [120/1] gop i [60]; with it reverted, dmesg shows fps fix [30/1] -> fix [30/1] gop i [30]; in both cases WebRTC inbound-rtp framesPerSecond sustains ~120 at the receiver. Drop the plumbing to keep the PR's surface area minimal — the EDID-side change alone is what unlocks 120 Hz end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NVIDIA's display driver enumerates base-block DTDs reliably but ignores DTDs in the CTA extension that don't carry a CTA-861 VIC. 1280x720@120 isn't a CTA VIC, so the previous layout (720p120 in CTA-extension first DTD slot) silently dropped the 120 Hz mode on GeForce hosts — `xrandr` listed 1080p60 / 720p60 / 640p variants only. Swap base-block DTD1 from 720p60 to 720p120. 720p60 stays advertised through the Standard Timings block (0x81C0 at offset 40), which every driver respects. Also drop the now-redundant 720p120 DTD from the CTA extension and add the prior CTA-only EDID to the migration list so existing devices auto-upgrade on next boot. Validated at edidcraft.com: 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rol" This reverts commit 3cb61df.
|
I added a video to the OP to demonstrate that it does indeed seem to work (though, my Wi-Fi is acting up I think, so it's dropping frames more frequently than I'd like...) |
feat(video): Add 120hz Support
2026-05-08.23-10-32.mp4
Summary
Two changes that together let the JetKVM stream 1280×720 @ 120 fps end-to-end on existing v1 hardware, without a separate "low latency" toggle or a separate EDID entry in the dropdown:
xrandr --rate 120, Windows Display Settings, etc.) — no EDID swap required.fps fix [30/1]because the firmware never setSrc/DstFrameRateNum/Den, so the H.264 bitrate budget was sized for 30 fps regardless of source rate. Empirically the encoder still forwards 120 input frames at the wire rate either way — but with rate control sized for 30, frame quality visibly degrades at 120 fps (perceptible juddering / smearing). Setting the rate-control fields to the actual source rate eliminates the choppiness. GOP also scales tofps/2, keeping the IDR cadence at ~0.5 s for any refresh.Glass-to-glass latency on the source-capture leg drops from ~16.7 ms (60 fps) to ~8.3 ms (120 fps) when the source PC is running at 120 Hz, with
inbound-rtp.framesPerSecond ≈ 120sustained on a clean LAN.Why DTD1 in the base block, not the CTA extension
NVIDIA's display driver enumerates base-block DTDs reliably but ignores DTDs in the CTA-861 extension that don't carry a CTA VIC. 1280x720@120 isn't a CTA VIC, so an earlier iteration that put 720p120 in the CTA extension's first DTD slot silently dropped the 120 Hz mode on GeForce hosts —
xrandronly listed 1080p60 / 720p60 / 640p variants, with no 119.91 Hz row.Solution: put 720p120 in the base block (DTD1) where every driver respects it. The displaced 720p60 mode survives via the existing Standard Timings entry
0x81C0at offset 40 of the base block, which is read by every modern driver.Why 120 Hz is the ceiling on JetKVM v1
The capture chip is the Toshiba TC358743XBG (vendor-modified
tc35874xdriver in the BSP). Its datasheet specifies "Video Formats Support (Up to 1080P @60fps)" and characterizes the chip only at 60 Hz. The kerneldv_timings_capis permissive (0–310 MHz, CTA-861/DMT/CVT/GTF/CUSTOM), but the chip's silicon — specifically the blocks above the TMDS PHY — doesn't reliably lock above ~120 Hz vrefresh.Bench results on real hardware:
All failed modes are well under the chip's 165 MHz TMDS ceiling, so this is not a PHY budget limit — it's the chip's downstream logic refusing to lock above 120 Hz. Exceeding 120 Hz on JetKVM hardware would require an HDMI 2.0-class capture chip (Lontium LT6911UXC, ITE IT6802, RK628F).
Files changed
internal/native/video.goDefaultEDIDupdated. Base block: DTD0 = 1080p60 (preferred, unchanged), DTD1 = 1280x720@120 (was 720p60), DTD2 = range descriptor, DTD3 = name "JetKVM v1". Standard Timings byte at offset 40 keeps 1280x720@60 advertised. Audio data block, YCbCr 4:2:2, vendor-specific data block in CTA extension all preserved.internal/native/cgo/video.cdetected_fpsglobal tracked from theVIDIOC_QUERY_DV_TIMINGSevent handler.populate_venc_attrandvenc_startnow take afpsparameter and write it into bothu32SrcFrameRateNum/Denandfr32DstFrameRateNum/Denof the H.264/H.265 VBR rate control struct. GOP scales tofps/2. The format-detection thread also restarts the streaming pipeline when the rounded fps changes by more than ±1 fps (so a same-resolution rate change like 720p60 → 720p120 actually reconfigures the encoder).config.goLoadConfigmigration extended with two new triggers: the previous JetKVM v1 EDID (1080p60 / 720p60 only) and the intermediate JetKVM v1 EDID with 720p120 in the CTA extension only. Both auto-rewrite tonative.DefaultEDIDso existing devices pick up the new layout on next boot. Comparisons usestrings.EqualFoldfor case-insensitive match.ui/src/routes/devices.$id.settings.video.tsxEDID validates clean (0 errors / 0 warnings) at edidcraft.com.
Test plan
EdidStringmatching either of the two prior JetKVM defaults get auto-migrated to the new combined EDID; devices with a custom user EDID are left alone.xrandr --output HDMI-0 --off && xrandr --output HDMI-0 --auto. Confirmxrandr | grep -A 6 HDMI-0lists1280x720 119.91.xrandr --output HDMI-0 --mode 1280x720 --rate 120(or Windows Display Settings → 120 Hz). Confirmv4l2-ctl -d /dev/v4l-subdev2 --query-dv-timingsreports1280×720, 120.00 frames per second.dmesg | grep "fps fix"showsfps fix [120/1] -> fix [120/1] gop i [60]— the encoder reconfigured to the 120 fps source.inbound-rtpstats:framesPerSecond: 120sustained, zeronackCount/pliCount. On a 120 Hz monitor, motion should appear smooth (no judder).fps fix [60/1], stream stays continuous.Source-side note
Picking the JetKVM Default EDID does not switch the source PC's display mode. The EDID just tells the source which modes the JetKVM is willing to accept. The user must manually pick
1280x720@120(or@60, or stay at1920x1080@60) in their OS display settings:xrandr --output <port> --mode 1280x720 --rate 120After source-side mode change, NVIDIA's proprietary driver caches EDIDs aggressively. On the source PC, the
xrandr --output HDMI-0 --off && xrandr --output HDMI-0 --autocycle forces a fresh DDC re-read if the new modes don't appear immediately.Future work (out of scope)
🤖 Generated with Claude Code
Note
Medium Risk
Touches the native video capture/encode pipeline and EDID migration logic; incorrect fps/EDID handling could cause streaming restarts, degraded quality, or source display-mode regressions.
Overview
Enables 1280x720@120 as part of the default JetKVM EDID (base-block DTD) and updates the UI’s default EDID to match, improving high-refresh compatibility (notably on NVIDIA hosts).
Plumbs detected source refresh rate into the Rockchip VENC rate-control configuration (including GOP scaling) and restarts streaming when fps changes, so bitrate/quality and IDR cadence track 60 Hz vs 120 Hz sources.
Extends config migration to auto-rewrite older built-in EDIDs (including prior JetKVM defaults and the Toshiba chip default) to the new
native.DefaultEDIDusing case-insensitive matching.Reviewed by Cursor Bugbot for commit 53662f3. Bugbot is set up for automated code reviews on this repo. Configure here.