Screen video capture in mcap

Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 to 4 built, mcap 0.1.6

In short

1. Scope

In:

Out, for now:

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

  1. 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.
  2. 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.
  3. 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.
  4. 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)

3.2 When to fall back

The choice is made once, at the start, and logged:

Desktop Duplication saysMeaningThen
DXGI_ERROR_UNSUPPORTED from DuplicateOutput1Two 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_AVAILABLEToo many duplications of this screen at once (other recorders running)Windows.Graphics.Capture
E_ACCESSDENIEDThe secure desktop (UAC, lock screen) is showingRetry for 2 s, then tell the user
Screen rotated (DXGI_OUTDUPL_DESC.Rotation isn't identity)Frames would come sidewaysWindows.Graphics.Capture, which hands them over upright
No output holds the regionThe screens changed since the freezeError, 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)

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

5. Encoding

6. Into the library

7. Hotkeys

Action in [global_hotkeys]DefaultWhat it does
capture_region (stills, built)PrintScreenOpens the overlay for a still.
record_regionCtrl-PrintScreenOpens the overlay in video mode. While a recording runs, it stops and saves it.

8. Audio Phase 5

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 Done

A command-line tool, tools/record_probe.jai, built with jai first.jai - record_probe:

It answers four questions on your laptop:

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.

Result (2026-10-10). Release build, RTX 5080 laptop. The screens are a 3840 × 2160 external and the 2560 × 1600 internal; DXGI lists both on the NVIDIA adapter.
  • The done-when run: 60.2 s of the 4K screen at 30 fps, with a square moving across it the whole time (-animate):
    • 3,602 images taken from the screen and 1,802 frames sent, all of them encoded with NVENC and none dropped.
    • 18 MB a minute.
    • FFmpeg reads it back as 60.07 s at 30 fps.
    • Per frame: 4.4 ms to read back and 13.4 ms to encode, on separate threads.
    The frame decoded back matches the one captured, cursor included, by eye. Playing it in VLC and Media Player is still to check.
  • 60 fps: all 59.3 changes a second are kept. On a fixed tick grid, a 60 Hz screen beat against the 60 fps cap and lost about 7% of frames. Frames are now sent as soon as their turn is due, on a schedule that advances by exactly one frame each time. Caps of 30, 60 and 24 give 29.8, 59.3 and 23.8 frames a second.
  • The questions:
    • Both screens duplicate with DuplicateOutput1.
    • The plain DuplicateOutput works too in a per-monitor DPI-aware process. The documented failure doesn't happen here; DuplicateOutput1 stays the first choice.
    • With a CUDA context in the process, duplication still works.
    • WDA_EXCLUDEFROMCAPTURE is respected: the excluded square is 0% present in the frame and the control square 100%.
    • The two-GPU failure wasn't reproduced. Task Manager shows the Intel GPU, but no screen is attached to it now: the laptop is in discrete-GPU mode. Testing it means switching the laptop to Optimus (hybrid) mode in the NVIDIA Control Panel or the BIOS, which needs a restart.
  • Encoders, 4K at 30 fps, 8 s of the same motion. All of them keep up:
    EncoderEncode per frameMB a minute
    h264_nvenc13.7 ms23.4
    h264_qsv (the Intel GPU, though no screen is attached to it)12.3 ms16.7
    h264_mf16.8 ms24.0
    libx264 (veryfast, CRF 23)14.9 ms11.4
    h264_amfNot available: no AMD driver (amfrt64.dll), so it falls through to the next
    Most of NVENC's 13.7 ms is FFmpeg copying the 33 MB frame before the GPU sees it. Handing it the frame without the copy is a later optimisation, not needed at 4K 60.
  • A crash mid-recording: killed about 7 s in, the fragmented MP4 still reads back with 6 s of video. At most the last keyframe interval (2 s) is lost.
  • The minimum size: 239 × 300 is refused, and 241 × 181 records at 240 × 180. The minimum is checked before rounding to even.

Changes from the plan:

  • The DPI check: once, a process that isn't DPI aware saw the 4K screen as 1920 × 1080, and the region would have been the wrong part of it. This couldn't be reproduced afterwards. dxgi_open now compares the duplication's size with the desktop's and refuses when they differ.
  • The probe's extra options: -animate, which moves a square for measuring throughput, and -check FILE, which reads a recording back.

Phase 2: Windows.Graphics.Capture fallback Done

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:

