← All projects

Clipper

A desktop gameplay capture tool for Linux and Windows. It captures and encodes screen video and audio continuously, with a short rolling buffer intended to make saving recent gameplay quick.

In progress Rust FFmpeg PipeWire Windows Graphics Capture

The problem

I started Clipper because Medal isn't available on Linux, and I wanted a way to save gameplay clips there. The moment worth saving often happens before you think to press record. Clipper is designed to keep recent gameplay ready: capture and encode video and audio as they arrive, hold the encoded packets in a rolling buffer, then write that recent window to a clip when requested. The current branch gets as far as capture, encoding, and buffering. The F9 key sends a clip command, but handling that command is still a todo!; writing the buffered packets to a finished clip remains work to do.

Why these choices

The rolling buffer

Each encoded packet is stored in a VecDeque with the time it arrived. The target is a 30-second replay window; the current implementation uses a three-second setting while I test the pipeline and work through packet memory use. Both audio and video packets are added to the same queue, with stream indices set during encoding.

This design is intended to avoid seeking through a long recording: the muxer can work with the recent packets already in memory. The current clip-writing path is unfinished, so the branch does not yet demonstrate a complete saved clip.

rustsrc/capture/muxer.rs
fn encode_video(&mut self, frame: frame::Video) {
    let video_encoder = self.video_encoder.as_mut().unwrap();
    video_encoder.send_frame(&frame).unwrap();

    loop {
        let mut packet = packet::Packet::empty();
        if video_encoder.receive_packet(&mut packet).is_err() {
            break;
        }
        packet.set_stream(0);
        self.replay_buffer.add_frame(packet);
    }

    self.video_pts += 1;
}

The encoder and packet use ffmpeg-next wrapper types. Ownership of each received packet moves into the replay buffer.

Working through packet ownership

The earlier implementation stored raw AVPacket pointers. The history shows several attempts to address memory use and cleanup, including tracking packet sizes, changing how packets are released, and experimenting with a frame pool. Those iterations made resource ownership a recurring part of the project.

The move to ffmpeg-next replaced much of the raw encoder and frame handling with Rust wrapper types. The replay buffer still uses ffmpeg-sys-next to call av_packet_unref and av_packet_free, so that boundary still needs care. A wrapper makes the ordinary path easier to manage; it does not make every interaction with FFmpeg safe automatically.

rustsrc/capture/replay.rs
impl Drop for ReplayBuffer {
    fn drop(&mut self) {
        for (mut packet, _) in self.frames.drain(..) {
            unsafe {
                let mut raw = &mut packet as *mut _
                    as *mut ffmpeg_sys_next::AVPacket;
                sys::av_packet_unref(raw);
                sys::av_packet_free(&mut raw);
            }
        }
    }
}

This is the current cleanup code. It reaches through the wrapper into the raw packet pointer and calls FFmpeg’s C API directly.

Current limitations

The current branch exposes several concrete gaps between the intended replay flow and a finished clip.

What I’ve learned

The rolling buffer is a small feature sitting on top of several hard problems: capture timing, separate audio and video clocks, packet ownership, and platform-specific input. Moving to ffmpeg-next made the media pipeline more approachable, while the remaining raw cleanup calls show where the C API still matters.

The next work is concrete: correct and verify eviction, finish writing buffered packets as a clip, and check the result end to end.