Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Performance
- **Sharpen on idle.** On a slow link the encoder runs below the display's resolution, and a
still screen stayed soft however long it sat there: no refinement pass restores pixels that were
never encoded. Once the screen has been still for 500 ms the host now moves the encoder up to
the display's native size for one full-resolution keyframe plus two refinement passes, and drops
back to the link's size when the screen moves again (four captures within 300 ms, so a blinking
cursor does not undo it; 2 s cooldown before sharpening again). Skipped when the keyframe would
take over 1.2 s to drain at the current bitrate. ([`host::refine`](crates/renderd-host/src/refine.rs),
[`EncodePipeline::sharpen`](crates/renderd-host/src/encode.rs))

### Fixed
- **Software renderer used the wrong colour maths.** The Windows software path (and the fallback
when the D3D11 presenter cannot start) converted NV12 as full-range BT.601, but the stream is
BT.709 limited range. Every black came out grey (16 instead of 0), every white dim (235 instead
of 255) and hues shifted, which reads as washed-out text. It now converts BT.709 limited range,
matching the D3D11 presenter.
- **Capture colour space is pinned.** `ScreenCaptureKit` is asked for sRGB with the BT.709 matrix
instead of inheriting the display's colour space, so a wide-gamut Mac cannot send Display P3
values the viewer reads as sRGB. The capture's colour tags are logged for the first frames, and
`cargo run -p renderd-sc-sys --example colour_probe` prints them.

---

## [1.0.0] — 2026-10-05
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 9 additions & 6 deletions crates/renderd-host/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ impl CapturePipeline {
"Capture target selected"
);

let refiner = Arc::new(crate::refine::StaticRefiner::start(Arc::clone(
&encode_pipeline,
)));
let refiner = Arc::new(crate::refine::StaticRefiner::start(
Arc::clone(&encode_pipeline),
(width, height),
));
let refiner_ref = Arc::clone(&refiner);
let pipeline_ref = encode_pipeline;