Result (2026-10-10). Release build, the same laptop and screens as phase 1.
  • Built:
    • wgc_source.jai: hand-written bindings for eight WinRT interfaces.
    • capture_gpu.jai: what both sources share, namely the device on the screen's adapter, the region copy and the staging ring. Each source is now only "the next image's region into a texture".
    • Record_Options.capture: .AUTO, .DXGI or .WGC.
    • The probe's -capture auto|dxgi|wgc.
  • The fallback:
    • auto picks Desktop Duplication where it works.
    • With -simulate-unsupported, which makes duplication fail with DXGI_ERROR_UNSUPPORTED as on a two-GPU laptop, auto logs why and records with Windows.Graphics.Capture.
    • -capture dxgi with the same simulated failure refuses, with exit code 3.
    • The real two-GPU case is shelved: this laptop's GPU is pinned in the BIOS.
  • Recording: 4K at 60 fps with motion: 56.6 frames a second taken and every one encoded, none dropped. Windows.Graphics.Capture delivers a little under the screen's 60 Hz; Desktop Duplication delivered 59.3. A 641 × 481 region on the internal screen gives the same frame as Desktop Duplication's in phase 1.
  • The border: IsBorderRequired = false works for mcap as an unpackaged app on Windows 11, with no access request. A GDI sample of the screen's outer 6 pixels found no yellow pixels with the border off, and 14,214 with it left on (-keep-border, the control).
  • The cursor: with the mouse put in the region (-cursor-test), Windows.Graphics.Capture's own cursor and the one cursor.jai draws into Desktop Duplication's frames are the same, pixel for pixel, by eye at 4×.
  • WDA_EXCLUDEFROMCAPTURE is respected by Windows.Graphics.Capture too: the excluded square is 0% present and the control square 100%.
  • Waiting for frames: each new frame sets an event, through a small COM handler for FrameArrived, which Windows took. The polling fallback, for when Windows refuses the handler (-poll), takes 41 frames a second of a 60 Hz screen, enough for a 30 fps recording (29.0 sent), at a 1 ms timer resolution.

Changes from the plan:

  • Events, not polling. The plan said to poll TryGetNextFrame, to avoid writing a WinRT event handler in Jai. The handler took about 30 lines, so it's the default, and polling is the fallback.
  • The source opens on the capture thread, for both methods. WinRT objects belong to the apartment that made them, so record_start starts the capture thread and waits for it to say whether its source opened.
  • The WinRT calls (RoInitialize, RoGetActivationFactory, HSTRINGs) are loaded from combase.dll at run time. Their import library, runtimeobject.lib, names no DLL of its own, which Jai's #library needs.
  • Test switches: Record_Options.testing holds three, for the probe: simulate the two-GPU failure, force polling, keep the border.

Phase 3: Recording in the app Done

The pieces:

Done when:

Result (2026-10-10).
  • Built:
    • src/app/screen_record.jai: start, stop, and writing the recording's .mcap.
    • The overlay's video mode, in screen_capture_win32.jai.
    • The record_region hotkey, Ctrl-PrintScreen by default. Existing settings files get it appended, as yours did.
    • The catalog reading a video's keys from capture.json as well as import.json.
    • Saving reuses the stills' jobs: a thread copies the MP4 into the .mcap with mcap_write_streamed, then the grid, the Capture window and the OCR and label scan take it as they take a still.
  • A real bug, which the fallback hid: the first run in the app recorded with Windows.Graphics.Capture. Desktop Duplication had failed with DXGI_ERROR_UNSUPPORTED, which record-probe never saw. The cause is that ImGui's EnableDpiAwareness sets per-monitor DPI awareness for the main thread only, and Desktop Duplication refuses a thread that isn't aware. The capture thread now makes itself aware (gpu_thread_dpi_aware), and the app records with DuplicateOutput1. This also answers phase 1's question: the plain call isn't what fails for an unaware thread; both do.
  • End to end, with -record 5 on a scratch library: the internal screen (2560 × 1600), Desktop Duplication, NVENC, 5 s. Its .mcap lands in Screen Captures, and the temporary MP4 is deleted.
  • Quitting while recording (-record 4 -record-quit): the shutdown stopped the recording, saved it and finished in 0.6 s.
  • Tests: a "screen recording" group of 17 checks in the import suite:
    • The size rule: 240 × 180 allowed, 239 × 180 and 240 × 179 not.
    • An MP4 made with the module's encoder, written as a recording, reads back with the MP4 as its original and a frame of it, at its size, as its rendered image.
    • The catalog takes it as an H.264 video of about a second, with its window's title and app.
    Every suite passes.
  • Not checked by me: the overlay's video mode by hand. That means the red outline, the dashed outline under 240 × 180 and letting go of it, the drag held to one screen, and Ctrl+PrintScreen to start and stop. It needs someone at the keyboard and mouse.

