Skip to content
3 changes: 2 additions & 1 deletion crates/recording/src/capture_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ impl MakeCapturePipeline for screen_capture::Direct3DCapture {
}
} else {
let d3d_device = screen_capture.d3d_device.clone();
let frame_rate = screen_capture.capture_frame_rate();
let bitrate_multiplier = if ultra { 0.3f32 } else { 0.15f32 };

OutputPipeline::builder(output_path.clone())
Expand All @@ -335,7 +336,7 @@ impl MakeCapturePipeline for screen_capture::Direct3DCapture {
pixel_format: screen_capture::Direct3DCapture::PIXEL_FORMAT.as_dxgi(),
d3d_device,
bitrate_multiplier,
frame_rate: 30u32,
frame_rate,
output_size: output_size.map(|(w, h)| windows::Graphics::SizeInt32 {
Width: w as i32,
Height: h as i32,
Expand Down
87 changes: 24 additions & 63 deletions crates/recording/src/output_pipeline/win.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1334,19 +1334,6 @@ fn convert_uyvy_to_yuyv_scalar(src: &[u8], dst: &mut [u8], len: usize) {
}
}

fn convert_uyvy_to_yuyv(
src: &[u8],
width: u32,
height: u32,
src_stride: usize,
flip: bool,
) -> Vec<u8> {
let total_bytes = (width * height * 2) as usize;
let mut dst = vec![0u8; total_bytes];
convert_uyvy_to_yuyv_into(src, &mut dst, width, height, src_stride, flip);
dst
}

pub(crate) fn camera_frame_to_ffmpeg(
frame: &NativeCameraFrame,
mjpeg_decoder: &mut MjpegDecoder,
Expand Down Expand Up @@ -1393,37 +1380,19 @@ pub(crate) fn camera_frame_to_ffmpeg(
)
})?;

let converted_data_storage;
let (final_data, final_format): (&[u8], ffmpeg::format::Pixel) =
if frame.pixel_format == cap_camera_windows::PixelFormat::UYVY422 {
converted_data_storage = convert_uyvy_to_yuyv(
data,
frame.width,
frame.height,
layout.primary_stride,
frame.is_bottom_up,
);
(
converted_data_storage.as_slice(),
ffmpeg::format::Pixel::YUYV422,
)
} else {
(data, ffmpeg_format)
};

let mut ffmpeg_frame = ffmpeg::frame::Video::new(final_format, frame.width, frame.height);
let flip = frame.is_bottom_up && frame.pixel_format != cap_camera_windows::PixelFormat::UYVY422;
let mut ffmpeg_frame = ffmpeg::frame::Video::new(ffmpeg_format, frame.width, frame.height);

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.

P1 Camera frames fail conversion

camera_frame_to_ffmpeg now returns UYVY422 frames, but WindowsFragmentedM4SCameraMuxer::normalize_video_config_for_frame still configures UYVY cameras as YUYV422. The encoder builds its scaler from that configured format. The AVX2 converter hides the mismatch for same-size frames, but resizing or running without AVX2 falls through to a scaler that expects the wrong input, causing conversion to fail. Update the fragmented camera configuration to match the returned frame format.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/win.rs
Line: 1299

Comment:
**Camera frames fail conversion**

`camera_frame_to_ffmpeg` now returns `UYVY422` frames, but `WindowsFragmentedM4SCameraMuxer::normalize_video_config_for_frame` still configures UYVY cameras as `YUYV422`. The encoder builds its scaler from that configured format. The AVX2 converter hides the mismatch for same-size frames, but resizing or running without AVX2 falls through to a scaler that expects the wrong input, causing conversion to fail. Update the fragmented camera configuration to match the returned frame format.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

let flip = frame.is_bottom_up;

match frame.pixel_format {
cap_camera_windows::PixelFormat::NV12 => {
let uv_height = height / 2;
let y_size = layout.primary_stride * height;
let uv_size = layout.secondary_stride * uv_height;
if final_data.len() >= y_size + uv_size {
if data.len() >= y_size + uv_size {
let stride_y = ffmpeg_frame.stride(0);
let stride_uv = ffmpeg_frame.stride(1);
copy_plane(
&final_data[..y_size],
&data[..y_size],
ffmpeg_frame.data_mut(0),
width,
height,
Expand All @@ -1432,7 +1401,7 @@ pub(crate) fn camera_frame_to_ffmpeg(
flip,
);
copy_plane(
&final_data[y_size..y_size + uv_size],
&data[y_size..y_size + uv_size],
ffmpeg_frame.data_mut(1),
width,
uv_height,
Expand All @@ -1446,19 +1415,19 @@ pub(crate) fn camera_frame_to_ffmpeg(
let uv_height = height / 2;
let y_size = layout.primary_stride * height;
let uv_size = layout.secondary_stride * uv_height;
if final_data.len() >= y_size + uv_size {
if data.len() >= y_size + uv_size {
let stride_y = ffmpeg_frame.stride(0);
let stride_uv = ffmpeg_frame.stride(1);
copy_plane(
&final_data[..y_size],
&data[..y_size],
ffmpeg_frame.data_mut(0),
width,
height,
layout.primary_stride,
stride_y,
flip,
);
let uv_data = &final_data[y_size..y_size + uv_size];
let uv_data = &data[y_size..y_size + uv_size];
let dest = ffmpeg_frame.data_mut(1);
for row in 0..uv_height {
let src_row = if flip { uv_height - 1 - row } else { row };
Expand All @@ -1475,23 +1444,15 @@ pub(crate) fn camera_frame_to_ffmpeg(
}
cap_camera_windows::PixelFormat::YUYV422 | cap_camera_windows::PixelFormat::UYVY422 => {
let row_bytes = width * 2;
let size = if frame.pixel_format == cap_camera_windows::PixelFormat::UYVY422 {
row_bytes * height
} else {
layout.primary_stride * height
};
if final_data.len() >= size {
let size = layout.primary_stride * height;
if data.len() >= size {
let stride = ffmpeg_frame.stride(0);
copy_plane(
final_data,
data,

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.

P1 Padded camera rows are corrupted

camera_frame_to_ffmpeg now copies the original UYVY buffer, but its UYVY branch still uses width * 2 as the source stride. That was correct only after the removed conversion packed the rows. If a webcam adds padding between rows, each row after the first starts at the wrong byte, corrupting the recorded image. Use layout.primary_stride for the source stride and size check.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/win.rs
Line: 1371

Comment:
**Padded camera rows are corrupted**

`camera_frame_to_ffmpeg` now copies the original UYVY buffer, but its UYVY branch still uses `width * 2` as the source stride. That was correct only after the removed conversion packed the rows. If a webcam adds padding between rows, each row after the first starts at the wrong byte, corrupting the recorded image. Use `layout.primary_stride` for the source stride and size check.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

ffmpeg_frame.data_mut(0),
row_bytes,
height,
if frame.pixel_format == cap_camera_windows::PixelFormat::UYVY422 {
row_bytes
} else {
layout.primary_stride
},
layout.primary_stride,
stride,
flip,
);
Expand All @@ -1500,10 +1461,10 @@ pub(crate) fn camera_frame_to_ffmpeg(
cap_camera_windows::PixelFormat::ARGB | cap_camera_windows::PixelFormat::RGB32 => {
let row_bytes = width * 4;
let size = layout.primary_stride * height;
if final_data.len() >= size {
if data.len() >= size {
let stride = ffmpeg_frame.stride(0);
copy_plane(
final_data,
data,
ffmpeg_frame.data_mut(0),
row_bytes,
height,
Expand All @@ -1516,10 +1477,10 @@ pub(crate) fn camera_frame_to_ffmpeg(
cap_camera_windows::PixelFormat::RGB24 | cap_camera_windows::PixelFormat::BGR24 => {
let row_bytes = width * 3;
let size = layout.primary_stride * height;
if final_data.len() >= size {
if data.len() >= size {
let stride = ffmpeg_frame.stride(0);
copy_plane(
final_data,
data,
ffmpeg_frame.data_mut(0),
row_bytes,
height,
Expand All @@ -1533,12 +1494,12 @@ pub(crate) fn camera_frame_to_ffmpeg(
let uv_height = height / 2;
let y_size = layout.primary_stride * height;
let uv_size = layout.secondary_stride * uv_height;
if final_data.len() >= y_size + uv_size * 2 {
if data.len() >= y_size + uv_size * 2 {
let stride_y = ffmpeg_frame.stride(0);
let stride_u = ffmpeg_frame.stride(1);
let stride_v = ffmpeg_frame.stride(2);
copy_plane(
&final_data[..y_size],
&data[..y_size],
ffmpeg_frame.data_mut(0),
width,
height,
Expand All @@ -1547,7 +1508,7 @@ pub(crate) fn camera_frame_to_ffmpeg(
flip,
);
copy_plane(
&final_data[y_size..y_size + uv_size],
&data[y_size..y_size + uv_size],
ffmpeg_frame.data_mut(1),
width / 2,
uv_height,
Expand All @@ -1556,7 +1517,7 @@ pub(crate) fn camera_frame_to_ffmpeg(
flip,
);
copy_plane(
&final_data[y_size + uv_size..y_size + uv_size * 2],
&data[y_size + uv_size..y_size + uv_size * 2],
ffmpeg_frame.data_mut(2),
width / 2,
uv_height,
Expand All @@ -1570,12 +1531,12 @@ pub(crate) fn camera_frame_to_ffmpeg(
let uv_height = height / 2;
let y_size = layout.primary_stride * height;
let uv_size = layout.secondary_stride * uv_height;
if final_data.len() >= y_size + uv_size * 2 {
if data.len() >= y_size + uv_size * 2 {
let stride_y = ffmpeg_frame.stride(0);
let stride_u = ffmpeg_frame.stride(1);
let stride_v = ffmpeg_frame.stride(2);
copy_plane(
&final_data[..y_size],
&data[..y_size],
ffmpeg_frame.data_mut(0),
width,
height,
Expand All @@ -1584,7 +1545,7 @@ pub(crate) fn camera_frame_to_ffmpeg(
flip,
);
copy_plane(
&final_data[y_size + uv_size..y_size + uv_size * 2],
&data[y_size + uv_size..y_size + uv_size * 2],
ffmpeg_frame.data_mut(1),
width / 2,
uv_height,
Expand All @@ -1593,7 +1554,7 @@ pub(crate) fn camera_frame_to_ffmpeg(
flip,
);
copy_plane(
&final_data[y_size..y_size + uv_size],
&data[y_size..y_size + uv_size],
ffmpeg_frame.data_mut(2),
width / 2,
uv_height,
Expand Down
2 changes: 1 addition & 1 deletion crates/recording/src/output_pipeline/win_fragmented_m4s.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ impl WindowsFragmentedM4SCameraMuxer {
let mut updated = false;

let desired_pixel_format = match frame.pixel_format {
cap_camera_windows::PixelFormat::UYVY422 => Pixel::YUYV422,
cap_camera_windows::PixelFormat::UYVY422 => Pixel::UYVY422,
_ => self.video_config.pixel_format,
};

Expand Down
7 changes: 7 additions & 0 deletions crates/recording/src/sources/screen_capture/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,13 @@ pub struct VideoSourceConfig {
settings: scap_direct3d::Settings,
pub d3d_device: ID3D11Device,
}

impl VideoSourceConfig {
pub(crate) fn capture_frame_rate(&self) -> u32 {
self.video_info.fps().max(1)
}
}

pub struct VideoSource {
video_info: VideoInfo,
ctrl_tx: std::sync::mpsc::SyncSender<VideoControl>,
Expand Down
2 changes: 1 addition & 1 deletion crates/scap-direct3d/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ impl Capturer {

let frame_pool_size = settings
.fps
.map(|fps| ((fps as f32 / 30.0 * 2.0).ceil() as i32).clamp(2, 4))
.map(|fps| ((fps as f32 / 30.0 * 2.0).ceil() as i32).clamp(4, 6))
.unwrap_or(2);

let item_size = item.Size().map_err(NewCapturerError::ItemSize)?;
Expand Down
6 changes: 5 additions & 1 deletion crates/verify/tests/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,11 @@ fn video_code_survives_compositing_scaling_rounding_bubble_and_blur() {
let dir = tempfile::tempdir().unwrap();
// 640x360 is a typical verification output; 320x180 puts the screen at
// a quarter of its source size and the camera code cells at ~6 px.
for (out_w, out_h, bitrate) in [(640, 360, 150_000), (320, 180, 80_000)] {
// 320x180 is encoded at the Web export preset (0.08 bpp): at 80 kbps the
// paravirtualised VideoToolbox HEVC encoder on the CI macOS VMs
// occasionally left a blurred camera cell at margin 0.17, below the
// reader's 0.18, while this host's hardware encoder read it at 0.31.
for (out_w, out_h, bitrate) in [(640, 360, 150_000), (320, 180, 138_240)] {
for blur in [false, true] {
let pairs: Vec<(u32, u32)> = (0..40).map(|i| (i * 3 + 100, i / 2 + 7)).collect();
let frames: Vec<Rgba> = pairs
Expand Down
8 changes: 4 additions & 4 deletions crates/video-decode/src/media_foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,11 +775,11 @@ impl MediaFoundationDecoder {
let uv_size = (y_stride * uv_height) as usize;
let total_size = y_size + uv_size;

let mut data = vec![0u8; total_size];
unsafe {
std::ptr::copy_nonoverlapping(mapped.pData as *const u8, data.as_mut_ptr(), total_size);
let data = unsafe {
let data = std::slice::from_raw_parts(mapped.pData as *const u8, total_size).to_vec();
self.d3d11_context.Unmap(staging, 0);
}
data
};

Ok(NV12Data {
data: Arc::new(data),
Expand Down
17 changes: 10 additions & 7 deletions scripts/linux-desktop-harness/arch-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ common=(LIBSEAT_BACKEND=seatd XDG_SESSION_TYPE=wayland)

case "$desktop" in
hyprland)
cat >"$HOME/.config/hypr/hyprland.conf" <<CONF
monitor = , 1920x1080, 0x0, 1
misc { disable_hyprland_logo = true }
debug { disable_logs = false }
exec-once = sh -c '$export_session'
exec-once = waybar
CONF
# Hyprland 0.57 reads only hyprland.lua; a hyprland.conf is ignored and
# the output falls back to vkms's preferred 1024x768.
cat >"$HOME/.config/hypr/hyprland.lua" <<LUA
hl.monitor({ output = "", mode = "1920x1080", position = "0x0", scale = "1" })
hl.config({ misc = { disable_hyprland_logo = true }, debug = { disable_logs = false } })
hl.on("hyprland.start", function()
hl.exec_cmd("sh -c '$export_session'")
hl.exec_cmd("waybar")
end)
LUA
# The screen-share picker: answer with the one screen, as a person would.
cat >"$HOME/.config/hypr/xdph.conf" <<CONF
screencopy {
Expand Down
30 changes: 25 additions & 5 deletions scripts/linux-desktop-harness/scenario-export.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,32 @@ gif_frames_and_seconds() {
import sys

data = open(sys.argv[1], "rb").read()


def skip_sub_blocks(at):
while data[at]:
at += data[at] + 1
return at + 1


if data[:6] in (b"GIF87a", b"GIF89a"):
frames, centiseconds, at = 0, 0, data.find(b"\x21\xf9\x04")
while at != -1 and at + 6 <= len(data):
frames += 1
centiseconds += data[at + 4] | data[at + 5] << 8
at = data.find(b"\x21\xf9\x04", at + 8)
frames, centiseconds, delay = 0, 0, 0
flags = data[10]
at = 13 + (3 << ((flags & 7) + 1) if flags & 0x80 else 0)
while at < len(data) and data[at] != 0x3B:
if data[at] == 0x21:
if data[at + 1] == 0xF9:
delay = data[at + 4] | data[at + 5] << 8
at = skip_sub_blocks(at + 2)
elif data[at] == 0x2C:
frames += 1
centiseconds += delay
delay = 0
local = data[at + 9]
at += 10 + (3 << ((local & 7) + 1) if local & 0x80 else 0)
at = skip_sub_blocks(at + 1)
else:
break
print(frames, f"{centiseconds / 100:.2f}")
PY
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/linux-desktop-harness/scenario-screenshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ focus_cap() {
"global.display.list_all_windows().filter(w => /^(Cap|so\.cap\.Cap)$/.test(w.get_wm_class() || '') && w.showing_on_its_workspace()).forEach(w => w.activate(global.display.get_current_time_roundtrip()))" \
>/dev/null 2>&1
elif [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
hyprctl dispatch focuswindow class:Cap >/dev/null 2>&1
hyprctl dispatch "hl.dsp.focus({ window = 'class:Cap' })" >/dev/null 2>&1
fi
}
for _ in $(seq 60); do
Expand Down
Loading