Screen video capture in mcap
Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · not started
- Decided DXGI Desktop Duplication first. If it can't run, for example on a laptop with two GPUs where it fails with
DXGI_ERROR_UNSUPPORTED, recording falls back to Windows.Graphics.Capture. Both sit behind one interface, so everything after the capture is shared. It's more code than Windows.Graphics.Capture alone, but each method covers the cases where the other fails. - Decided Encoding uses the FFmpeg mcap already ships. It tries NVENC first, then AMD AMF, Intel QSV, Media Foundation and
libx264. The output is H.264 in MP4, with frames written only when the screen changes. That FFmpeg build already includes every one of these encoders. - Decided Ctrl+PrintScreen starts a recording. It opens the same frozen-screen overlay as stills, in a video mode. The region must be at least 240 × 180 pixels, which Snagit doesn't require.
- Decided Ctrl+PrintScreen again stops it and saves the recording. One key both starts and stops, and no other key is taken from the app being recorded.
- Decided A recording becomes an
.mcapin Screen Captures, the folder stills go to. It's stored the way imported videos are: the MP4 as the original, a frame from it for the thumbnail, OCR and labels. - It's built in five phases. Phase 1 is a command-line probe that answers the two-GPU question on your laptop before anything depends on it.
- Later Audio is phase 5. Recording one window, and regions that span two screens, come after.
1. Scope
In:
- Recording a rectangle on one screen, or a whole screen, to H.264 MP4 at up to 60 frames a second, with the mouse cursor.
- Pause and resume, stop, and discard.
- Saving into the library with the window and app it was recorded from.
- System audio and the microphone, in the last phase.
Out, for now:
- Recording one window that follows it as it moves.
- Regions that span two screens.
- Webcam overlay.
- Trimming and editing.
- HDR output.
- macOS and Linux.
Windows only, like the stills overlay. It needs Windows 10 version 2004 or later; the Windows.Graphics.Capture features used below need that version.
2. Using it
- Ctrl+PrintScreen freezes the screens and shows the overlay from stills, in its video mode. The outline and the magnifier are red, not orange, so you can see it will record.
- Drag a region. The magnifier's label shows its size.
- The drag is kept on the screen where it started (see 3.4).
- Below 240 × 180, the outline is drawn dashed and the label reads
320 × 120 · at least 240 × 180. Letting go then starts nothing, and the overlay stays open to drag again. - A click without dragging, or Enter, takes the whole screen, as for stills.
- Escape or a right click cancels, as for stills.
- Recording starts as soon as you let go, with no countdown (see open question 4).
- A thin red border is drawn just outside the region, so it's never in the video.
- A small toolbar sits beside the region, outside it where there's room. It shows the time, the size and frames dropped, with Pause, Stop and Discard.
- Both the border and the toolbar are kept out of the capture with
SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE), which needs Windows 10 2004. Whether Desktop Duplication respects this is checked in phase 1. The border is outside the region either way, so only the toolbar depends on it.
- Ctrl+PrintScreen again, or Stop, ends the recording at once:
- The encoder is flushed and the file closed.
- The file becomes an
.mcap. - mcap comes to the front showing it in the Capture window, as it does after a still.
Dimensions are rounded down to even numbers after the minimum is checked, since H.264's 4:2:0 colour needs that. 240 × 180 is already even, so a region that passes the check stays at least 240 × 180.
3. Grabbing frames
One interface, two implementations, chosen when the recording starts:
Capture_Source :: struct {
start: (source: *Capture_Source, screen: Screen_Ref, region: Rect, cursor: bool) -> ok: bool, error: string, fall_back: bool;
next_frame: (source: *Capture_Source, timeout_ms: u32) -> Captured_Frame, status: Frame_Status; // NEW, SAME, LOST, FAILED
stop: (source: *Capture_Source);
name: string; // "DXGI" or "WGC", for the log and the toolbar's tooltip
}
Captured_Frame :: struct {
texture: *ID3D11Texture2D; // the region only, region-sized, owned by the source until the next call
time: s64; // QueryPerformanceCounter ticks: when it was on screen
cursor: Cursor_State; // DXGI only: where, visible, and the shape when it changed
}
Both run on a capture thread of their own, with their own D3D11 device, not the app's. The app's device starts on WARP and moves to the GPU later (#WarpFirstStartup in graphics/DX11.jai). Desktop Duplication also needs a device on the GPU the screen is connected to, which may not be the one the app draws with.
3.1 DXGI Desktop Duplication (first choice)
- Finding the screen:
CreateDXGIFactory1, thenEnumAdapters1andEnumOutputs, to find the output whose desktop rectangle holds the region. The device is created on that output's adapter, withD3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, ...). The jaidxgimodule already has every type needed:IDXGIOutput5,DuplicateOutput1,IDXGIOutputDuplication,DXGI_OUTDUPL_FRAME_INFO. - Duplicating:
IDXGIOutput5::DuplicateOutput1withB8G8R8A8_UNORM. mcap is per-monitor DPI aware (v2), and the plainDuplicateOutputis documented to fail for such processes. Phase 1 confirms this, and falls back toIDXGIOutput1::DuplicateOutputwhereIDXGIOutput5is missing. - Each frame:
AcquireNextFrame(timeout)waits for one.LastPresentTime == 0means only the mouse moved: the cursor is updated and no new image is copied.CopySubresourceRegioncopies the region into a region-sized texture.ReleaseFrameis called straight after the copy. Holding a frame stalls the desktop.
- The cursor isn't drawn into the image. Its position comes with each frame, and its shape from
GetFramePointerShapewhenPointerShapeBufferSizeis set. It is drawn onto the copy on the CPU, after it's read back (4). There are three kinds of shape to handle: colour, masked colour, and monochrome. The monochrome kind (the text I-beam) inverts what's under it. - Errors while recording:
DXGI_ERROR_ACCESS_LOSTcomes from a display mode change, the UAC or lock screen, or a game switching to fullscreen. The duplication is made again, retrying every 250 ms, and the recording goes on. The toolbar shows the gap.DXGI_ERROR_WAIT_TIMEOUTmeans nothing changed.- Anything else ends the recording, keeping what was captured.
3.2 When to fall back
The choice is made once, at the start, and logged:
| Desktop Duplication says | Meaning | Then |
|---|---|---|
DXGI_ERROR_UNSUPPORTED from DuplicateOutput1 | Two GPUs, and the process or device isn't on the one driving the screen. Also returned when no driver can duplicate. | Windows.Graphics.Capture |
DXGI_ERROR_NOT_CURRENTLY_AVAILABLE | Too many duplications of this screen at once (other recorders running) | Windows.Graphics.Capture |
E_ACCESSDENIED | The secure desktop (UAC, lock screen) is showing | Retry for 2 s, then tell the user |
Screen rotated (DXGI_OUTDUPL_DESC.Rotation isn't identity) | Frames would come sideways | Windows.Graphics.Capture, which hands them over upright |
| No output holds the region | The screens changed since the freeze | Error, nothing recorded |
A setting forces one method, for testing and for machines where one misbehaves: capture: auto (the default), dxgi or wgc, under a new [video] section of settings.config.
3.3 Windows.Graphics.Capture (fallback)
- Setup: the WinRT runtime is started on the capture thread (
RoInitialize, multithreaded).IGraphicsCaptureItemInterop::CreateForMonitormakes the item for the screen.CreateDirect3D11DeviceFromDXGIDevicewraps the capture thread's device.Direct3D11CaptureFramePool::CreateFreeThreadedmakes a pool of 2 BGRA frames.
- Polling, not events:
TryGetNextFrameis called at the frame rate, with the same timeout as DXGI. This avoids implementing WinRT event-handler objects in Jai. - Cursor and border:
IsCursorCaptureEnableddraws the cursor into the image, so the cursor step in 3.1 is skipped.IsBorderRequired = falseremoves the yellow border on Windows 11; whether an unpackaged app may do that without asking is checked in phase 2. Windows 10 always shows the border. - Bindings: roughly six WinRT interfaces, written by hand as COM vtables, plus HSTRINGs (
WindowsCreateString). This is the bulk of phase 2.
3.4 One screen per recording
Desktop Duplication works one output at a time, and Windows.Graphics.Capture one monitor at a time. A region over two screens would need two captures stitched together, possibly at different DPI and refresh rates. In video mode the overlay keeps the drag on the screen where it started. Spanning screens is Later.
4. The frame pipeline
capture thread encoder thread
next_frame ─► region texture ─► staging ring (3) ─► queue (≤ 8 frames) ─► AVFrame bgr0 ─► encoder ─► MP4
Map the one from drops the oldest when full,
2 frames back, copy counted as dropped
out, draw the cursor
- Reading back without stalls: three staging textures are used in turn. The CPU reads the one copied two frames ago, so
Mapnever waits on the GPU. At 4K and 60 frames a second, about 2 GB/s is read back, well within what the hardware can do. - Timestamps come from when each frame was on screen (
LastPresentTime, or the system-relative time of a Windows.Graphics.Capture frame), in a 1/90000 timebase. Time paused is subtracted. - The frame rate is capped at the setting (30 by default, or 60). When nothing changes, no frame is written, apart from one each second so players can seek. A still screen costs almost nothing.
- Buffers come from a fixed pool the size of the region, made at the start. Nothing is allocated per frame.
5. Encoding
- The encoder is the first of
h264_nvenc,h264_amf,h264_qsv,h264_mfandlibx264that opens at the region's size. Each is opened once, so a driver that's present but broken falls through to the next. The one used is logged and stored with the recording. - The input is BGRA as
AV_PIX_FMT_BGR0. NVENC converts it to its own colour format on the GPU. For the CPU encoders,swscaleconverts it toyuv420pfirst. - Quality is constant quality, about CQ 23 for NVENC (CRF 23 for
libx264), with a keyframe every 2 s. NVENC uses presetp4. Screen content (text, flat colour) compresses well at this setting. Phase 1 measures the file sizes. - Crash-safe files: the MP4 is written fragmented (
movflags=frag_keyframe+empty_moov) to%TEMP%\mcap\recording-*.mp4. If mcap or Windows crashes mid-recording, the file still plays up to the last fragment. On the next start, a leftover file is offered for import. - Bindings:
modules/ffmpegalready binds everything encoding needs (avcodec_find_encoder_by_name,avcodec_send_frame,avcodec_receive_packet,avformat_alloc_output_context2,avformat_write_header,av_interleaved_write_frame,av_write_trailer,avio_open).swresampleships, but has no bindings yet. Audio needs them (phase 5).
6. Into the library
- Like an import: on stop, the MP4 goes through the path imported videos take (
import_video_mcapandmcap_write_streamed,video_posterfor the thumbnail frame) intoScreen Captures. The temporary file is then deleted. - metadata.json is a still's: the title and app of the window under the region's middle when recording started, and when it started.
- capture.json is a still's (region, position, app path) plus
Duration,FrameRate,VideoCodec,Encoder(h264_nvenc...),Capture(DXGIorWGC),DroppedFramesandPaused(seconds). - The catalog today marks a capture as a video only from
VideoCodecinimport.json(app/catalog.jai). It will read the same keys fromcapture.json, so recordings show as videos, play with Open, and are skipped by segmentation. - The thumbnail frame:
video_posterpicks a frame a tenth of the way in, at most 3 s, to skip a phone video's black start. A screen recording has no black start, but the first second is often the region being chosen. The same rule is kept unless it picks badly.
7. Hotkeys
Action in [global_hotkeys] | Default | What it does |
|---|---|---|
capture_region (stills, built) | PrintScreen | Opens the overlay for a still. |
record_region | Ctrl-PrintScreen | Opens the overlay in video mode. While a recording runs, it stops and saves it. |
- One key for start and stop. It's registered all the time, as
capture_regionis, and onlyglobal_hotkey_firedneeds to know whether a recording is running. No key is borrowed from the app being recorded: a plain Escape, or Windows' own Ctrl+Escape, would be. - Escape inside the overlay, before recording starts, cancels as it does for stills.
- Saving and discarding: stopping always saves. Discarding is the toolbar's Discard button, which asks first.
- PrintScreen during a recording still takes a still. The overlay would freeze the screen, and that freeze would end up in the video, so the still is taken without the overlay: the whole screen under the cursor, saved as usual. Open question 1 asks whether that's right.
- Settings files:
record_regionis a newwindows_onlyaction. Existing settings files get it appended on the next launch, ascapture_regionwas.
8. Audio Phase 5
- Sources: system audio comes from WASAPI loopback on the default output device, and the microphone from WASAPI capture. Each is optional, set in the toolbar and remembered under
[video]. - Mixing: both are mixed to 48 kHz stereo float with
swresample(bindings to generate), and encoded as AAC at 160 kb/s into the same MP4. - Sync: each audio packet's QPC time is lined up with the video's.
- Silence: loopback sends nothing while nothing plays, so silence is written to fill the gaps.
9. Phases
Each phase ends with something to run and check, and is a commit (0.1.3 onwards).
Phase 1: record-probe with Desktop Duplication
A command-line tool, tools/record_probe.jai, built with jai first.jai - record_probe:
- It lists adapters and outputs, picks a screen or region, and duplicates it on the output's adapter.
- It records N seconds through the pipeline (section 4) and encoder (section 5) to an MP4.
- It prints which method and encoder it used, frames captured, written and dropped, time per stage, and the file size.
It answers four questions on your laptop:
- Does
DuplicateOutput1succeed on the internal screen, and on an external one? - Does it succeed with CUDA loaded in the same process? mcap loads onnxruntime with CUDA, which may keep the process on the NVIDIA GPU.
- Does a window with
WDA_EXCLUDEFROMCAPTUREstay out of the frames? - What does 4K at 60 frames a second cost?
Done when: 60 s of a 4K screen at 30 frames a second plays in VLC and Windows' Media Player with the cursor drawn, frames dropped are under 1%, and each of the questions above has a recorded answer. A DXGI_ERROR_UNSUPPORTED is also an answer: it means phase 2 is needed on this machine.
Phase 2: Windows.Graphics.Capture fallback
The second source behind the same interface: WinRT bindings, polling TryGetNextFrame, the cursor and border settings. The fallback rules from 3.2, and -capture dxgi|wgc|auto on the probe.
Done when:
- The probe records the same screens with
-capture wgc. autopicks Desktop Duplication where it works and Windows.Graphics.Capture where it doesn't. The latter is shown either on the two-GPU laptop or on a rotated screen.- Whether the yellow border can be turned off is recorded.
Phase 3: Recording in the app
The pieces:
- The
record_regionaction, which starts a recording or, during one, stops it. - The overlay's video mode: red outline, the 240 × 180 minimum, the drag kept to one screen.
- The capture and encoder threads in the app.
- Stopping, and the file into the library (section 6).
- The catalog reading
capture.json. - A shutdown step that finishes the file.
- Tests of the minimum-size and even-size rules, and of the
capture.jsonround trip.
Done when:
- Ctrl+PrintScreen, a drag, then Ctrl+PrintScreen again leaves a playable video in Screen Captures, shown as a video in the grid and opened in the Capture window.
- A 239 × 300 drag doesn't start a recording.
- Quitting mid-recording keeps it.
Phase 4: Border, toolbar, pause and recovery
The pieces:
- The border and toolbar windows, kept out of the capture.
- Pause, resume, and Discard with a confirmation.
- The dropped-frame count.
- Recovery from
ACCESS_LOST. - Offering a leftover temporary file for import at startup.
- The
[video]settings: frame rate, cursor, capture method.
Done when: neither the border nor the toolbar appears in a recording. Pausing for 10 s leaves no gap in the video. A UAC prompt or a resolution change mid-recording leaves a video that plays through.
Phase 5: Audio
Section 8: swresample bindings, loopback and microphone, mixing, AAC.
Done when: a 10-minute recording with a video playing and someone talking stays in sync to within 50 ms at the end, and silence doesn't shift it.
10. Risks
11. Open questions
- PrintScreen during a recording: take the whole screen without the overlay (this plan), ignore it, or freeze anyway and accept the frozen moment in the video?
- The folder: recordings in Screen Captures beside stills, or a folder of their own (Screen Recordings)? This plan uses Screen Captures, so the grid shows both together.
- A time limit: none by default, apart from stopping when the disk has less than 2 GB free?
- A countdown (3, 2, 1) before recording starts, as Snagit has? This plan starts at once.
- Mouse clicks: should they be shown in the video, for example as a ring at each click, as Snagit can?