The CRF value was set through AVCodecContext.global_quality with
AV_CODEC_FLAG_QSCALE, which FFmpeg's libx264 wrapper divides by
FF_QP2LAMBDA (118) before passing to x264 — turning CRF 16 into
CRF 0.135 (essentially lossless) while x264 logged '-qscale is
ignored, -crf is recommended' and fell back to its own defaults.
The CRF was never actually applied.
Now the CRF is set as x264's private "crf" option via av_opt_set,
which passes the exact value directly to the encoder. The VBV max
rate still caps bursts as before.
Three changes to make the stream survive constrained links:
Adaptive quality: the sender pipeline now tracks the PLI rate from
the receiver. Every 5 seconds it evaluates: >0.5 PLI/s means the
link is saturated (the receiver is dropping frames), so the CRF
increases by 2 (lower quality, fewer bits) and the encoder restarts
with a keyframe. <0.1 PLI/s means the link is stable, so the CRF
decreases by 1 (better quality) and the encoder probes upward.
Clamped to [user CRF, user CRF + 10] so quality never degrades
below what the link can handle, and never exceeds what the user
asked for. The adaptation is logged to stderr for visibility.
Frame rate capping (--fps N): throttles the capture loop to N
frames per second (0 = no cap; monitor rate). At 15fps instead of
60fps, the bandwidth requirement drops 4x at the same quality
level. Desktop content is still smooth at 15-20fps.
Tighter VBV: one frame period of buffer instead of two. A two-frame
buffer lets a keyframe spike to twice the target rate in one burst,
which overflows any constrained hop (Wi-Fi hotspot, slow switch)
and cascades into PLI storms. One frame period keeps bursts
within what the link can absorb in real time.
Four encoder quality improvements, all sender-side:
- CRF rate control (default 22, --crf to override): targets a
constant visual quality level instead of a fixed bitrate. Static
desktop content uses 300-800 kbps (vs. forced 4000+), and the saved
bits go to sharp text and clean motion when they appear. The VBV
max rate (the --bitrate value, now a cap rather than a target)
bounds bursts so the receiver's UDP buffers stay safe. Round-trip
test bitrate dropped from 1390 kb/s to 47 kb/s on synthetic frames
— the encoder uses only what it needs.
- 5-second GOP (was 1 second): 80% fewer keyframe bits freed for
detail frames. Screen content changes incrementally, not
wholesale; PLI feedback recovers from loss in one frame time
regardless of GOP length.
- faster preset (was veryfast): better sub-pixel estimation and
RDO on more decisions. The desktop handles it trivially at 1080p.
- Screen-content x264 tuning: aq-mode=2 (auto-variance AQ moves
bits away from flat areas toward text edges) and psy-rd=1.5
(preserves texture sharpness).
Combined with the earlier veryfast upgrade and sender-side
downscaling, this is roughly 2x the perceived quality at the same
average bandwidth compared to the original ultrafast ABR encoder.
meson test 5/5 in both configurations, valgrind clean.
Two quality improvements:
Preset: ultrafast -> veryfast. Unlocks Main profile with CABAC
entropy coding, hexagonal motion search, 3 reference frames, and
adaptive quantization — typically 30-40% better quality at the same
bitrate. The desktop handles the extra encoding cost trivially
(150+ fps at 1080p).
Downscaling: the receiver now advertises its display resolution in
the signaling answer (display_width/display_height, 0 = unknown).
When the capture exceeds the display (e.g. 2256x1504 source on a
1920x1080 receiver), the sender scales down preserving aspect ratio
before encoding — the same sws_scale pass that already converts the
pixel format also handles the resolution change, so there is no
extra step. This concentrates the entire bitrate into pixels the
display actually shows (~2.7x more bits per visible pixel at
4000 kbps when going from 2256x1504 to 1620x1080).
The renderer caches the display size during window creation
(native monitor resolution in fullscreen/KMSDRM; window size
otherwise). The receiver includes it in every signaling answer; the
sender pipeline computes aspect-preserving, even-rounded scaled
dimensions when the display is smaller than the capture.
meson test 5/5 in both configurations, valgrind clean.
The first real sender/receiver run failed permanently: the receiver
reported 'non-existing PPS 0' for every frame. Without a VBV, a
2256x1504 IDR keyframe bursts hundreds of kilobytes of back-to-back
FU-A packets, overflowing the ~208KB default UDP receive buffer; the
resulting sequence gap made the depacketizer drop whole keyframes
including their in-band SPS/PPS, so the decoder never initialized and
never recovered, because every keyframe burst overflowed again.
- Encoder: add rc_max_rate = bitrate and rc_buffer_size =
bitrate*2/fps, capping any single frame to about two frame periods
of bytes (~40KB at the 4Mbps default).
- Encoder: switch the time_base to microseconds. It was derived from
the configured frame rate, which quantized capture-rate timestamps
and duplicated pts; RTP timestamps are now lossless.
- Transport: request a 4MB SO_RCVBUF on the receive socket
(best-effort; the kernel clamps to net.core.rmem_max).
meson test 4/4, valgrind clean (loopback + codec).
Wire the first end-to-end pipeline: capture -> encode -> packetize ->
UDP -> depacketize -> decode -> render.
- UdpRtpTransport: raw POSIX UDP sockets (IPv4 via getaddrinfo), a
receive jthread woken by socket close on stop; port 0 skips binding
so the sender uses an OS-assigned source port. ASIO stays deferred
to the signaling phase per ARCHITECTURE.md.
- SdlRenderer: SDL3 window/renderer with RGBA texture upload; the
texture is recreated on resolution change. RendererFactory now
returns RendererResult so SDL init failures carry a message,
mirroring the codec/capture error patterns.
- screencast binary: parse_cli plus SenderPipeline/ReceiverPipeline
per the app scaffolds; the sender creates its encoder once capture
reports real dimensions, the receiver keeps a bounded 3-frame queue
to hold latency down and renders on its own thread until the window
closes. cli argv signature fixed to 'const char* const*' so main's
argv converts implicitly.
- Encoder: drop AV_CODEC_FLAG_GLOBAL_HEADER so libx264 repeats SPS/PPS
in-band at each keyframe -- the receiver decodes from the bitstream
alone, which also makes mid-stream joins and later PLI recovery
work without out-of-band parameter negotiation. The round-trip test
now exercises exactly that path.
- tests: new udp-loopback integration test pushes synthetic frames
through a real localhost socket and decodes 10/10 frames with the
right dimensions; valgrind clean (loopback + codec). meson test 4/4.
Manual validation on the desktop (receiver window shows the captured
desktop) is documented in docs/RUNBOOK.md.
Implement the xdg-desktop-portal ScreenCast backend via libportal: a
blocking portal handshake (interactive source picker), a PipeWire stream
on the portal's node enumerating BGRx/BGRA/RGBx/RGBA, and a latest-frame
slot handing frames to next_frame(). stop() is thread-safe; teardown
follows the order PipeWire requires. All proxy operations run under the
thread-loop lock to satisfy the protocol extension context checks
('impl_ext_end_proxy called from wrong context' otherwise).
The encoder now accepts padded strides for packed RGB inputs (real
PipeWire row pitches) and maps the new PixelFormat::Bgrx to
AV_PIX_FMT_BGRA.
Add tools/capture_smoke: a manual smoke tool (interactive, not in
meson test) that captures N frames, encodes them, and writes a
self-contained Annex-B elementary stream with prepended SPS/PPS.
Validated manually on Wayland/Hyprland: 2256x1504 H.264 elementary
stream, ffprobe clean. Phase 3 marked complete in docs/PHASES.md.
Allocate packet payloads with av_new_packet so they are freed through the
owning AVBufferRef instead of leaking on every decode. Pad extradata with
AV_INPUT_BUFFER_PADDING_SIZE for FFmpeg's bitstream parsers. Reject
oversized frames before the int cast, handle unexpected EOF in the
EAGAIN-retry loops, and add the missing <limits> include. Document the
valgrind codec check in docs/RUNBOOK.md.
Validated with valgrind: 0 bytes definitely lost, 0 invalid reads.
Add FFmpeg-based encoder/decoder with SPS/PPS extradata, Annex-B output
normalization, low-latency libx264 settings, and a round-trip unit test.
Includes review hardening: cached SwsContext, bitrate-only rate control,
std::byte/uin8_t cast helpers, and richer test assertions.