From 59c5b8dd6265c88df090ab538b97349b7e50f968 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Sat, 10 Oct 2026 07:45:14 +0100 Subject: [PATCH] [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). --- README.md | 17 +- check_targets.jai | 2 +- first.jai | 2 +- modules/Capture_File/README.md | 4 + modules/Screen_Record/module.jai | 35 ++- src/App.jai | 3 + src/app/app_ui.jai | 2 + src/app/main.jai | 4 + src/app/screen_capture.jai | 1 + src/app/screen_capture_win32.jai | 56 ---- src/app/screen_record.jai | 159 +++++++++-- src/app/screen_record_win32.jai | 329 +++++++++++++++++++++++ src/base/platform/win32_gdi_bindings.jai | 68 +++++ src/test/import_tests.jai | 30 +++ src/ui/imgui_theme_registry.jai | 39 ++- tools/record_probe.jai | 17 +- video-capture-plan.html | 38 ++- 17 files changed, 721 insertions(+), 85 deletions(-) create mode 100644 src/app/screen_record_win32.jai create mode 100644 src/base/platform/win32_gdi_bindings.jai 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 @@

Screen video capture in mcap

-

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.6

@@ -83,6 +83,7 @@
  • Done Phase 1 (2026-10-10, mcap 0.1.3): 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).
  • Done Phase 2 (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated 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.
  • Done Phase 3 (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 .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.
  • +
  • Done Phase 4 (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 [video] settings.
  • Later Audio is phase 5. Recording one window, and regions that span two screens, come after.
  • @@ -412,7 +413,7 @@ Captured_Frame :: struct {
    -

    Phase 4: Border, toolbar, pause and recovery

    +

    Phase 4: Border, toolbar, pause and recovery Done

    The pieces:

    Done when: neither the border nor the toolbar appears in a recording. Pausing for 10 s leaves no gap in the video. A UAC prompt or a resolution change mid-recording leaves a video that plays through.

    +
    Result (2026-10-10). +
      +
    • Built: +
        +
      • src/app/screen_record_win32.jai: the border, one red frame window shaped with a window region, which lets clicks through; and the toolbar. Both are plain GDI windows that never take the focus.
      • +
      • record_pause in modules/Screen_Record.
      • +
      • The [video] settings: fps, cursor, capture. Your settings file received the section on the next launch.
      • +
      • Leftover recordings offered at startup, in a popup in the main window.
      • +
      • The Win32 GDI bindings moved to src/base/platform/win32_gdi_bindings.jai, shared by the overlay and the toolbar.
      • +
    • +
    • Kept out of the recording: a whole-screen recording with the app (-record 6), where the toolbar sits inside the recorded screen. The frame stored in its .mcap has no toolbar. With -record-ui-visible and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.
    • +
    • Pause: 16 s with a 10 s pause from 2 s in gives a 6.2 s video at a steady 30 fps, with Desktop Duplication and with Windows.Graphics.Capture alike. The pause is cut out, with no gap.
    • +
    • Losing the screen (-lose, which loses Desktop Duplication every 3 s through the real recovery path): 3 losses in 10 s, 304 frames, 30 fps throughout. A real UAC prompt or resolution change is still to try by hand.
    • +
    • Tests: 5 more checks in the recording group, 22 in all: +
        +
      • A leftover's date read from its file name, including a _2 one.
      • +
      • A leftover written with nothing known about it but its frames: catalogued as a video, its size from its frames, and no encoder claimed.
      • +
      + Every suite passes.
    • +
    • Not checked by me, needing someone at the mouse: +
        +
      • Clicking Pause, Stop and Discard, and Discard's "Yes, discard / No".
      • +
      • The popup offering leftovers.
      • +
      • A real UAC prompt or resolution change mid-recording.
      • +
    • +
    +

    Changes from the plan:

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