Expand All @@ -147,14 +148,16 @@ impl CapturePipeline {
"CapturePipeline: forwarding first captured frame to VideoToolbox encoder"
);
}
// Whether it is encoded or skipped for a deep queue, this is
// now the screen: the refiner makes sure the viewer ends up
// with it, and, before the frame is encoded, takes the encoder
// back to the link's size if a sharpened still has ended.
refiner_ref.on_capture(&frame.surface, frame.pts_ns);
if let Err(e) = pipeline_ref.encode_surface(&frame.surface, frame.pts_ns) {
if count % 300 == 1 {
tracing::warn!("encode_surface failed: {e}");
}
}
// Encoded or skipped for a deep queue, this is now the
// screen; the refiner makes sure the viewer ends up with it.
refiner_ref.on_capture(&frame.surface, frame.pts_ns);
},
)
.map_err(|e| HostError::Initialization(format!("ScreenStream creation failed: {e}")))?;
Expand Down
99 changes: 98 additions & 1 deletion crates/renderd-host/src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ pub struct EncodePipeline {
/// Presentation timestamp of the last surface submitted, in nanoseconds.
last_pts_ns: AtomicI64,
params: std::sync::Mutex<Option<EncoderParams>>,
/// The size the link calls for, while the encoder is temporarily running at
/// a larger one to sharpen a still screen (see [`EncodePipeline::sharpen`]).
base_size: std::sync::Mutex<Option<(u32, u32)>>,
#[cfg(target_os = "macos")]
session: std::sync::Mutex<Option<renderd_vt_sys::CompressionSession>>,
}
Expand Down Expand Up @@ -262,6 +265,7 @@ impl EncodePipeline {
link: Arc::new(LinkPressure::new()),
last_pts_ns: AtomicI64::new(i64::MIN),
params: std::sync::Mutex::new(None),
base_size: std::sync::Mutex::new(None),
#[cfg(target_os = "macos")]
session: std::sync::Mutex::new(None),
}
Expand Down Expand Up @@ -359,6 +363,70 @@ impl EncodePipeline {
/// configured or the new session cannot be created; the old one keeps
/// running in that case.
pub fn reconfigure(&self, width: u32, height: u32) -> Result<bool, HostError> {
// A size the link asked for replaces whatever sharpening was in use.
if let Ok(mut base) = self.base_size.lock() {
*base = None;
}
self.resize(width, height, "Encoder resized to follow the link")
}

/// Runs the encoder at `native` until [`restore_size`](Self::restore_size),
/// remembering the size the link called for. Returns `false` if it already
/// runs at that size.
///
/// The viewer follows the stream's size, so this is how a screen that has
/// stopped changing gets the full-resolution picture the link could not
/// afford while it was moving.
///
/// # Errors
///
/// As [`reconfigure`](Self::reconfigure).
pub fn sharpen(&self, native: (u32, u32)) -> Result<bool, HostError> {
let current = self
.encoded_size()
.ok_or_else(|| HostError::Initialization("encoder was never configured".into()))?;
if current == native || self.is_sharpened() {
return Ok(false);
}
if let Ok(mut base) = self.base_size.lock() {
*base = Some(current);
}
match self.resize(native.0, native.1, "Encoder sharpened for a still screen") {
Ok(changed) => Ok(changed),
Err(e) => {
if let Ok(mut base) = self.base_size.lock() {
*base = None;
}
Err(e)
}
}
}

/// Whether the encoder is running at a sharpened size.
#[must_use]
pub fn is_sharpened(&self) -> bool {
self.base_size.lock().is_ok_and(|b| b.is_some())
}

/// Returns to the size the link calls for after [`sharpen`](Self::sharpen).
/// Returns `false` if the encoder was not sharpened.
///
/// # Errors
///
/// As [`reconfigure`](Self::reconfigure); the sharpened size stays in use
/// if the new session cannot be created.
pub fn restore_size(&self) -> Result<bool, HostError> {
let Some(base) = self.base_size.lock().ok().and_then(|b| *b) else {
return Ok(false);
};
let changed = self.resize(base.0, base.1, "Encoder back to the link's size")?;
if let Ok(mut b) = self.base_size.lock() {
*b = None;
}
Ok(changed)
}

fn resize(&self, width: u32, height: u32, why: &str) -> Result<bool, HostError> {
let mut params = self
.params
.lock()
Expand All @@ -372,7 +440,7 @@ impl EncodePipeline {
params.height = height;
self.keyframes.reset();
self.start_session(&params, self.current_bitrate().max(1))?;
tracing::info!(width, height, "Encoder resized to follow the link");
tracing::info!(width, height, "{why}");
if let Ok(mut guard) = self.params.lock() {
*guard = Some(params);
}
Expand Down Expand Up @@ -795,6 +863,35 @@ mod tests {
assert_eq!(pipeline.encoded_size(), None);
}

#[test]
fn test_sharpen_runs_at_native_and_restores_the_links_size() {
let pipeline = EncodePipeline::new();
assert!(pipeline.sharpen((1920, 1080)).is_err());
assert!(!pipeline.restore_size().unwrap());
if pipeline.init(960, 544, 4_000, "hevc", 60).is_err() {
return; // no hardware encoder here
}
assert!(pipeline.sharpen((1920, 1080)).unwrap());
assert!(pipeline.is_sharpened());
assert_eq!(pipeline.encoded_size(), Some((1920, 1080)));
assert!(
!pipeline.sharpen((1920, 1080)).unwrap(),
"already sharpened"
);

assert!(pipeline.restore_size().unwrap());
assert!(!pipeline.is_sharpened());
assert_eq!(pipeline.encoded_size(), Some((960, 544)));

// A size the link asks for wins over a sharpened one.
assert!(pipeline.sharpen((1920, 1080)).unwrap());
assert!(pipeline.reconfigure(1280, 720).unwrap());
assert!(!pipeline.is_sharpened());
assert!(!pipeline.restore_size().unwrap());
assert_eq!(pipeline.encoded_size(), Some((1280, 720)));
pipeline.shutdown();
}

#[test]
fn test_monotonic_pts_never_goes_backwards() {
let pipeline = EncodePipeline::new();
Expand Down
Loading
Loading