[0.1.6] Video capture phase 4: border, toolbar, pause, discard, recovery, leftovers, [video]

- src/app/screen_record_win32.jai: while recording, a red frame just outside the region (one
  window shaped by a window region, clicks let through) and a toolbar beside it (below the
  region at its right, else above, else inside a whole screen's top right): a blinking dot,
  the time less time paused, the size, frames dropped and screen losses, and Pause/Resume,
  Stop, and Discard, confirmed in the toolbar itself ("Yes, discard / No"). GDI windows that
  never take the focus, kept out of every capture (WDA_EXCLUDEFROMCAPTURE). Buttons only ask;
  screen_record_frame acts on the next frame. On the shutdown thread they are hidden, not
  destroyed.
- modules/Screen_Record: record_pause; while paused the source is drained but nothing is sent,
  and frames' times leave the pause out, so the video has no gap. Record_Stats.paused_seconds;
  Record_Testing.LOSE_DUPLICATION loses Desktop Duplication every 3 s.
- Recordings left in %TEMP%/mcap by a crash are offered at startup: into the library (no window
  known; size from its frames, date from its name), deleted, or later.
- [video] in settings.config: fps (1-120, 30), cursor (true), capture (auto, dxgi, wgc); written
  into existing files on the next launch.
- The Win32 GDI bindings move to src/base/platform/win32_gdi_bindings.jai, shared by the overlay
  and the toolbar.
- -record-region X Y W H and -record-ui-visible for -record; -pause SECONDS and -lose for
  record-probe. capture.json's Paused.
- Checked: the toolbar is not in a whole-screen recording's frame; a 10 s pause leaves a 6.2 s
  video of a 16 s run at a steady 30 fps (DXGI and WGC); 3 simulated losses in 10 s recover with
  no gap. Tests: a leftover's date and its write (22 checks in the recording group); all pass.
- Fixed on the way: a recording's path repeated the temporary folder ("%1" is the first argument).
This commit is contained in:
Musa Mahmood 2026-10-10 07:45:14 +01:00
parent 93274a2b04
commit 59c5b8dd62
17 changed files with 721 additions and 85 deletions

View File

@ -188,7 +188,11 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
- Escape or a right click cancels. - Escape or a right click cancels.
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written 3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
only when the screen changes, so a still screen costs almost nothing. only when the screen changes, so a still screen costs almost nothing.
4. **Ctrl+PrintScreen again** stops it. The video goes into **Screen Captures** beside the stills, - **A red border** is drawn just outside the region.
- **A toolbar** beside it shows the time, the size and any frames dropped, with **Pause** (the time
paused is left out of the video), **Stop**, and **Discard**, which asks first.
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
4. **Ctrl+PrintScreen again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window. as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
OCR and labels read a frame from it. OCR and labels read a frame from it.
@ -196,8 +200,13 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
cannot run (a laptop with two GPUs, a rotated screen). cannot run (a laptop with two GPUs, a rotated screen).
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media - **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
Foundation, or libx264. Foundation, or libx264.
- **Losing nothing:** a recording that stops by itself keeps what it has, and so does quitting while - **Losing nothing:**
recording. - A recording that stops by itself keeps what it has, and so does quitting while recording.
- When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the
recording carries on once it is back.
- A recording a crash cut short is offered at the next start: into the library, or deleted.
- **Settings:** `[video]` in the settings file: `fps` (30), `cursor` (true), and `capture`: `auto`
(Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing - **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
the screens, which would show in the recording. the screens, which would show in the recording.
- **The key** is `record_region` in `[global_hotkeys]`. - **The key** is `record_region` in `[global_hotkeys]`.
@ -331,7 +340,7 @@ then in that order instead of by date.
| `-search QUERY` | Starts with QUERY in the search box. | | `-search QUERY` | Starts with QUERY in the search box. |
| `-scan` | Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed. | | `-scan` | Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed. |
| `-stats` | Logs the Stats window's numbers once the catalog has loaded, then exits; with `-search`, for the matches, and the first ten are logged. | | `-stats` | Logs the Stats window's numbers once the catalog has loaded, then exits; with `-search`, for the matches, and the first ten are logged. |
| `-record SECONDS` | Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed. `-record-quit` quits while still recording instead, to check the recording is kept. | | `-record SECONDS` | Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed. `-record-region X Y W H` records that region instead. `-record-quit` quits while still recording, to check the recording is kept. `-record-ui-visible` leaves the border and toolbar in screenshots, to look at them. |
| `-segment` | Segments the images not yet segmented in the library folders set to segment (see the Segmentation window), once the library has loaded, then exits: 0, or 1 if any image failed. | | `-segment` | Segments the images not yet segmented in the library folders set to segment (see the Segmentation window), once the library has loaded, then exits: 0, or 1 if any image failed. |
| `-erase ocr`, `-erase labels`, `-erase segments`, `-erase all` | Erases that from every file in the library, then exits (for testing). | | `-erase ocr`, `-erase labels`, `-erase segments`, `-erase all` | Erases that from every file in the library, then exits (for testing). |
| `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. | | `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. |

View File

@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.1.5"; VERSION :: "0.1.6";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.5"; VERSION :: "0.1.6";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -133,8 +133,12 @@ Encoder: "h264_nvenc" FFmpeg's encoder: h264_nvenc, h264_amf, h264_
Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU" Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU"
or "Windows.Graphics.Capture on ..., border off" or "Windows.Graphics.Capture on ..., border off"
DroppedFrames: 0 frames the encoder fell too far behind for DroppedFrames: 0 frames the encoder fell too far behind for
Paused: 10.2 seconds paused, left out of the video; only when it was paused
``` ```
A recording a crash cut short, put in the library at the next start, has no window, Encoder or
Capture: its Width and Height are its frames', and its CaptureDate is from its file's name.
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json. A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
## Imported videos ## Imported videos

View File

@ -10,7 +10,8 @@
// dropped and counted. // dropped and counted.
// //
// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with // A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
// one a second while nothing does, so players can seek. Each carries the time it was taken. // one a second while nothing does, so players can seek. Each carries the time it was taken, less the
// time spent paused (record_pause), so a pause leaves no gap in the video.
// //
// recorder: Recorder; // recorder: Recorder;
// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0}); // ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
@ -52,6 +53,7 @@ Record_Testing :: enum_flags u8 {
DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED) DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED)
WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler
KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on
LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
} }
Record_Options :: struct { Record_Options :: struct {
@ -78,6 +80,7 @@ Record_Stats :: struct {
written: s64; // encoded written: s64; // encoded
dropped: s64; // the encoder was too far behind dropped: s64; // the encoder was too far behind
lost: s64; // times the duplication was lost and made again lost: s64; // times the duplication was lost and made again
paused_seconds: float64; // left out of the video
copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
bytes: s64; // encoded bytes: s64; // encoded
seconds: float64; // from start to stop seconds: float64; // from start to stop
@ -99,6 +102,7 @@ Recorder :: struct {
capture_started, encoder_started: bool; capture_started, encoder_started: bool;
running: bool; running: bool;
stopping: bool; // set by record_stop; the capture thread ends, then the encoder stopping: bool; // set by record_stop; the capture thread ends, then the encoder
paused: bool; // set by record_pause; the capture thread takes nothing while it is
capture_ended: bool; capture_ended: bool;
opened: Semaphore; // the capture thread has opened its source, or failed to opened: Semaphore; // the capture thread has opened its source, or failed to
@ -220,6 +224,15 @@ record_stop :: (r: *Recorder) -> ok: bool, error: string {
return true, ""; return true, "";
} }
// Pauses or resumes. While paused nothing is taken, and the time is left out of the video.
record_pause :: (r: *Recorder, pause: bool) {
if r.running atomic_swap(*r.paused, pause);
}
record_paused :: (r: *Recorder) -> bool {
return r.running && atomic_read(*r.paused);
}
// As they are now; strings are the recorder's. // As they are now; strings are the recorder's.
record_stats :: (r: *Recorder) -> Record_Stats { record_stats :: (r: *Recorder) -> Record_Stats {
if !r.running return r.stats; if !r.running return r.stats;
@ -318,6 +331,8 @@ record_capture_proc :: (thread: *Thread) -> s64 {
have_image, changed := false, false; have_image, changed := false, false;
staged: [RECORD_STAGING] Staged; staged: [RECORD_STAGING] Staged;
emits: s64; emits: s64;
paused_ticks, pause_start: s64; // time paused so far, left out of the frames' times; when this pause began
last_lost := ticks_now(); // for Record_Testing.LOSE_DUPLICATION
while !atomic_read(*r.stopping) { while !atomic_read(*r.stopping) {
now := ticks_now(); now := ticks_now();
@ -327,6 +342,9 @@ record_capture_proc :: (thread: *Thread) -> s64 {
error: string; error: string;
if r.using_wgc next, error = wgc_next(*r.wgc, wait); if r.using_wgc next, error = wgc_next(*r.wgc, wait);
else next, error = dxgi_next(*r.dxgi, wait); else next, error = dxgi_next(*r.dxgi, wait);
if r.options.testing & .LOSE_DUPLICATION && !r.using_wgc && ticks_now() - last_lost >= 3 * r.frequency {
next, last_lost = Capture_Next.LOST, ticks_now();
}
if next == { if next == {
case .FAILED; case .FAILED;
record_fail(r, error); record_fail(r, error);
@ -359,7 +377,20 @@ record_capture_proc :: (thread: *Thread) -> s64 {
} }
} }
// Paused: the source is still drained, so it never stalls, but nothing is sent. On resuming, the
// screen as it is now is sent at once.
now = ticks_now(); now = ticks_now();
if atomic_read(*r.paused) {
if !pause_start pause_start = now;
lock(*r.mutex); r.stats.paused_seconds = cast(float64) (paused_ticks + now - pause_start) / r.frequency; unlock(*r.mutex);
continue;
}
if pause_start {
paused_ticks += now - pause_start;
pause_start = 0;
changed = true;
turn = now;
}
if !have_image continue; if !have_image continue;
repeat := !changed && now - last_emit >= r.frequency; repeat := !changed && now - last_emit >= r.frequency;
if !(changed && now >= turn - early) && !repeat continue; if !(changed && now >= turn - early) && !repeat continue;
@ -369,7 +400,7 @@ record_capture_proc :: (thread: *Thread) -> s64 {
slot := emits % RECORD_STAGING; slot := emits % RECORD_STAGING;
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]); if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest); ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
staged[slot] = .{pending = true, ticks = now}; staged[slot] = .{pending = true, ticks = now - paused_ticks};
if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible; if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
emits += 1; emits += 1;
last_emit = now; last_emit = now;

View File

@ -30,6 +30,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/screen_capture.jai"; #load "app/screen_capture.jai";
#load "app/screen_record.jai"; #load "app/screen_record.jai";
#if OS == .WINDOWS #load "app/screen_capture_win32.jai"; #if OS == .WINDOWS #load "app/screen_capture_win32.jai";
#if OS == .WINDOWS #load "app/screen_record_win32.jai";
#load "base/circle_buffer.jai"; #load "base/circle_buffer.jai";
#load "base/debug.jai"; #load "base/debug.jai";
@ -200,6 +201,7 @@ App :: struct {
start_search: string; // -search QUERY: the search box's text at startup start_search: string; // -search QUERY: the search box's text at startup
erase_and_exit: string; // -erase ocr|labels|segments|all: erase that from the whole library once it has loaded, then exit erase_and_exit: string; // -erase ocr|labels|segments|all: erase that from the whole library once it has loaded, then exit
record_and_exit: float64; // -record SECONDS: record the screen under the cursor that long, put it in the library, then exit (0, or 1 if it failed) record_and_exit: float64; // -record SECONDS: record the screen under the cursor that long, put it in the library, then exit (0, or 1 if it failed)
record_region: RECT; // -record-region X Y W H: -record records that, on the virtual desktop, not the whole screen
segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed) segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed)
import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit
last_window_restore_time: Apollo_Time; last_window_restore_time: Apollo_Time;
@ -267,6 +269,7 @@ App :: struct {
#load "base/platform/platform_windows.jai"; #load "base/platform/platform_windows.jai";
#load "base/platform/win32_bindings.jai"; #load "base/platform/win32_bindings.jai";
#load "base/platform/win32_gdi_bindings.jai";
#load "graphics/DX11.jai"; #load "graphics/DX11.jai";
APP_TRAY_ICON_PATH :: "images/tray_icon.ico"; APP_TRAY_ICON_PATH :: "images/tray_icon.ico";

View File

@ -11,6 +11,7 @@ app_ui_startup :: () {
ocr_ui_startup(); ocr_ui_startup();
labels_ui_startup(); labels_ui_startup();
segment_ui_startup(); // the library folders' options: after the viewer, which knows the library segment_ui_startup(); // the library folders' options: after the viewer, which knows the library
screen_record_startup(); // recordings a crash left behind, to offer
} }
// Every frame, before input is handed to ImGui. Poll background work here. // Every frame, before input is handed to ImGui. Poll background work here.
@ -99,4 +100,5 @@ app_main_window_ui :: () {
segment_ui_frame(); segment_ui_frame();
stats_ui_frame(); stats_ui_frame();
import_frame(); import_frame();
screen_record_leftovers_popup();
} }

View File

@ -26,6 +26,10 @@ main :: () {
app.scan_and_exit = array_find(app.args, "-scan"); app.scan_and_exit = array_find(app.args, "-scan");
app.segment_and_exit = array_find(app.args, "-segment"); app.segment_and_exit = array_find(app.args, "-segment");
for app.args if it == "-record" && it_index + 1 < app.args.count app.record_and_exit = max(string_to_float64(app.args[it_index + 1]), 1.0); for app.args if it == "-record" && it_index + 1 < app.args.count app.record_and_exit = max(string_to_float64(app.args[it_index + 1]), 1.0);
for app.args if it == "-record-region" && it_index + 4 < app.args.count {
x, y := cast(s32) string_to_int(app.args[it_index + 1]), cast(s32) string_to_int(app.args[it_index + 2]);
app.record_region = .{x, y, x + cast(s32) string_to_int(app.args[it_index + 3]), y + cast(s32) string_to_int(app.args[it_index + 4])};
}
app.stats_and_exit = array_find(app.args, "-stats"); app.stats_and_exit = array_find(app.args, "-stats");
for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1]; for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1];
for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1]; for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1];

View File

@ -22,6 +22,7 @@ Screen_Capture_Job :: struct {
encoder: string; // "h264_nvenc" encoder: string; // "h264_nvenc"
source: string; // how the screen was taken: Record_Stats.source source: string; // how the screen was taken: Record_Stats.source
dropped: s64; // frames the encoder fell too far behind for dropped: s64; // frames the encoder fell too far behind for
paused_seconds: float64; // left out of it
thread: Thread; thread: Thread;
busy: bool; // cleared by the thread as it ends busy: bool; // cleared by the thread as it ends

View File

@ -658,59 +658,3 @@ overlay_file_description :: (path: *u16) -> string {
text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator
return ifx ok then trim(text) else ""; return ifx ok then trim(text) else "";
} }
// ---------------------------------------------------------------- bindings
gdi32 :: #library,system "gdi32";
version_dll :: #library,system "version";
HFONT :: HANDLE;
HRGN :: HANDLE;
PAINTSTRUCT :: struct {
hdc: HDC;
fErase: BOOL;
rcPaint: RECT;
fRestore: BOOL;
fIncUpdate: BOOL;
rgbReserved: [32] u8;
}
CAPTUREBLT :: 0x40000000;
GA_ROOT :: 2;
WA_INACTIVE :: 0;
WM_ACTIVATE :: 0x0006;
TRANSPARENT :: 1;
CLEARTYPE_QUALITY :: 5;
PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000;
GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32;
BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32;
EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32;
FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32;
GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32;
GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32;
GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32;
GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32;
WindowFromPoint :: (point: POINT) -> HWND #foreign user32;
GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32;
capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos";
CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32;
CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32;
SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32;
SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32;
SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32;
TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32;
GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32;
CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32,
strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32,
pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32;
OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32;
QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32;
GlobalFree :: (memory: HGLOBAL) -> HGLOBAL #foreign kernel32;
GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll;
GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll;
VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll;

View File

@ -7,18 +7,26 @@
// original, a frame from it as its rendered image (which OCR and labels read), the window it was // original, a frame from it as its rendered image (which OCR and labels read), the window it was
// recorded from in metadata.json, as for a still, and how it was recorded in capture.json. // recorded from in metadata.json, as for a still, and how it was recorded in capture.json.
// //
// While it records, a red border is drawn just outside the region and a toolbar beside it gives the
// time, Pause, Stop and Discard (app/screen_record_win32.jai); both are kept out of the recording.
// Time paused is left out of the video. The frame rate, the cursor and how the screen is taken are
// [video] in the settings file.
//
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and // A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
// so does quitting while recording. PrintScreen while recording takes the screen under the cursor at // so does quitting while recording. One left in the temporary folder by a crash is offered at the next
// once, without the overlay, whose frozen screen would be in the recording. // start (screen_record_leftovers_popup). PrintScreen while recording takes the screen under the cursor
// at once, without the overlay, whose frozen screen would be in the recording.
// //
// Windows only, as modules/Screen_Record is. // Windows only, as modules/Screen_Record is.
SCREEN_RECORD_FPS :: 30; SCREEN_RECORD_PREFIX :: "recording-"; // the MP4s in the temporary folder: recording-2026-10-10_07-12-43.mp4
screen_record: struct { screen_record: struct {
recording: bool; recording: bool;
flag_started: bool; // -record has started its recording flag_started: bool; // -record has started its recording
flag_start: Apollo_Time; flag_start: Apollo_Time;
leftovers: [..] string; // MP4s a crash left in the temporary folder, to offer; heap
leftovers_asked: bool;
shot: Screen_Shot; // the region and the window it is of; no pixels shot: Screen_Shot; // the region and the window it is of; no pixels
date: string; // when it started, as CaptureDate; heap date: string; // when it started, as CaptureDate; heap
path: string; // the MP4 being written; heap path: string; // the MP4 being written; heap
@ -40,13 +48,25 @@ screen_record_frame :: () {
if failed { if failed {
toast_show(.Error, "The recording stopped: %", why); toast_show(.Error, "The recording stopped: %", why);
screen_record_stop(); screen_record_stop();
return;
}
if record_ui_take_ask() == {
case .PAUSE;
pause := !record_paused(*screen_record.recorder);
record_pause(*screen_record.recorder, pause);
log("[record] %.", ifx pause then "Paused" else "Resumed");
record_ui_refresh();
case .STOP; screen_record_stop();
case .DISCARD; screen_record_discard();
} }
} }
} }
// -record SECONDS: the screen under the cursor, without the overlay, for that long; then // -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
// screen_record_saved ends the app once it is in the library. With -record-quit, the app is quit while // -record-region X Y W H; then screen_record_saved ends the app once it is in the library. With
// still recording instead, as quitting from the tray would: the shutdown keeps the recording. // -record-quit, the app is quit while still recording instead, as quitting from the tray would: the
// shutdown keeps the recording. -record-ui-visible leaves the border and toolbar in screenshots, to
// look at them.
screen_record_flag_frame :: () { screen_record_flag_frame :: () {
if !screen_record.flag_started { if !screen_record.flag_started {
if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it
@ -77,7 +97,81 @@ screen_record_saved :: (path: string, error: string) {
// On the shutdown thread: a recording running is stopped and kept. // On the shutdown thread: a recording running is stopped and kept.
screen_record_shutdown :: () { screen_record_shutdown :: () {
if screen_record.recording screen_record_stop(quiet = true); if screen_record.recording screen_record_stop(shutdown = true);
}
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
screen_record_startup :: () {
#if OS == .WINDOWS {
screen_record.leftovers.allocator = context.default_allocator;
for file_list(screen_record_folder(),, temp) {
name := path_filename(it);
if !begins_with(name, SCREEN_RECORD_PREFIX) || !ends_with_nocase(name, ".mp4") continue;
array_add(*screen_record.leftovers, copy_string(it,, context.default_allocator));
}
if screen_record.leftovers.count log("[record] % recording% left in % by an earlier run.", screen_record.leftovers.count, ifx screen_record.leftovers.count == 1 then "" else "s", screen_record_folder());
}
}
// The offer of what screen_record_startup found: into the library, or deleted, or asked again next
// time. A modal popup, at the top level, each frame.
screen_record_leftovers_popup :: () { using ImGui;
POPUP :: "Unsaved recordings";
if !screen_record.leftovers.count || app.record_and_exit > 0 return;
if !screen_record.leftovers_asked && !IsPopupOpen(POPUP) { OpenPopup(POPUP); screen_record.leftovers_asked = true; }
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
count := screen_record.leftovers.count;
Text(ifx count == 1 then "A screen recording was not saved: mcap closed while it was recording." else tprint("% screen recordings were not saved: mcap closed while they were recording.", count));
for screen_record.leftovers {
_, size, _ := file_modtime_and_size(it);
TextColored(app_theme.palette.dim, "% (% MB)", screen_record_leftover_date(it), FF(size / (1024.0 * 1024.0), 1));
}
TextColored(app_theme.palette.dim, "Each plays up to its last two seconds or so.");
Spacing();
done := false;
if Button("Save to the library") {
for screen_record.leftovers {
job := New(Screen_Capture_Job,, context.default_allocator);
job.video = true;
job.recording = it; // the job's now
job.date = copy_string(screen_record_leftover_date(it),, context.default_allocator);
screen_capture_job_start(job);
}
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
SameLine();
if Button("Delete") {
for screen_record.leftovers { file_delete(it); free(it,, context.default_allocator); }
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
SameLine();
if Button("Later") || IsKeyPressed(.Escape) {
for screen_record.leftovers free(it,, context.default_allocator);
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
if done CloseCurrentPopup();
EndPopup();
}
// "2026-10-10 07:12:43" from recording-2026-10-10_07-12-43.mp4 (or _2.mp4 ...): when it began. Temp.
screen_record_leftover_date :: (path: string) -> string {
name := path_strip_extension(path_filename(path));
stamp := advance(name, SCREEN_RECORD_PREFIX.count);
if stamp.count < 19 return name;
stamp.count = 19;
date := copy_string(stamp,, temp);
date[10] = #char " ";
date[13] = #char ":";
date[16] = #char ":";
return date;
}
screen_record_folder :: () -> string {
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
else return tprint("/tmp/%", APP_FOLDER_NAME);
} }
#if OS == .WINDOWS { #if OS == .WINDOWS {
@ -87,23 +181,33 @@ screen_record_start :: (immediate := false) {
if app_exit_shutdown_started() return; if app_exit_shutdown_started() return;
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL)); date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate); shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
r := app.record_region;
if ok && immediate && r.right > r.left { // -record-region
shot.left, shot.top, shot.width, shot.height = r.left, r.top, r.right - r.left, r.bottom - r.top;
shot.whole_screen = false;
}
if !ok { if !ok {
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); } if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
else log("[record] Cancelled."); else log("[record] Cancelled.");
return; return;
} }
folder := tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME); folder := screen_record_folder();
make_directory_if_it_does_not_exist(folder, recursive = true); make_directory_if_it_does_not_exist(folder, recursive = true);
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp); stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
path := tprint("%/recording-%.mp4", folder, stamp); path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
number := 2; number := 2;
while file_exists(path) { path = tprint("%/recording-%_%.mp4", folder, stamp, number); number += 1; } while file_exists(path) { path = tprint("%/%%_%.mp4", folder, SCREEN_RECORD_PREFIX, stamp, number); number += 1; }
options: Record_Options; options: Record_Options;
options.path = path; options.path = path;
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height}; options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
options.fps = SCREEN_RECORD_FPS; options.fps = cast(s32) app_user_settings.video_fps;
options.cursor = app_user_settings.video_cursor;
if app_user_settings.video_capture == {
case "dxgi"; options.capture = .DXGI;
case "wgc"; options.capture = .WGC;
}
started, start_error := record_start(*screen_record.recorder, options); started, start_error := record_start(*screen_record.recorder, options);
if !started { if !started {
log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error); log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error);
@ -121,12 +225,32 @@ screen_record_start :: (immediate := false) {
log("[record] Recording % x % at %, % (%) of \"%\" (%) with % and %, to %.", stats.width, stats.height, shot.left, shot.top, log("[record] Recording % x % at %, % (%) of \"%\" (%) with % and %, to %.", stats.width, stats.height, shot.left, shot.top,
ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, stats.source, stats.encoder, path); ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, stats.source, stats.encoder, path);
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text()); toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
} }
// Stops, and hands the MP4 to a job that puts it in the library. quiet: no toasts (shutdown). // Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
screen_record_stop :: (quiet := false) { screen_record_discard :: () {
if !screen_record.recording return; if !screen_record.recording return;
screen_record.recording = false; screen_record.recording = false;
record_ui_close();
record_stop(*screen_record.recorder);
file_delete(screen_record.path);
log("[record] Discarded % (% s).", screen_record.path, FF(screen_record.recorder.stats.seconds, 1));
toast_show(.Success, "Recording discarded");
screen_shot_free(*screen_record.shot);
free(screen_record.date,, context.default_allocator);
free(screen_record.path,, context.default_allocator);
screen_record.date, screen_record.path = "", "";
}
// Stops, and hands the MP4 to a job that puts it in the library. shutdown: on the shutdown thread, so
// no toasts, and the border and toolbar, which are the main thread's, are only hidden.
screen_record_stop :: (shutdown := false) {
if !screen_record.recording return;
screen_record.recording = false;
quiet := shutdown;
if shutdown record_ui_hide();
else record_ui_close();
stopped, stop_error := record_stop(*screen_record.recorder); stopped, stop_error := record_stop(*screen_record.recorder);
stats := screen_record.recorder.stats; stats := screen_record.recorder.stats;
if !stopped log_error("[record] %; keeping what was recorded.", stop_error); if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
@ -153,6 +277,7 @@ screen_record_stop :: (quiet := false) {
job.encoder = copy_string(stats.encoder,, context.default_allocator); job.encoder = copy_string(stats.encoder,, context.default_allocator);
job.source = copy_string(stats.source,, context.default_allocator); job.source = copy_string(stats.source,, context.default_allocator);
job.dropped = stats.dropped; job.dropped = stats.dropped;
job.paused_seconds = stats.paused_seconds;
screen_record.shot = .{}; screen_record.shot = .{};
screen_record.date, screen_record.path = "", ""; screen_record.date, screen_record.path = "", "";
screen_capture_job_start(job); screen_capture_job_start(job);
@ -174,6 +299,7 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
if !hashed return "", tprint("% cannot be read", job.recording); if !hashed return "", tprint("% cannot be read", job.recording);
shot := job.shot; shot := job.shot;
if !shot.width { shot.width, shot.height = image.width, image.height; } // a crash's leftover: no more is known
doc := New(Capture_Document,, temp); doc := New(Capture_Document,, temp);
capture_document_init(doc, temp); capture_document_init(doc, temp);
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION); doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
@ -194,9 +320,10 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate); if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate);
import_add(doc, info, "VideoCodec", import_text(video.codec)); import_add(doc, info, "VideoCodec", import_text(video.codec));
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time)); import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
import_add(doc, info, "Encoder", import_text(job.encoder)); if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
import_add(doc, info, "Capture", import_text(job.source)); if job.source import_add(doc, info, "Capture", import_text(job.source));
import_add_number(doc, info, "DroppedFrames", xx job.dropped); import_add_number(doc, info, "DroppedFrames", xx job.dropped);
if job.paused_seconds > 0 import_add_number(doc, info, "Paused", import_milliseconds(job.paused_seconds));
name := import_out_name(job.date, hash); name := import_out_name(job.date, hash);
text, text_ok := import_document_text(doc, name); text, text_ok := import_document_text(doc, name);
@ -229,7 +356,7 @@ screen_record_start :: (immediate := false) {
if app.record_and_exit > 0 screen_record_saved("", "screen recording is Windows only"); if app.record_and_exit > 0 screen_record_saved("", "screen recording is Windows only");
} }
screen_record_stop :: (quiet := false) {} screen_record_stop :: (shutdown := false) {}
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string { screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
return "", "screen recording is Windows only"; return "", "screen recording is Windows only";

View File

@ -0,0 +1,329 @@
// While recording (app/screen_record.jai), on Windows: a thin red border just outside the region, and a
// toolbar beside it with the time, the size, frames dropped, and Pause, Stop and Discard. Both are
// plain GDI windows, as the overlay is, so they work while mcap is hidden in the tray, and both are
// kept out of every capture (WDA_EXCLUDEFROMCAPTURE), the recording's own included. The border is
// outside the region anyway, so it could never be in the video.
//
// Neither takes the focus: the window being recorded keeps it. The border lets clicks through. A
// toolbar button only asks (record_ui.asked); screen_record_frame does it, outside the window's own
// message, as Stop and Discard close the toolbar. Discard asks first, in the toolbar itself: a
// message box would be in the recording.
Record_UI_Ask :: enum u8 { NONE; PAUSE; STOP; DISCARD; }
record_ui: struct {
border, toolbar: HWND;
classes_registered: bool;
scale: float;
font: HFONT;
asked: Record_UI_Ask; // a button pressed, for screen_record_frame
confirming: bool; // Discard pressed once: "Discard it? Yes No"
hovered: s32 = -1; // the button under the mouse
buttons: [3] Record_UI_Button;
button_count: s32;
started: Apollo_Time;
size_text: string; // "1920 × 1080"; heap
}
Record_UI_Button :: struct { rect: RECT; label: string; ask: Record_UI_Ask; confirm: bool; }
// Shows the border around region (on the virtual desktop) and the toolbar beside it.
record_ui_open :: (region: RECT, whole_screen: bool, width: s32, height: s32) {
record_ui_close();
instance := GetModuleHandleW(null);
if !record_ui.classes_registered {
wc: WNDCLASSEXW;
wc.cbSize = size_of(WNDCLASSEXW);
wc.lpfnWndProc = xx DefWindowProcW;
wc.hInstance = instance;
wc.hbrBackground = CreateSolidBrush(RECORD_UI_RED);
wc.lpszClassName = utf8_to_wide(RECORD_BORDER_CLASS,, context.default_allocator);
RegisterClassExW(*wc);
wc.lpfnWndProc = xx record_toolbar_proc;
wc.hbrBackground = null;
wc.hCursor = LoadCursorW(null, IDC_ARROW);
wc.lpszClassName = utf8_to_wide(RECORD_TOOLBAR_CLASS,, context.default_allocator);
RegisterClassExW(*wc);
record_ui.classes_registered = true;
}
record_ui.started = current_time_monotonic();
record_ui.asked = .NONE;
record_ui.confirming = false;
record_ui.hovered = -1;
free(record_ui.size_text,, context.default_allocator);
record_ui.size_text = sprint("% × %", width, height,, context.default_allocator);
monitor := MonitorFromPoint(.{region.left, region.top}, MONITOR_DEFAULTTONEAREST);
info: MONITORINFO;
info.cbSize = size_of(MONITORINFO);
GetMonitorInfoW(monitor, *info);
screen := info.rcMonitor;
// The toolbar first, to know the screen's DPI.
ex_style :: WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
record_ui.toolbar = CreateWindowExW(ex_style, utf8_to_wide(RECORD_TOOLBAR_CLASS,, temp), utf8_to_wide("Recording",, temp),
WS_POPUP, screen.left, screen.top, 10, 10, null, null, instance, null);
if !record_ui.toolbar return;
dpi := GetDpiForWindow(record_ui.toolbar);
record_ui.scale = ifx dpi then dpi / 96.0 else 1.0;
font_height := cast(s32) (14 * record_ui.scale + 0.5);
record_ui.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
// Below the region, at its right; else above; else inside its top right (kept out of the recording).
toolbar_width, toolbar_height := record_toolbar_size();
border := record_ui_px(3);
gap := record_ui_px(6);
x := region.right - toolbar_width;
y := region.bottom + border + gap;
if y + toolbar_height > screen.bottom y = region.top - border - gap - toolbar_height;
if y < screen.top || whole_screen {
y = region.top + gap;
x = region.right - toolbar_width - gap;
}
x = clamp(x, screen.left, screen.right - toolbar_width);
SetWindowPos(record_ui.toolbar, HWND_TOPMOST, x, y, toolbar_width, toolbar_height, SWP_NOACTIVATE);
visible := array_find(app.args, "-record-ui-visible"); // to look at them in a screenshot
if !visible SetWindowDisplayAffinity(record_ui.toolbar, WDA_EXCLUDEFROMCAPTURE);
ShowWindow(record_ui.toolbar, SW_SHOWNOACTIVATE);
SetTimer(record_ui.toolbar, RECORD_UI_TIMER, 250, null);
// The border: a frame border pixels wide just outside the region, which lets clicks through. Not
// for a whole screen, where it would be on the screen beside it.
if !whole_screen {
outer := RECT.{region.left - border, region.top - border, region.right + border, region.bottom + border};
record_ui.border = CreateWindowExW(ex_style | WS_EX_LAYERED | WS_EX_TRANSPARENT, utf8_to_wide(RECORD_BORDER_CLASS,, temp), utf8_to_wide("Recording",, temp),
WS_POPUP, outer.left, outer.top, outer.right - outer.left, outer.bottom - outer.top, null, null, instance, null);
if record_ui.border {
SetLayeredWindowAttributes(record_ui.border, 0, 255, LWA_ALPHA);
frame := CreateRectRgn(0, 0, outer.right - outer.left, outer.bottom - outer.top);
inside := CreateRectRgn(border, border, outer.right - outer.left - border, outer.bottom - outer.top - border);
CombineRgn(frame, frame, inside, RGN_DIFF);
DeleteObject(inside);
SetWindowRgn(record_ui.border, frame, .TRUE); // the window owns the region from here
if !visible SetWindowDisplayAffinity(record_ui.border, WDA_EXCLUDEFROMCAPTURE);
ShowWindow(record_ui.border, SW_SHOWNOACTIVATE);
}
}
}
record_ui_close :: () {
if record_ui.toolbar { KillTimer(record_ui.toolbar, RECORD_UI_TIMER); DestroyWindow(record_ui.toolbar); record_ui.toolbar = null; }
if record_ui.border { DestroyWindow(record_ui.border); record_ui.border = null; }
if record_ui.font { DeleteObject(record_ui.font); record_ui.font = null; }
}
// From another thread (the shutdown's): hidden, not destroyed, which only the main thread may do.
record_ui_hide :: () {
if record_ui.toolbar ShowWindowAsync(record_ui.toolbar, SW_HIDE);
if record_ui.border ShowWindowAsync(record_ui.border, SW_HIDE);
}
// The button pressed since the last call, once.
record_ui_take_ask :: () -> Record_UI_Ask {
ask := record_ui.asked;
record_ui.asked = .NONE;
return ask;
}
record_ui_refresh :: () {
if record_ui.toolbar InvalidateRect(record_ui.toolbar, null, .FALSE);
}
#scope_file
RECORD_BORDER_CLASS :: "mcapRecordBorder";
RECORD_TOOLBAR_CLASS :: "mcapRecordToolbar";
RECORD_UI_TIMER :: 1;
RECORD_UI_RED :: 0x002828E8; // COLORREF 0x00BBGGRR, as the overlay's video mode
RECORD_UI_GREY :: 0x00909090;
RECORD_UI_AMBER :: 0x0000B4FF;
RECORD_UI_BACK :: 0x00202020;
RECORD_UI_BUTTON :: 0x00383838;
RECORD_UI_HOVER :: 0x00505050;
RECORD_UI_TEXT :: 0x00F0F0F0;
record_ui_px :: (pixels: float) -> s32 {
return max(cast(s32) (pixels * record_ui.scale + 0.5), 1);
}
// What the toolbar shows now, left to right: the status, then the buttons.
record_toolbar_texts :: () -> status: string, dropped: string {
stats := record_stats(*screen_record.recorder);
paused := record_paused(*screen_record.recorder);
seconds := max(to_float64_seconds(current_time_monotonic() - record_ui.started) - stats.paused_seconds, 0.0);
whole := cast(s64) seconds;
two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); }
time := ifx whole >= 3600 then tprint("%:%:%", whole / 3600, two(whole / 60 % 60), two(whole % 60)) else tprint("%:%", two(whole / 60), two(whole % 60));
status := tprint("% % %", ifx paused then "PAUSED" else "REC", time, record_ui.size_text);
dropped := ifx stats.dropped then tprint("% dropped", stats.dropped) else "";
if stats.lost {
lost := tprint("screen lost % time%", stats.lost, ifx stats.lost == 1 then "" else "s");
dropped = ifx dropped then tprint("%, %", dropped, lost) else lost;
}
return status, dropped;
}
// The buttons for the state the toolbar is in.
record_toolbar_buttons :: () -> [] Record_UI_Button {
if record_ui.confirming {
record_ui.buttons[0] = .{label = "Yes, discard", ask = .DISCARD};
record_ui.buttons[1] = .{label = "No", confirm = true};
record_ui.button_count = 2;
} else {
record_ui.buttons[0] = .{label = ifx record_paused(*screen_record.recorder) then "Resume" else "Pause", ask = .PAUSE};
record_ui.buttons[1] = .{label = "Stop", ask = .STOP};
record_ui.buttons[2] = .{label = "Discard", confirm = true};
record_ui.button_count = 3;
}
return array_view(record_ui.buttons, 0, record_ui.button_count);
}
text_width :: (dc: HDC, text: string) -> s32 {
wide := utf8_to_wide(text,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(dc, wide, count, *size);
return size.cx;
}
// The toolbar's size, and where its buttons are, for the longest it is likely to be.
record_toolbar_size :: () -> width: s32, height: s32 {
dc := GetDC(record_ui.toolbar);
defer ReleaseDC(record_ui.toolbar, dc);
old := SelectObject(dc, record_ui.font);
defer SelectObject(dc, old);
padding, gap := record_ui_px(10), record_ui_px(8);
status_width := text_width(dc, tprint("PAUSED 00:00:00 %", record_ui.size_text));
dropped_width := text_width(dc, "999 dropped, screen lost 9 times");
buttons_width := text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 3 * 2 * padding + 2 * gap;
height := record_ui_px(34);
width := padding + record_ui_px(12) + gap + status_width + gap + dropped_width + gap + buttons_width + padding;
return width, height;
}
record_toolbar_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call {
if !platform.main_context return DefWindowProcW(hwnd, message, wParam, lParam);
push_context platform.main_context.* {
if message == {
case WM_MOUSEACTIVATE;
return MA_NOACTIVATE; // the window being recorded keeps the focus
case WM_ERASEBKGND;
return 1;
case WM_TIMER;
// The time moves on, and a mouse that left the toolbar no longer lights a button.
cursor: POINT;
GetCursorPos(*cursor);
window: RECT;
GetWindowRect(hwnd, *window);
if cursor.x < window.left || cursor.x >= window.right || cursor.y < window.top || cursor.y >= window.bottom record_ui.hovered = -1;
InvalidateRect(hwnd, null, .FALSE);
return 0;
case WM_MOUSEMOVE;
hovered := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
if hovered != record_ui.hovered { record_ui.hovered = hovered; InvalidateRect(hwnd, null, .FALSE); }
return 0;
case WM_LBUTTONDOWN;
hit := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
if hit >= 0 {
button := record_ui.buttons[hit];
if button.confirm record_ui.confirming = !record_ui.confirming;
else if button.ask != .NONE { record_ui.asked = button.ask; record_ui.confirming = false; }
InvalidateRect(hwnd, null, .FALSE);
}
return 0;
case WM_PAINT;
record_toolbar_paint(hwnd);
return 0;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
}
record_toolbar_hit :: (p: POINT) -> s32 {
for array_view(record_ui.buttons, 0, record_ui.button_count) {
r := it.rect;
if p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom return cast(s32) it_index;
}
return -1;
}
record_toolbar_paint :: (hwnd: HWND) {
paint: PAINTSTRUCT;
window_dc := BeginPaint(hwnd, *paint);
defer EndPaint(hwnd, *paint);
client: RECT;
GetClientRect(hwnd, *client);
width, height := client.right, client.bottom;
// Drawn whole into a bitmap, then put on screen: no flicker as the time changes.
dc := CreateCompatibleDC(window_dc);
defer DeleteDC(dc);
bitmap := CreateCompatibleBitmap(window_dc, width, height);
defer DeleteObject(bitmap);
old_bitmap := SelectObject(dc, bitmap);
defer SelectObject(dc, old_bitmap);
old_font := SelectObject(dc, record_ui.font);
defer SelectObject(dc, old_font);
fill :: (r: RECT, colour: u32) #expand {
brush := CreateSolidBrush(colour);
rect := r;
FillRect(`dc, *rect, brush);
DeleteObject(brush);
}
text_at :: (x: s32, text: string, colour: u32) #expand {
SetTextColor(`dc, colour);
wide := utf8_to_wide(text,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(`dc, wide, count, *size);
TextOutW(`dc, x, (`height - size.cy) / 2, wide, count);
}
fill(.{0, 0, width, height}, RECORD_UI_BACK);
SetBkMode(dc, TRANSPARENT);
padding, gap := record_ui_px(10), record_ui_px(8);
paused := record_paused(*screen_record.recorder);
// The dot: red while recording, blinking each half second; grey while paused.
dot := record_ui_px(12);
blink := (cast(s64) (to_float64_seconds(current_time_monotonic() - record_ui.started) * 2)) % 2 == 0;
dot_colour: u32 = ifx paused then cast(u32) RECORD_UI_GREY else ifx blink then cast(u32) RECORD_UI_RED else cast(u32) 0x00602020;
brush := CreateSolidBrush(dot_colour);
old_brush := SelectObject(dc, brush);
old_pen := SelectObject(dc, GetStockObject(NULL_PEN));
Ellipse(dc, padding, (height - dot) / 2, padding + dot, (height - dot) / 2 + dot);
SelectObject(dc, old_pen);
SelectObject(dc, old_brush);
DeleteObject(brush);
status, dropped := record_toolbar_texts();
x := padding + dot + gap;
text_at(x, status, RECORD_UI_TEXT);
x += text_width(dc, status) + gap;
if dropped text_at(x, dropped, RECORD_UI_AMBER);
// The buttons, from the right.
buttons := record_toolbar_buttons();
button_height := height - 2 * record_ui_px(5);
right := width - padding;
for < buttons {
label_width := text_width(dc, it.label);
r := RECT.{right - label_width - 2 * padding, (height - button_height) / 2, right, (height - button_height) / 2 + button_height};
record_ui.buttons[it_index].rect = r;
fill(r, ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
SetTextColor(dc, ifx it.ask == .DISCARD then cast(u32) RECORD_UI_AMBER else cast(u32) RECORD_UI_TEXT);
wide := utf8_to_wide(it.label,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(dc, wide, count, *size);
TextOutW(dc, r.left + padding, r.top + (button_height - size.cy) / 2, wide, count);
right = r.left - gap;
}
if record_ui.confirming text_at(right - text_width(dc, "Discard it?"), "Discard it?", RECORD_UI_AMBER);
BitBlt(window_dc, 0, 0, width, height, dc, 0, 0, .SRCCOPY);
}

View File

@ -0,0 +1,68 @@
// Win32 calls and types for the GDI windows the app draws itself (app/screen_capture_win32.jai's
// overlay, app/screen_record_win32.jai's border and toolbar) that modules/Windows.jai does not have.
gdi32 :: #library,system "gdi32";
version_dll :: #library,system "version";
HFONT :: HANDLE;
HRGN :: HANDLE;
PAINTSTRUCT :: struct {
hdc: HDC;
fErase: BOOL;
rcPaint: RECT;
fRestore: BOOL;
fIncUpdate: BOOL;
rgbReserved: [32] u8;
}
CAPTUREBLT :: 0x40000000;
GA_ROOT :: 2;
WA_INACTIVE :: 0;
WM_ACTIVATE :: 0x0006;
TRANSPARENT :: 1;
CLEARTYPE_QUALITY :: 5;
PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000;
GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32;
BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32;
EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32;
FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32;
GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32;
GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32;
GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32;
GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32;
GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32;
capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos";
CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32;
CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32;
SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32;
SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32;
SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32;
TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32;
GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32;
CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32,
strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32,
pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32;
OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32;
QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32;
GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll;
GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll;
VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll;
// The recording's border and toolbar (app/screen_record_win32.jai).
MA_NOACTIVATE :: 3;
LWA_ALPHA :: 0x2;
RGN_DIFF :: 4;
WDA_EXCLUDEFROMCAPTURE :: 0x11; // Windows 10 2004 on: left out of screenshots and recordings
ShowWindowAsync :: (hwnd: HWND, show: s32) -> BOOL #foreign user32;
SetWindowRgn :: (hwnd: HWND, region: HRGN, redraw: BOOL) -> s32 #foreign user32;
SetLayeredWindowAttributes :: (hwnd: HWND, key: u32, alpha: u8, flags: u32) -> BOOL #foreign user32;
SetWindowDisplayAffinity :: (hwnd: HWND, affinity: u32) -> BOOL #foreign user32;
CombineRgn :: (destination: HRGN, a: HRGN, b: HRGN, mode: s32) -> s32 #foreign gdi32;
Ellipse :: (dc: HDC, left: s32, top: s32, right: s32, bottom: s32) -> BOOL #foreign gdi32;

View File

@ -749,6 +749,36 @@ screen_record_tests :: () {
catalog_entry_free(entry); catalog_entry_free(entry);
} }
file_delete(path); file_delete(path);
// A recording a crash left behind: its date from its name, and no window, size or encoder known.
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43.mp4") == "2026-10-10 07:12:43", "a leftover's date from its name");
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43_2.mp4") == "2026-10-10 07:12:43", "and from a second one's");
encoder = .{};
opened, open_error = encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
for frame: 0..9 encoder_write(*encoder, pixels.data, frame * 3000);
encoder_close(*encoder);
leftover: Screen_Capture_Job;
leftover.video = true;
leftover.recording = mp4;
leftover.date = "2026-10-10 07:12:43";
leftover_path, leftover_error := screen_record_write(*leftover);
expect(!leftover_error, "a leftover is written: %", leftover_error);
if !leftover_error {
leftover_entry := catalog_load(leftover_path);
expect(leftover_entry && leftover_entry.media == .VIDEO, "as a video");
if leftover_entry catalog_entry_free(leftover_entry);
m2, parsed2 := mcap_parse(cast([] u8) read_entire_file(leftover_path,, temp));
if parsed2 {
doc2: Capture_Document;
capture_document_init(*doc2, temp);
capture_text_parse(*doc2, leftover_path, m2.text);
info2 := capture_find_file(*doc2, "capture.json");
width2 := ifx info2 then capture_object_find(*info2.root, "Width") else null;
encoder2 := ifx info2 then capture_object_find(*info2.root, "Encoder") else null;
expect(width2 && width2.kind == .NUMBER && width2.number == WIDTH && !encoder2, "its size from its frames, and no encoder claimed");
}
file_delete(leftover_path);
}
} }
} }

View File

@ -306,6 +306,12 @@ App_User_Settings :: struct {
// Entries the Event Log keeps in memory; the oldest are dropped past it. The log file keeps every line. // Entries the Event Log keeps in memory; the oldest are dropped past it. The log file keeps every line.
event_log_capacity: s64 = EVENT_LOG_CAPACITY_DEFAULT; event_log_capacity: s64 = EVENT_LOG_CAPACITY_DEFAULT;
// [video]: screen recording (app/screen_record.jai), set by hand. video_capture is "auto", "dxgi"
// or "wgc"; heap-allocated, "" meaning auto.
video_fps: s64 = VIDEO_FPS_DEFAULT;
video_cursor := true;
video_capture: string;
// The [hotkeys] lines. Only meaningful when the section was seen; see hotkeys_install. // The [hotkeys] lines. Only meaningful when the section was seen; see hotkeys_install.
hotkey_bindings: [..] Hotkey_Binding; hotkey_bindings: [..] Hotkey_Binding;
// The [global_hotkeys] lines, likewise; see global_hotkeys_install. // The [global_hotkeys] lines, likewise; see global_hotkeys_install.
@ -315,6 +321,7 @@ App_User_Settings :: struct {
settings_free :: (s: *App_User_Settings) { settings_free :: (s: *App_User_Settings) {
free(s.theme,, context.default_allocator); free(s.theme,, context.default_allocator);
free(s.ocr_model,, context.default_allocator); free(s.ocr_model,, context.default_allocator);
free(s.video_capture,, context.default_allocator);
array_free(s.hotkey_bindings); array_free(s.hotkey_bindings);
array_free(s.global_hotkey_bindings); array_free(s.global_hotkey_bindings);
} }
@ -326,6 +333,9 @@ FONT_SIZE_BASE_MAX :: 64.0;
EVENT_LOG_CAPACITY_DEFAULT :: 4096; EVENT_LOG_CAPACITY_DEFAULT :: 4096;
EVENT_LOG_CAPACITY_MIN :: 64; EVENT_LOG_CAPACITY_MIN :: 64;
EVENT_LOG_CAPACITY_MAX :: 1_000_000; EVENT_LOG_CAPACITY_MAX :: 1_000_000;
VIDEO_FPS_DEFAULT :: 30;
VIDEO_FPS_MIN :: 1;
VIDEO_FPS_MAX :: 120;
// Fills app_user_settings. Returns true when the file should be written back: it is missing, or // Fills app_user_settings. Returns true when the file should be written back: it is missing, or
// is the current version but lacks a key, so every setting ends up listed there to edit by hand. // is the current version but lacks a key, so every setting ends up listed there to edit by hand.
@ -370,11 +380,12 @@ app_load_user_settings_file :: () -> write_back: bool {
if hotkeys_missing.count || global_missing.count return true; if hotkeys_missing.count || global_missing.count return true;
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet #if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model); return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.video_fps && seen.video_cursor && seen.video_capture);
} }
Settings_Seen :: struct { Settings_Seen :: struct {
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool; theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool;
video_fps, video_cursor, video_capture: bool;
} }
// Of the file's bytes as last loaded or written, so a watcher event for our own save, or for an // Of the file's bytes as last loaded or written, so a watcher event for our own save, or for an
@ -505,6 +516,19 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) ->
seen.font_size = settings_read_float(*out.font_size_base, path, line_number, key, value, FONT_SIZE_BASE_MIN, FONT_SIZE_BASE_MAX); seen.font_size = settings_read_float(*out.font_size_base, path, line_number, key, value, FONT_SIZE_BASE_MIN, FONT_SIZE_BASE_MAX);
} else if section == "[font]" && key == "scale_main" { } else if section == "[font]" && key == "scale_main" {
seen.font_scale = settings_read_float(*out.font_scale_main, path, line_number, key, value, FONT_SCALE_MIN, FONT_SCALE_MAX); seen.font_scale = settings_read_float(*out.font_scale_main, path, line_number, key, value, FONT_SCALE_MIN, FONT_SCALE_MAX);
} else if section == "[video]" && key == "fps" {
seen.video_fps = settings_read_int(*out.video_fps, path, line_number, key, value, VIDEO_FPS_MIN, VIDEO_FPS_MAX);
} else if section == "[video]" && key == "cursor" {
seen.video_cursor = settings_read_bool(*out.video_cursor, path, line_number, key, value);
} else if section == "[video]" && key == "capture" {
capture := to_lower_copy(settings_unquote(value),, temp);
if capture == "auto" || capture == "dxgi" || capture == "wgc" {
free(out.video_capture,, context.default_allocator);
out.video_capture = copy_string(capture,, context.default_allocator);
seen.video_capture = true;
} else {
log_error("[settings] %:%: capture is %; it takes auto, dxgi or wgc. Using auto.", path, line_number, value);
}
} else if section == "[logging]" && key == "event_log_capacity" { } else if section == "[logging]" && key == "event_log_capacity" {
seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX); seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX);
} else { } else {
@ -638,6 +662,12 @@ app_save_user_settings_file :: () -> bool {
if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction; if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction;
if seen.height app_user_settings.window_height_fraction = on_disk.window_height_fraction; if seen.height app_user_settings.window_height_fraction = on_disk.window_height_fraction;
if seen.event_log app_user_settings.event_log_capacity = on_disk.event_log_capacity; if seen.event_log app_user_settings.event_log_capacity = on_disk.event_log_capacity;
if seen.video_fps app_user_settings.video_fps = on_disk.video_fps;
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
if seen.video_capture {
free(app_user_settings.video_capture,, context.default_allocator);
app_user_settings.video_capture = copy_string(on_disk.video_capture,, context.default_allocator);
}
} }
builder: String_Builder; builder: String_Builder;
@ -658,6 +688,13 @@ app_save_user_settings_file :: () -> bool {
append(*builder, "[ocr]\n"); append(*builder, "[ocr]\n");
append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n"); append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n");
print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model)); print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model));
append(*builder, "[video]\n");
append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\n");
append(*builder, "# screen changes. cursor: true or false. capture: auto (Desktop Duplication, else\n");
append(*builder, "# Windows.Graphics.Capture), dxgi or wgc.\n");
print_to_builder(*builder, "fps: %\n", app_user_settings.video_fps);
print_to_builder(*builder, "cursor: %\n", ifx app_user_settings.video_cursor then "true" else "false");
print_to_builder(*builder, "capture: %\n\n", ifx app_user_settings.video_capture.count then app_user_settings.video_capture else "auto");
append(*builder, "[logging]\n"); append(*builder, "[logging]\n");
append(*builder, "# Entries the Event Log keeps in memory. Past it the oldest are dropped; the log file keeps every line.\n"); append(*builder, "# Entries the Event Log keeps in memory. Past it the oldest are dropped; the log file keeps every line.\n");
print_to_builder(*builder, "event_log_capacity: %\n", app_user_settings.event_log_capacity); print_to_builder(*builder, "event_log_capacity: %\n", app_user_settings.event_log_capacity);

View File

@ -9,6 +9,8 @@
// -keep-border leaves Windows.Graphics.Capture's border on: the border test's control. // -keep-border leaves Windows.Graphics.Capture's border on: the border test's control.
// -simulate-unsupported Desktop Duplication fails as on a laptop with two GPUs: the fallback's path. // -simulate-unsupported Desktop Duplication fails as on a laptop with two GPUs: the fallback's path.
// -poll Windows.Graphics.Capture polled, as when it will not take its frame handler. // -poll Windows.Graphics.Capture polled, as when it will not take its frame handler.
// -pause SECONDS pauses 2 s in, for that long: the video should be that much shorter, with no gap.
// -lose Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it.
// -cursor-test puts the mouse in the middle of the region while recording, and back after; // -cursor-test puts the mouse in the middle of the region while recording, and back after;
// with -snapshot, cursor.png is the frame around it, four times as large. // with -snapshot, cursor.png is the frame around it, four times as large.
// -legacy the plain DuplicateOutput, not DuplicateOutput1: does it fail for a process aware // -legacy the plain DuplicateOutput, not DuplicateOutput1: does it fail for a process aware
@ -27,7 +29,7 @@
// Run: bin/record-probe [-list] [-screen N] [-region X Y W H] [-seconds S] [-fps F] [-encoder NAME] // Run: bin/record-probe [-list] [-screen N] [-region X Y W H] [-seconds S] [-fps F] [-encoder NAME]
// [-out FILE.mp4] [-no-cursor] [-legacy] [-cuda] [-exclude-test] [-snapshot DIR] // [-out FILE.mp4] [-no-cursor] [-legacy] [-cuda] [-exclude-test] [-snapshot DIR]
// [-animate] [-capture auto|dxgi|wgc] [-border-test] [-keep-border] [-cursor-test] // [-animate] [-capture auto|dxgi|wgc] [-border-test] [-keep-border] [-cursor-test]
// [-simulate-unsupported] [-poll] [-dpi-unaware] // [-simulate-unsupported] [-poll] [-pause SECONDS] [-lose] [-dpi-unaware]
// Exit code: 0 recorded, 1 failed, 3 Desktop Duplication cannot run here (the fallback's case). // Exit code: 0 recorded, 1 failed, 3 Desktop Duplication cannot run here (the fallback's case).
main :: () { main :: () {
@ -37,6 +39,7 @@ main :: () {
seconds: float64 = 10; seconds: float64 = 10;
list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false; list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false;
snapshot_folder: string; snapshot_folder: string;
pause_seconds: float64;
i := 1; i := 1;
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); } number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
while i < args.count { while i < args.count {
@ -64,6 +67,8 @@ main :: () {
if a == "-keep-border" { options.testing |= .KEEP_BORDER; i += 1; continue; } if a == "-keep-border" { options.testing |= .KEEP_BORDER; i += 1; continue; }
if a == "-simulate-unsupported" { options.testing |= .DXGI_UNSUPPORTED; i += 1; continue; } if a == "-simulate-unsupported" { options.testing |= .DXGI_UNSUPPORTED; i += 1; continue; }
if a == "-poll" { options.testing |= .WGC_POLL; i += 1; continue; } if a == "-poll" { options.testing |= .WGC_POLL; i += 1; continue; }
if a == "-lose" { options.testing |= .LOSE_DUPLICATION; i += 1; continue; }
if a == "-pause" && more >= 1 { pause_seconds = string_to_float64(args[i + 1]); i += 2; continue; }
if a == "-cursor-test" { cursor_test = true; i += 1; continue; } if a == "-cursor-test" { cursor_test = true; i += 1; continue; }
if a == "-capture" && more >= 1 { if a == "-capture" && more >= 1 {
how := args[i + 1]; how := args[i + 1];
@ -161,6 +166,14 @@ main :: () {
print(" % s: % captured, % sent (% repeats), % written, % dropped, % lost, % KB\n", print(" % s: % captured, % sent (% repeats), % written, % dropped, % lost, % KB\n",
FF(to_float64_seconds(last_print - start), 0), s.captured, s.emitted, s.repeats, s.written, s.dropped, s.lost, s.bytes / 1024); FF(to_float64_seconds(last_print - start), 0), s.captured, s.emitted, s.repeats, s.written, s.dropped, s.lost, s.bytes / 1024);
} }
if pause_seconds > 0 {
elapsed := to_float64_seconds(current_time_monotonic() - start);
should := elapsed >= 2 && elapsed < 2 + pause_seconds;
if should != record_paused(*recorder) {
record_pause(*recorder, should);
print(" % s: %\n", FF(elapsed, 1), ifx should then "paused" else "resumed");
}
}
if border_test && border_during < 0 && to_float64_seconds(current_time_monotonic() - start) >= 1.5 if border_test && border_during < 0 && to_float64_seconds(current_time_monotonic() - start) >= 1.5
border_during = yellow_edge_pixels(border_screen); border_during = yellow_edge_pixels(border_screen);
failed, _ := record_failed(*recorder); failed, _ := record_failed(*recorder);
@ -184,7 +197,7 @@ main :: () {
print(" seconds: %\n", FF(s.seconds, 1)); print(" seconds: %\n", FF(s.seconds, 1));
print(" captured: % new images of the screen (% a second), % cursor-only changes\n", s.captured, FF(s.captured / s.seconds, 1), s.pointer_moves); print(" captured: % new images of the screen (% a second), % cursor-only changes\n", s.captured, FF(s.captured / s.seconds, 1), s.pointer_moves);
print(" sent: % frames (% a second), % of them repeats of an unchanged screen\n", s.emitted, FF(s.emitted / s.seconds, 1), s.repeats); print(" sent: % frames (% a second), % of them repeats of an unchanged screen\n", s.emitted, FF(s.emitted / s.seconds, 1), s.repeats);
print(" written: %, dropped %, duplication lost %\n", s.written, s.dropped, s.lost); print(" written: %, dropped %, duplication lost %, paused % s\n", s.written, s.dropped, s.lost, FF(s.paused_seconds, 1));
print(" each: copy % (per image), read back % , encode % (per frame)\n", print(" each: copy % (per image), read back % , encode % (per frame)\n",
ms_each(s.copy_ticks, s.captured), ms_each(s.readback_ticks, s.emitted), ms_each(s.encode_ticks, s.written)); ms_each(s.copy_ticks, s.captured), ms_each(s.readback_ticks, s.emitted), ms_each(s.encode_ticks, s.written));
print(" file: % KB, % MB a minute\n", file_size / 1024, FF(cast(float64) file_size / (1024 * 1024) / s.seconds * 60, 1)); print(" file: % KB, % MB a minute\n", file_size / 1024, FF(cast(float64) file_size / (1024 * 1024) / s.seconds * 60, 1));

View File

@ -68,7 +68,7 @@
<header> <header>
<h1>Screen video capture in mcap</h1> <h1>Screen video capture in mcap</h1>
<p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 to 3 built, mcap 0.1.5</strong></p> <p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 to 4 built, mcap 0.1.6</strong></p>
</header> </header>
<div class="summary"> <div class="summary">
@ -83,6 +83,7 @@
<li><span class="tag decided">Done</span> <strong>Phase 1</strong> (2026-10-10, mcap 0.1.3): <code>modules/Screen_Record</code> and <code>record-probe</code>. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see <a href="#phases">phase 1's result</a>).</li> <li><span class="tag decided">Done</span> <strong>Phase 1</strong> (2026-10-10, mcap 0.1.3): <code>modules/Screen_Record</code> and <code>record-probe</code>. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see <a href="#phases">phase 1's result</a>).</li>
<li><span class="tag decided">Done</span> <strong>Phase 2</strong> (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated <code>DXGI_ERROR_UNSUPPORTED</code>. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11.</li> <li><span class="tag decided">Done</span> <strong>Phase 2</strong> (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated <code>DXGI_ERROR_UNSUPPORTED</code>. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11.</li>
<li><span class="tag decided">Done</span> <strong>Phase 3</strong> (2026-10-10, mcap 0.1.5): recording in the app. Ctrl+PrintScreen opens the overlay's video mode; Ctrl+PrintScreen again stops; the recording becomes a video <code>.mcap</code> in Screen Captures. In the app, Desktop Duplication first failed with <code>DXGI_ERROR_UNSUPPORTED</code>: only the main thread was DPI aware. The capture thread now makes itself aware.</li> <li><span class="tag decided">Done</span> <strong>Phase 3</strong> (2026-10-10, mcap 0.1.5): recording in the app. Ctrl+PrintScreen opens the overlay's video mode; Ctrl+PrintScreen again stops; the recording becomes a video <code>.mcap</code> in Screen Captures. In the app, Desktop Duplication first failed with <code>DXGI_ERROR_UNSUPPORTED</code>: only the main thread was DPI aware. The capture thread now makes itself aware.</li>
<li><span class="tag decided">Done</span> <strong>Phase 4</strong> (2026-10-10, mcap 0.1.6): the border and toolbar, both kept out of the recording; pause, with no gap in the video; discard, confirmed in the toolbar; recovery when the screen is lost; leftover recordings offered at startup; the <code>[video]</code> settings.</li>
<li><span class="tag later">Later</span> Audio is phase 5. Recording one window, and regions that span two screens, come after.</li> <li><span class="tag later">Later</span> Audio is phase 5. Recording one window, and regions that span two screens, come after.</li>
</ul> </ul>
</div> </div>
@ -412,7 +413,7 @@ Captured_Frame :: struct {
</div> </div>
<div class="phase"> <div class="phase">
<h3>Phase 4: Border, toolbar, pause and recovery</h3> <h3>Phase 4: Border, toolbar, pause and recovery <span class="tag decided">Done</span></h3>
<p>The pieces:</p> <p>The pieces:</p>
<ul> <ul>
<li>The border and toolbar windows, kept out of the capture.</li> <li>The border and toolbar windows, kept out of the capture.</li>
@ -423,6 +424,39 @@ Captured_Frame :: struct {
<li>The <code>[video]</code> settings: frame rate, cursor, capture method.</li> <li>The <code>[video]</code> settings: frame rate, cursor, capture method.</li>
</ul> </ul>
<p><strong>Done when:</strong> 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.</p> <p><strong>Done when:</strong> 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.</p>
<div class="note"><strong>Result (2026-10-10).</strong>
<ul>
<li><strong>Built:</strong>
<ul>
<li><code>src/app/screen_record_win32.jai</code>: 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.</li>
<li><code>record_pause</code> in <code>modules/Screen_Record</code>.</li>
<li>The <code>[video]</code> settings: <code>fps</code>, <code>cursor</code>, <code>capture</code>. Your settings file received the section on the next launch.</li>
<li>Leftover recordings offered at startup, in a popup in the main window.</li>
<li>The Win32 GDI bindings moved to <code>src/base/platform/win32_gdi_bindings.jai</code>, shared by the overlay and the toolbar.</li>
</ul></li>
<li><strong>Kept out of the recording:</strong> a whole-screen recording with the app (<code>-record 6</code>), where the toolbar sits inside the recorded screen. The frame stored in its <code>.mcap</code> has no toolbar. With <code>-record-ui-visible</code> and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.</li>
<li><strong>Pause:</strong> 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.</li>
<li><strong>Losing the screen</strong> (<code>-lose</code>, 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.</li>
<li><strong>Tests:</strong> 5 more checks in the recording group, 22 in all:
<ul>
<li>A leftover's date read from its file name, including a <code>_2</code> one.</li>
<li>A leftover written with nothing known about it but its frames: catalogued as a video, its size from its frames, and no encoder claimed.</li>
</ul>
Every suite passes.</li>
<li><strong>Not checked by me</strong>, needing someone at the mouse:
<ul>
<li>Clicking Pause, Stop and Discard, and Discard's "Yes, discard / No".</li>
<li>The popup offering leftovers.</li>
<li>A real UAC prompt or resolution change mid-recording.</li>
</ul></li>
</ul>
<p><strong>Changes from the plan:</strong></p>
<ul>
<li><strong>The toolbar's buttons only ask.</strong> The app does the asking on its next frame, as Stop and Discard close the toolbar itself.</li>
<li><strong>No border for a whole screen.</strong> It would be drawn on the screen beside it. The toolbar then sits inside the screen's top right, kept out of the recording.</li>
<li><strong>Test switches</strong> for <code>-record</code>: <code>-record-region</code>, and <code>-record-ui-visible</code>, which leaves the border and toolbar in screenshots to look at them. The probe gained <code>-pause SECONDS</code> and <code>-lose</code>.</li>
<li><strong>On the shutdown thread</strong> the border and toolbar are hidden, not destroyed: only the thread that made a window may destroy it.</li>
</ul></div>
</div> </div>
<div class="phase"> <div class="phase">