Skip to content

feat(dgw): drop unchanged frames from finished recordings - #2060

Merged
irvingouj@Devolutions (irvingoujAtDevolution) merged 6 commits into
feat/recording-dedup-cadeau-0.5.4from
feat/recording-dedup
Nov 9, 2026
Merged

irvingouj@Devolutions (irvingoujAtDevolution) merged 6 commits into
feat/recording-dedup-cadeau-0.5.4from
feat/recording-dedup

Conversation

@irvingoujAtDevolution

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixed-rate recordings (web sessions at 1 fps) keep growing at the bitrate target even when nothing on screen changes. After a WebM recording is remuxed, Gateway now re-encodes it as VP8 at a fixed quantizer of 30, keeping only the frames whose picture changed, and swaps the file in only when the result is strictly smaller.

On 8 lab recordings (RDP and web, busy and idle, VP8 and VP9 sources) Gateway stores 68.0 MB of 116.0 MB, with no visible change lost. Idle recordings drop to 3–8 % of their size and busy web ones to about 70 %. A busy RDP recording either shrinks a little or comes out larger, and then the original stays.

The encoding costs about 3 CPU seconds per recorded minute on one thread. VP9 at a matching fixed quantizer saved about 6.6 MB more for three times the CPU, which a 2-vCPU Gateway can't spare beside its live sessions.

The rules live in crates/video-streamer/src/compact.intent.md and the new Compaction section of devolutions-gateway/src/recording.intent.md.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Let maintainers know that an action is required on their side

  • Add the label release-required Please cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module) when you request a maintainer to cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module)

  • Add the label release-blocker Follow-up is required before cutting a new release if a follow-up is required before cutting a new release

  • Add the label publish-required Please publish libraries (`Devolutions.Gateway.Utils`, OpenAPI clients, etc) when you request a maintainer to publish libraries (Devolutions.Gateway.Utils, OpenAPI clients, etc.)

  • Add the label publish-blocker Follow-up is required before publishing libraries if a follow-up is required before publishing libraries

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Implementation notes:

  • video_streamer::compact_webm (compact.rs) decodes every frame, diffs full-res luma in 8x8 blocks against the last kept picture (any block with mean |dY| > 12 counts), and re-encodes kept frames at their original timestamps in the input codec with max_quantizer(30). First and last frames are always kept, so duration doesn't change.
  • No RDP/web gate. It bails as soon as the output is bigger than the input file, or bigger than the input consumed so far after 60 s of media. Gateway remuxes the result and only replaces the recording when it's strictly smaller. Resolution change inside a file, multiple tracks, or a non-1 ms timescale: skipped, original kept.
  • Runs inside RemuxJob right after the existing remux, so no new job type. Failures just log a warning.
  • Lab numbers (16 recordings): web idle 0.08x, web active 0.50x, every RDP file skipped after 0.1-0.4 CPU-s. Outputs decode clean in ffmpeg and duration matches to the ms.
  • examples/compact.rs is the driver I used for those numbers.

Draft because it needs a cadeau release: cadeau#87 (quantizer range) is merged but not published, so this patches cadeau to git e56c47c for now. To land: merge cadeau#88, cut a native release, then bump the crates and ci/download-cadeau.ps1 (still on v2026.8.21.0). XMF is loaded with dlopen and every symbol is resolved at init, so the crate and the native lib have to move together.

Note

Human-tuned, LLM-assisted content.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An output path that aliases the input can truncate the source recording before it is read, creating a critical data-loss risk.

5 open issues
What changed in this PR

This PR adds post-recording WebM compaction to Gateway, using video-streamer to reduce storage for fixed-rate recordings with unchanged pictures.

Changes:

  • Re-encodes retained frames as VP8 at a fixed quantizer of 30.
  • Runs compaction after remuxing and replaces the recording only when the final output is smaller.
  • Adds compaction tests, an example tool, XMF CI coverage, and updated native dependencies.
File Description
testsuite/​tests/​video_streamer/​mod.rs Registers compaction integration tests.
testsuite/​tests/​video_streamer/​compact.rs Tests frame retention, timestamps, size savings, and empty inputs.
testsuite/​tests/​main.rs Registers video-streamer tests.
testsuite/​Cargo.toml Adds dependencies for compaction tests.
devolutions-gateway/​src/​recording.rs Integrates compaction, size-checked replacement, and temporary-file cleanup.
devolutions-gateway/​src/​recording.intent.md Documents remuxing and replacement guarantees.
crates/​video-streamer/​src/​lib.rs Exports the compaction API.
crates/​video-streamer/​src/​compact.rs Implements frame comparison, VP8 re-encoding, and output generation.
crates/​video-streamer/​src/​compact.intent.md Defines frame-retention and encoding decisions.
crates/​video-streamer/​examples/​compact.rs Adds a command-line compaction example.
ci/​download-cadeau.ps1 Updates the native Cadeau release.
Cargo.toml Pins Cadeau patches supporting quantizer controls.
Cargo.lock Records patched dependencies and test additions.
.github/​workflows/​ci.yml Runs XMF-backed tests on Windows and Linux.

Review effort: Balanced