Changes from the plan:

  • PrintScreen while recording takes the whole screen under the cursor at once, without the overlay: open question 1's answer, for now.
  • -record SECONDS (and -record-quit) on the app's command line, for end-to-end checks like the existing -scan.
  • No indicator yet: a toast says recording started and which key stops it. The border and toolbar are phase 4.
  • A settings-dependent test fixed: the segment overlay test expected no segment colours chosen, but it loads the app's real segment-colours.config, where you had chosen one. It now checks only the colour it set.

Phase 4: Border, toolbar, pause and recovery Done

The pieces:

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.

Result (2026-10-10).
  • Built:
    • src/app/screen_record_win32.jai: the border, one red frame window shaped with a window region, which lets clicks through; and the toolbar. Both are plain GDI windows that never take the focus.
    • record_pause in modules/Screen_Record.
    • The [video] settings: fps, cursor, capture. Your settings file received the section on the next launch.
    • Leftover recordings offered at startup, in a popup in the main window.
    • The Win32 GDI bindings moved to src/base/platform/win32_gdi_bindings.jai, shared by the overlay and the toolbar.
  • Found by hand: a killed recording's timestamps were wrong (mcap 0.1.7). There were two causes:
    • No length or index. A fragmented MP4's header gives its length as 0, and one cut short never gets the index a clean stop writes at its end, so players showed its time and seeked badly. Every recording, cut short or not, is now rewritten as a regular MP4 on its way into the library (record_remux): its packets copied as they are, with one header first holding its length and an index of every frame. Nothing is re-encoded.
    • Keyframes counted in frames. They came every 60 frames, and with frames written only on change, 60 frames can span minutes. So a fragment, and what a crash loses, could span minutes too, not the 2 s section 5 promised. A keyframe is now forced every 2 s of time.
    Checked: your recording's MP4, cut mid-fragment, rewrites to a regular MP4 of the 76 whole frames (5.2 s) with positive frame durations. A 12 s mostly still recording has fragments at 0, 2.0, 4.1, 6.1, 8.1, 10.2 and 12.1 s. The app killed about 10 s into a recording leaves 8.1 s. A test cuts a recording at its last fragment, as a crash would, and checks it goes in as a regular MP4 of the time before the cut.
  • Kept out of the recording: a whole-screen recording with the app (-record 6), where the toolbar sits inside the recorded screen. The frame stored in its .mcap has no toolbar. With -record-ui-visible and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.
  • Pause: 16 s with a 10 s pause from 2 s in gives a 6.2 s video at a steady 30 fps, with Desktop Duplication and with Windows.Graphics.Capture alike. The pause is cut out, with no gap.
  • Losing the screen (-lose, which loses Desktop Duplication every 3 s through the real recovery path): 3 losses in 10 s, 304 frames, 30 fps throughout. A real UAC prompt or resolution change is still to try by hand.
  • Tests: 5 more checks in the recording group, 22 in all:
    • A leftover's date read from its file name, including a _2 one.
    • A leftover written with nothing known about it but its frames: catalogued as a video, its size from its frames, and no encoder claimed.
    Every suite passes.
  • Not checked by me, needing someone at the mouse:
    • Clicking Pause, Stop and Discard, and Discard's "Yes, discard / No".
    • The popup offering leftovers.
    • A real UAC prompt or resolution change mid-recording.

Changes from the plan:

  • The toolbar's buttons only ask. The app does the asking on its next frame, as Stop and Discard close the toolbar itself.
  • No border for a whole screen. It would be drawn on the screen beside it. The toolbar then sits inside the screen's top right, kept out of the recording.
  • Test switches for -record: -record-region, and -record-ui-visible, which leaves the border and toolbar in screenshots to look at them. The probe gained -pause SECONDS and -lose.
  • On the shutdown thread the border and toolbar are hidden, not destroyed: only the thread that made a window may destroy it.

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

Two GPUs. This is the reason for the fallback. One step beyond it: with the NVIDIA control panel set to the high-performance GPU for everything, both methods might be affected. Phase 1 tests your laptop with and without that setting.
HDR screens. Asking for 8-bit BGRA from an HDR screen gives Windows' own tone mapping, which can look washed out. Recording HDR (16-bit float frames, HEVC 10-bit) is out of scope. The toolbar will say when the screen is HDR.
Protected content (DRM video in browsers, some streaming apps) records as black with either method. Nothing can be done about it, so it's documented in the README.
Laptop power. 60 frames a second at 4K keeps the GPU awake. The default is 30, and the probe measures the cost.

11. Open questions

  1. 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?
  2. 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.
  3. A time limit: none by default, apart from stopping when the disk has less than 2 GB free?
  4. A countdown (3, 2, 1) before recording starts, as Snagit has? This plan starts at once.
  5. Mouse clicks: should they be shown in the video, for example as a ring at each click, as Snagit can?