diff --git a/README.md b/README.md
index 4b662e6..0e8ff8d 100644
--- a/README.md
+++ b/README.md
@@ -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.
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.
-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.
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).
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
Foundation, or libx264.
-- **Losing nothing:** a recording that stops by itself keeps what it has, and so does quitting while
- recording.
+- **Losing nothing:**
+ - 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
the screens, which would show in the recording.
- **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. |
| `-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. |
-| `-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. |
| `-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. |
diff --git a/check_targets.jai b/check_targets.jai
index 05bf049..e33ffd2 100644
--- a/check_targets.jai
+++ b/check_targets.jai
@@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
-VERSION :: "0.1.5";
+VERSION :: "0.1.6";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;
diff --git a/first.jai b/first.jai
index 10d5f41..bb4b86d 100644
--- a/first.jai
+++ b/first.jai
@@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "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
// builds never write over the ones you run by hand.
build_directory := "bin";
diff --git a/modules/Capture_File/README.md b/modules/Capture_File/README.md
index c36881c..1e21207 100644
--- a/modules/Capture_File/README.md
+++ b/modules/Capture_File/README.md
@@ -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"
or "Windows.Graphics.Capture on ..., border off"
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.
## Imported videos
diff --git a/modules/Screen_Record/module.jai b/modules/Screen_Record/module.jai
index ec12bc8..8758186 100644
--- a/modules/Screen_Record/module.jai
+++ b/modules/Screen_Record/module.jai
@@ -10,7 +10,8 @@
// dropped and counted.
//
// 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;
// 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)
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
+ LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
}
Record_Options :: struct {
@@ -78,6 +80,7 @@ Record_Stats :: struct {
written: s64; // encoded
dropped: s64; // the encoder was too far behind
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
bytes: s64; // encoded
seconds: float64; // from start to stop
@@ -99,6 +102,7 @@ Recorder :: struct {
capture_started, encoder_started: bool;
running: bool;
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;
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, "";
}
+// 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.
record_stats :: (r: *Recorder) -> Record_Stats {
if !r.running return r.stats;
@@ -318,6 +331,8 @@ record_capture_proc :: (thread: *Thread) -> s64 {
have_image, changed := false, false;
staged: [RECORD_STAGING] Staged;
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) {
now := ticks_now();
@@ -327,6 +342,9 @@ record_capture_proc :: (thread: *Thread) -> s64 {
error: string;
if r.using_wgc next, error = wgc_next(*r.wgc, 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 == {
case .FAILED;
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();
+ 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;
repeat := !changed && now - last_emit >= r.frequency;
if !(changed && now >= turn - early) && !repeat continue;
@@ -369,7 +400,7 @@ record_capture_proc :: (thread: *Thread) -> s64 {
slot := emits % RECORD_STAGING;
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
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;
emits += 1;
last_emit = now;
diff --git a/src/App.jai b/src/App.jai
index 76438b7..e7f8bc7 100644
--- a/src/App.jai
+++ b/src/App.jai
@@ -30,6 +30,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/screen_capture.jai";
#load "app/screen_record.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/debug.jai";
@@ -200,6 +201,7 @@ App :: struct {
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
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)
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;
@@ -267,6 +269,7 @@ App :: struct {
#load "base/platform/platform_windows.jai";
#load "base/platform/win32_bindings.jai";
+ #load "base/platform/win32_gdi_bindings.jai";
#load "graphics/DX11.jai";
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
diff --git a/src/app/app_ui.jai b/src/app/app_ui.jai
index a6405ba..4784ec8 100644
--- a/src/app/app_ui.jai
+++ b/src/app/app_ui.jai
@@ -11,6 +11,7 @@ app_ui_startup :: () {
ocr_ui_startup();
labels_ui_startup();
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.
@@ -99,4 +100,5 @@ app_main_window_ui :: () {
segment_ui_frame();
stats_ui_frame();
import_frame();
+ screen_record_leftovers_popup();
}
diff --git a/src/app/main.jai b/src/app/main.jai
index 292af2a..041c8d2 100644
--- a/src/app/main.jai
+++ b/src/app/main.jai
@@ -26,6 +26,10 @@ main :: () {
app.scan_and_exit = array_find(app.args, "-scan");
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-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");
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];
diff --git a/src/app/screen_capture.jai b/src/app/screen_capture.jai
index d168721..96ac49c 100644
--- a/src/app/screen_capture.jai
+++ b/src/app/screen_capture.jai
@@ -22,6 +22,7 @@ Screen_Capture_Job :: struct {
encoder: string; // "h264_nvenc"
source: string; // how the screen was taken: Record_Stats.source
dropped: s64; // frames the encoder fell too far behind for
+ paused_seconds: float64; // left out of it
thread: Thread;
busy: bool; // cleared by the thread as it ends
diff --git a/src/app/screen_capture_win32.jai b/src/app/screen_capture_win32.jai
index 3967183..ba71022 100644
--- a/src/app/screen_capture_win32.jai
+++ b/src/app/screen_capture_win32.jai
@@ -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
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;
diff --git a/src/app/screen_record.jai b/src/app/screen_record.jai
index a2c6975..ea5b91a 100644
--- a/src/app/screen_record.jai
+++ b/src/app/screen_record.jai
@@ -7,18 +7,26 @@
// 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.
//
+// 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
-// so does quitting while recording. PrintScreen while recording takes the screen under the cursor at
-// once, without the overlay, whose frozen screen would be in the recording.
+// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
+// 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.
-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 {
recording: bool;
flag_started: bool; // -record has started its recording
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
date: string; // when it started, as CaptureDate; heap
path: string; // the MP4 being written; heap
@@ -40,13 +48,25 @@ screen_record_frame :: () {
if failed {
toast_show(.Error, "The recording stopped: %", why);
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
-// screen_record_saved ends the app once it is in the library. With -record-quit, the app is quit while
-// still recording instead, as quitting from the tray would: the shutdown keeps the recording.
+// -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
+// -record-region X Y W H; then screen_record_saved ends the app once it is in the library. With
+// -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 :: () {
if !screen_record.flag_started {
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.
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 {
@@ -87,23 +181,33 @@ screen_record_start :: (immediate := false) {
if app_exit_shutdown_started() return;
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
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 error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
else log("[record] Cancelled.");
return;
}
- folder := tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
+ folder := screen_record_folder();
make_directory_if_it_does_not_exist(folder, recursive = true);
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
- path := tprint("%/recording-%.mp4", folder, stamp);
+ path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
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.path = path;
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);
if !started {
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,
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());
+ 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).
-screen_record_stop :: (quiet := false) {
+// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
+screen_record_discard :: () {
if !screen_record.recording return;
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);
stats := screen_record.recorder.stats;
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.source = copy_string(stats.source,, context.default_allocator);
job.dropped = stats.dropped;
+ job.paused_seconds = stats.paused_seconds;
screen_record.shot = .{};
screen_record.date, screen_record.path = "", "";
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);
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);
capture_document_init(doc, temp);
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);
import_add(doc, info, "VideoCodec", import_text(video.codec));
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
- import_add(doc, info, "Encoder", import_text(job.encoder));
- import_add(doc, info, "Capture", import_text(job.source));
+ if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
+ if job.source import_add(doc, info, "Capture", import_text(job.source));
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);
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");
}
-screen_record_stop :: (quiet := false) {}
+screen_record_stop :: (shutdown := false) {}
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
return "", "screen recording is Windows only";
diff --git a/src/app/screen_record_win32.jai b/src/app/screen_record_win32.jai
new file mode 100644
index 0000000..0c568a5
--- /dev/null
+++ b/src/app/screen_record_win32.jai
@@ -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);
+}
diff --git a/src/base/platform/win32_gdi_bindings.jai b/src/base/platform/win32_gdi_bindings.jai
new file mode 100644
index 0000000..4114c90
--- /dev/null
+++ b/src/base/platform/win32_gdi_bindings.jai
@@ -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;
diff --git a/src/test/import_tests.jai b/src/test/import_tests.jai
index cdcc0bd..e108f68 100644
--- a/src/test/import_tests.jai
+++ b/src/test/import_tests.jai
@@ -749,6 +749,36 @@ screen_record_tests :: () {
catalog_entry_free(entry);
}
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);
+ }
}
}
diff --git a/src/ui/imgui_theme_registry.jai b/src/ui/imgui_theme_registry.jai
index 703d2ff..7e52b4d 100644
--- a/src/ui/imgui_theme_registry.jai
+++ b/src/ui/imgui_theme_registry.jai
@@ -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.
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.
hotkey_bindings: [..] Hotkey_Binding;
// The [global_hotkeys] lines, likewise; see global_hotkeys_install.
@@ -315,6 +321,7 @@ App_User_Settings :: struct {
settings_free :: (s: *App_User_Settings) {
free(s.theme,, 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.global_hotkey_bindings);
}
@@ -326,6 +333,9 @@ FONT_SIZE_BASE_MAX :: 64.0;
EVENT_LOG_CAPACITY_DEFAULT :: 4096;
EVENT_LOG_CAPACITY_MIN :: 64;
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
// 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 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 {
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
@@ -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);
} 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);
+ } 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" {
seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX);
} 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.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.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;
@@ -658,6 +688,13 @@ app_save_user_settings_file :: () -> bool {
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");
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, "# 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);
diff --git a/tools/record_probe.jai b/tools/record_probe.jai
index 691e2b3..06b6a17 100644
--- a/tools/record_probe.jai
+++ b/tools/record_probe.jai
@@ -9,6 +9,8 @@
// -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.
// -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;
// 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
@@ -27,7 +29,7 @@
// 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]
// [-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).
main :: () {
@@ -37,6 +39,7 @@ main :: () {
seconds: float64 = 10;
list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false;
snapshot_folder: string;
+ pause_seconds: float64;
i := 1;
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
while i < args.count {
@@ -64,6 +67,8 @@ main :: () {
if a == "-keep-border" { options.testing |= .KEEP_BORDER; 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 == "-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 == "-capture" && more >= 1 {
how := args[i + 1];
@@ -161,6 +166,14 @@ main :: () {
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);
}
+ 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
border_during = yellow_edge_pixels(border_screen);
failed, _ := record_failed(*recorder);
@@ -184,7 +197,7 @@ main :: () {
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(" 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",
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));
diff --git a/video-capture-plan.html b/video-capture-plan.html
index 36e8360..748c75e 100644
--- a/video-capture-plan.html
+++ b/video-capture-plan.html
@@ -68,7 +68,7 @@
Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 to 3 built, mcap 0.1.5 Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 to 4 built, mcap 0.1.6Screen video capture in mcap
-
modules/Screen_Record and record-probe. 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 phase 1's result).DXGI_ERROR_UNSUPPORTED. 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..mcap in Screen Captures. In the app, Desktop Duplication first failed with DXGI_ERROR_UNSUPPORTED: only the main thread was DPI aware. The capture thread now makes itself aware.[video] settings.The pieces:
[video] settings: frame rate, cursor, capture method.Done when: neither the border nor the toolbar appears in a recording. Pausing for 10 s leaves no gap in the video. A UAC prompt or a resolution change mid-recording leaves a video that plays through.
+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.[video] settings: fps, cursor, capture. Your settings file received the section on the next launch.src/base/platform/win32_gdi_bindings.jai, shared by the overlay and the toolbar.-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.-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._2 one.Changes from the plan:
+-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.