Comment thread crates/video-streamer/src/compact.rs Resolved
Comment thread crates/video-streamer/src/compact.rs Resolved
Comment thread crates/video-streamer/src/compact.rs Resolved
Comment thread crates/video-streamer/src/compact.rs Resolved
Comment thread devolutions-gateway/src/recording.rs Resolved
@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Before/after numbers from the lab experiment, per scenario. Compaction only replaces a recording when the result is strictly smaller; otherwise the (remuxed) original stays untouched, as in rdp-heavy-vp8 below.

Scenario Source Before Compacted (VP8, Q30) Frames kept Replaced? Stored after CPU (1 thread) VMAF vs source
RDP idle, VP8 1280x800, 3.1 min 5.97 MB 0.23 MB (4 %) 32/1471 yes 0.23 MB 2.2 s 94.3
RDP idle, VP9 1536x961, 5.0 min 20.33 MB 1.08 MB (5 %) 320/2410 yes 1.08 MB 9.0 s 95.9
RDP busy, VP8 1280x800, 3.7 min 27.04 MB 31.15 MB (115 %) 1769/1781 no, original kept 27.04 MB 31.3 s 99.1
RDP busy, VP9 1536x960, 4.0 min 19.50 MB 16.76 MB (86 %) 1691/1824 yes 16.76 MB 23.0 s 97.8
Web idle, VP8 1024x768, 4.0 min 7.36 MB 0.65 MB (9 %) 25/1920 yes 0.65 MB 2.9 s 88.9
Web idle, VP9 1024x768, 4.0 min 5.29 MB 0.23 MB (4 %) 6/1919 yes 0.23 MB 2.0 s 89.7
Web busy, VP8 1024x768, 3.0 min 15.03 MB 11.12 MB (74 %) 1396/1440 yes 11.12 MB 11.8 s 100.0
Web busy, VP9 1024x768, 3.0 min 15.47 MB 10.85 MB (70 %) 1391/1440 yes 10.85 MB 13.7 s 100.0
Total 116.0 MB 68.0 MB
  • CPU is the median of 3 runs. VMAF compares every source frame with what the compacted file shows at that time, so on idle recordings it also counts the moments between kept frames.
  • End to end through Gateway (push, remux, compaction, pull): web idle VP8 7.36 MB → 0.65 MB and RDP idle VP9 20.33 MB → 1.08 MB, same duration (239.877 s and 301.388 s), fully decodable and seekable. Played back after replacement in the web player and the RDM player (seek to middle, seek to end, scripted seek): 8/8 passed over two runs on a VM.

Note

LLM-assisted content (no human feedback).

Fixed-rate recordings (web sessions at 1 fps) keep growing at the
bitrate target even when nothing on screen changes. After a WebM
recording is remuxed, Gateway now re-encodes it keeping only the
frames whose picture changed, with a quantizer cap so text stays
readable, and swaps the file in only when the result is smaller.

On lab web recordings that's about -92% idle and -50% active. RDP
recordings are already change-driven, so the job gives up early and
keeps them as is.
The oracle-markers check requires every new test to name the source
of its expected value. No intent or spec states the dedup rules yet,
so each test is marked with the fault it catches; each fault was
applied by hand and the test failed against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compaction encoded at the source's average bitrate in the source's
codec. On busy recordings that spends bytes above the quality the
quantizer cap already guarantees, so the output often came out larger
than the original and was thrown away.

Kept frames are now encoded as VP8 with the quantizer pinned at 30 and
a bitrate high enough that rate control never moves it. On 8 lab
recordings (RDP and web, busy and idle, VP8 and VP9 sources) Gateway
stores 68.0 MB instead of 84.8 MB out of 116.0 MB, for about the same
CPU time and with no visible change lost. VP9 at a fixed quantizer of
25 saves 6.6 MB more for about three times the CPU time.

The intent files record the term, the changed-frame rule, the encoding
and the remux-then-replace rule, and the compaction tests now cite them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md puts new integration tests in the testsuite crate, so the
three compaction tests move there with the few helpers they need.

The two markers that cited intent go back to the fault each test
catches: the early not-smaller skip inside compact_webm and the
"output is less than half" check are not decided by an intent line.

Also follows rust-style: comparisons read with < and <=, the block
pixel count states its invariant, and a compacted recording logs at
debug like the remux next to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picture size in the WebM header comes from the client and decides
two buffers before any frame is decoded, so a recording above 8192
pixels on a side is now skipped as unsupported instead of allocating
whatever it declares.

The compaction tests skipped themselves when XMF was missing, and CI
never provides it to the tests job, so they passed without running.
They are now ignored by default, fail loudly without XMF, and a new
CI step downloads XMF and runs them on Windows and Linux.

Also documents the public compaction types and fixes the example's
SAFETY comment and float cast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compact_webm now refuses an output path that names the input recording,
because creating the output would truncate the recording it reads.

When the last frame decodes to no picture (a hidden VP9 frame), there
is no picture to keep it with, so the recording is left as it is
instead of losing its tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) merged commit 9036cf1 into master Nov 9, 2026
53 checks passed
irvingouj@Devolutions (irvingoujAtDevolution) added a commit that referenced this pull request Nov 9, 2026
Moves Gateway to the published cadeau 0.5.4 / xmf-sys 0.4.3
([cadeau#88](Devolutions/cadeau#88)) and pins
the XMF native library to `v2026.10.26.0`.

The new crates carry the VPX encoder quantizer range
(`XmfVpxEncoder_CreateEx`), which recording compaction in #2060 needs.
XMF resolves every symbol at load, so the native library and the crates
have to move together.

#2060 stacks on top of this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants