[0.1.5] Video capture phase 3: Ctrl+PrintScreen records a region into the library

- record_region, a new [global_hotkeys] action on Ctrl-PrintScreen (Windows only; none on
  macOS): starts a recording, or stops the one running.
- The overlay's video mode (screen_capture_win32.jai): red outline and magnifier; the drag held
  to the screen it began on; under 240 x 180 (record_size_allowed, in Screen_Record) the outline
  is dashed, the label gives the minimum, and letting go takes nothing; a click or Enter takes
  the whole screen; no pixels copied. With immediate, no overlay at all: the screen under the
  cursor, for PrintScreen while recording.
- src/app/screen_record.jai: records with modules/Screen_Record to %TEMP%/mcap at 30 fps; a
  recording that stops by itself keeps what it has, as does quitting (the shutdown step stops it).
  The MP4 goes into the library through the stills' jobs: a video .mcap in Screen Captures, the
  MP4 its original, a frame of it its rendered image, metadata.json as a still's, capture.json
  with Duration, FrameRate, VideoCodec, PosterTime, Encoder, Capture and DroppedFrames; the MP4 is
  deleted once it is in. The catalog reads a video's keys from capture.json too.
- The capture thread makes itself aware of each monitor's DPI: ImGui makes only the main thread
  aware, and Desktop Duplication refused the app's capture thread with DXGI_ERROR_UNSUPPORTED
  (the fallback recorded with Windows.Graphics.Capture, and the log said so).
- -record SECONDS records the screen under the cursor, puts it in the library and exits;
  -record-quit quits while recording instead. Both pass on a scratch library.
- Tests: a "screen recording" group (the size rule; an MP4 from the module's encoder written as a
  recording reads back, and the catalog takes it as a video). The segment overlay test no longer
  depends on the colours chosen in the app's own data folder.
- README, the format's README and the plan describe it.
This commit is contained in:
Musa Mahmood 2026-10-10 07:14:35 +01:00
parent 9953ba6f1f
commit 93274a2b04
17 changed files with 596 additions and 57 deletions

View File

@ -176,6 +176,32 @@ have PrintScreen: if Snagit is running it has it, and so does Windows while Sett
Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot
register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`.
### Recording the screen
**Ctrl+PrintScreen** records a region of one screen as a video:
1. **The screens freeze**, as for a still, with the outline and magnifier in red.
2. **Drag a region**, or click (or press Enter) for the whole screen under the cursor:
- The drag stays on the screen it began on: a recording is of one screen.
- A region smaller than **240 × 180** is outlined dashed, with the minimum in the label, and letting
go of it starts nothing; drag again.
- 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,
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.
- **The screen** is taken with DXGI Desktop Duplication, or with Windows.Graphics.Capture where that
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.
- **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]`.
## Importing
**Import** (above the grid) brings a folder of screenshots, photos and videos into the library:
@ -305,6 +331,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. |
| `-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. |

View File

@ -12,3 +12,7 @@ none bring_to_foreground
# key, such as Ctrl-Shift-S. Windows takes it as well while Settings > Accessibility > Keyboard >
# "Use the Print screen key to open screen capture" is on.
PrintScreen capture_region
# Records a region of one screen (dragged in the same frozen view, at least 240 x 180, or a click
# for the whole screen) until pressed again; it goes into the library as a video.
Ctrl-PrintScreen record_region

View File

@ -6,3 +6,4 @@
none bring_to_foreground
none capture_region
none record_region

View File

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

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.4";
VERSION :: "0.1.5";
// 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";

View File

@ -6,8 +6,8 @@ then the original and rendered image, then any large chunks. There is no ZIP and
it; the images and videos keep their own compression.
A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image
format), an imported video (the mcap app's Import window), or a screen capture (the mcap app's
PrintScreen).
format), an imported video (the mcap app's Import window), or a screen capture or recording (the mcap app's
PrintScreen and Ctrl+PrintScreen).
The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
the start of each file and never touches the images; a search, which needs no preview, reads less still.
@ -118,6 +118,25 @@ ContentHash: "5c1e9a0d77b2f413" 64-bit FNV-1a of the PNG's bytes
Format: "PNG"
```
## Screen recordings
A recording made with the mcap app's Ctrl+PrintScreen is a video: its original is the H.264 MP4 as
recorded, and its rendered image (kind 2) a frame from it as a PNG, as for an imported video. Its
metadata.json is a still's. Its capture.json has a still's keys, with Format "MP4", and these:
```
Duration: 12.48 seconds
FrameRate: 30 as the container says; frames are written only when the screen changes
VideoCodec: "h264"
PosterTime: 1.25 seconds into it of the rendered frame
Encoder: "h264_nvenc" FFmpeg's encoder: h264_nvenc, h264_amf, h264_qsv, h264_mf or libx264
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
```
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
## Imported videos
A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is

View File

@ -66,8 +66,18 @@ gpu_close :: (g: *Capture_Gpu) {
com_release(*g.adapter);
}
// The calling thread made aware of each monitor's DPI (Windows 10 1703 on), so the desktop it is told
// of is in the screens' own pixels.
gpu_thread_dpi_aware :: () {
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 :: cast(HANDLE) -4;
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
#scope_module
user32_dll :: #library,system "user32";
SetThreadDpiAwarenessContext :: (value: HANDLE) -> HANDLE #foreign user32_dll;
com_release :: (p: *$T) {
if p.* { IUnknown_Release(cast(*IUnknown) p.*); p.* = null; }
}

View File

@ -26,6 +26,12 @@ RECORD_MIN_HEIGHT :: 180;
RECORD_QUEUE :: 8; // frames waiting for the encoder at most
RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded
// Whether a region this size may be recorded: at least RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT. It is
// rounded down to even sizes after, which keeps it at least that, as both are even.
record_size_allowed :: (width: s64, height: s64) -> bool {
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
}
Record_Screen :: struct {
adapter_index, output_index: u32;
adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
@ -144,7 +150,7 @@ record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: stri
// The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
// RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
#assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
if region.right - region.left < RECORD_MIN_WIDTH || region.bottom - region.top < RECORD_MIN_HEIGHT
if !record_size_allowed(region.right - region.left, region.bottom - region.top)
return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
d := r.screen.desktop;
r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
@ -291,6 +297,9 @@ record_open_source :: (r: *Recorder) -> bool {
record_capture_proc :: (thread: *Thread) -> s64 {
r := cast(*Recorder) thread.data;
// Aware of each monitor's DPI, whatever the process is: Desktop Duplication refuses a thread that is
// not (DXGI_ERROR_UNSUPPORTED), and the mcap app's ImGui makes only its main thread aware.
gpu_thread_dpi_aware();
wgc_thread_start();
defer wgc_thread_end();
r.open_ok = record_open_source(r);

View File

@ -28,6 +28,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/library_folders.jai";
#load "app/exit_shutdown.jai";
#load "app/screen_capture.jai";
#load "app/screen_record.jai";
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
#load "base/circle_buffer.jai";
@ -74,6 +75,7 @@ Shared_Channel :: #import "Shared_Memory_Channel";
#import "Capture_Text";
#import "Base64";
#import "onnxruntime";
#if OS == .WINDOWS #import "Screen_Record"; // screen recording: Desktop Duplication or Windows.Graphics.Capture, to NVENC
// Basic's arenas import Secure_Memory at its defaults; this import adds its test suite.
using Secure_Memory :: #import "Secure_Memory"()(LOAD_TESTS=COMPILE_TESTS);
@ -185,6 +187,7 @@ App :: struct {
minimize_to_tray_this_frame: bool;
restore_window_this_frame: bool;
capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai
record_region_this_frame: bool; // the record_region global hotkey fired: app/screen_record.jai
is_master: bool; // Are we the first instance of this app launched?
allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one.
should_exit_early: bool;
@ -196,6 +199,7 @@ App :: struct {
stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit
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)
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;

View File

@ -92,7 +92,9 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
entry.url = catalog_string(metadata.root, "WebURL");
entry.app = catalog_string(metadata.root, "AppName");
}
// An import's import.json, or a screen recording's capture.json, says what kind of media it is.
imported := capture_find_file(*doc, "import.json");
if !imported imported = capture_find_file(*doc, "capture.json");
if imported {
entry.video_codec = catalog_string(imported.root, "VideoCodec");
entry.duration = catalog_number(imported.root, "Duration");

View File

@ -25,6 +25,7 @@ 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);
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];
@ -472,6 +473,12 @@ app_frame :: (in_live_resize := false) -> bool {
screen_capture_take();
app.capture_region_this_frame = false; // the hotkey pressed again while choosing
}
if app.record_region_this_frame && !in_live_resize {
app.record_region_this_frame = false;
screen_record_toggle();
app.record_region_this_frame = false;
app.capture_region_this_frame = false;
}
if app.should_exit {
app_exit_shutdown_start();

View File

@ -7,13 +7,22 @@
// it in the Capture window, and OCR and labels read it once they are free.
//
// The PNG is made and the file written on a thread of its own, as a large capture takes a second or
// so to compress.
// so to compress. A screen recording (app/screen_record.jai) goes into the library through the same
// jobs.
SCREEN_CAPTURE_FOLDER :: "Screen Captures"; // in the library
Screen_Capture_Job :: struct {
shot: Screen_Shot;
date: string; // CaptureDate; heap
// A recording (app/screen_record.jai), not a still; its strings are heap.
video: bool;
recording: string; // the MP4 recorded, deleted once in the library
encoder: string; // "h264_nvenc"
source: string; // how the screen was taken: Record_Stats.source
dropped: s64; // frames the encoder fell too far behind for
thread: Thread;
busy: bool; // cleared by the thread as it ends
path: string; // what it wrote; heap
@ -32,7 +41,9 @@ screen_capture_take :: () {
if app_exit_shutdown_started() return;
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
start := current_time_monotonic();
shot, ok, error := platform_screen_capture_select();
// While recording, the overlay's frozen screen would be in the recording: the screen under the
// cursor is taken at once instead.
shot, ok, error := platform_screen_capture_select(immediate = screen_record.recording);
if !ok {
if error { log_error("[capture] %.", error); toast_show(.Error, "Screen capture: %", error); }
else log("[capture] Cancelled.");
@ -47,29 +58,40 @@ screen_capture_take :: () {
job := New(Screen_Capture_Job,, context.default_allocator);
job.shot = shot;
job.date = copy_string(date,, context.default_allocator);
screen_capture_job_start(job);
}
// Writes the job into the library on a thread of its own; screen_capture_frame takes it from there.
// The job is freed when it cannot start.
screen_capture_job_start :: (job: *Screen_Capture_Job) -> bool {
job.busy = true;
if !thread_init(*job.thread, screen_capture_save_proc, temporary_storage_size = 1024 * 1024, name = "screen capture") {
log_error("[capture] Could not start the thread to save the capture.");
toast_show(.Error, "The screen capture could not be saved");
what := ifx job.video then "recording" else "capture";
log_error("[capture] Could not start the thread to save the %.", what);
toast_show(.Error, "The screen % could not be saved", what);
screen_capture_job_free(job);
return;
return false;
}
thread_start(*job.thread, job);
screen_capture.jobs.allocator = context.default_allocator;
array_add(*screen_capture.jobs, job);
return true;
}
// Every frame, even while hidden in the tray: the captures written, into the grid and the Capture
// window, and then read.
screen_capture_frame :: () {
if app_exit_shutdown_started() return;
screen_record_frame();
for job: screen_capture.jobs {
if atomic_read(*job.busy) continue;
thread_is_done(*job.thread, -1);
thread_deinit(*job.thread);
what := ifx job.video then "recording" else "capture";
if job.video screen_record_saved(job.path, job.error);
if job.error {
log_error("[capture] Could not save the capture: %", job.error);
toast_show(.Error, "The screen capture could not be saved: %", job.error);
log_error("[capture] Could not save the %: %", what, job.error);
toast_show(.Error, "The screen % could not be saved: %", what, job.error);
} else {
log("[capture] Saved %.", job.path);
index := viewer_add_capture(job.path);
@ -78,10 +100,11 @@ screen_capture_frame :: () {
screen_capture.scan_pending.allocator = context.default_allocator;
screen_capture.segment_pending.allocator = context.default_allocator;
array_add(*screen_capture.scan_pending, index);
if library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index);
if !job.video && library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index);
}
app.restore_window_this_frame = true;
toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height);
if job.video toast_show(.Success, "% x % recorded", job.shot.width, job.shot.height);
else toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height);
}
screen_capture_job_free(job);
remove job;
@ -105,6 +128,7 @@ screen_capture_frame :: () {
// On the shutdown thread: a capture being written is finished, not lost.
screen_capture_shutdown :: () {
screen_record_shutdown(); // a recording running is stopped and becomes a job, finished below
for screen_capture.jobs thread_is_done(*it.thread, -1);
}
@ -114,6 +138,9 @@ screen_capture_job_free :: (job: *Screen_Capture_Job) {
free(job.date);
free(job.path);
free(job.error);
free(job.recording);
free(job.encoder);
free(job.source);
free(job);
}
@ -121,7 +148,9 @@ screen_capture_save_proc :: (thread: *Thread) -> s64 {
job := cast(*Screen_Capture_Job) thread.data;
context.allocator = context.default_allocator;
defer atomic_swap(*job.busy, false);
path, error := screen_capture_write(job.shot, job.date);
path, error: string;
if job.video path, error = screen_record_write(job);
else path, error = screen_capture_write(job.shot, job.date);
if error job.error = copy_string(error);
else job.path = copy_string(path);
return 0;
@ -166,6 +195,16 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
serializer, serialized, serialize_error := mcap_serialize(m,, temp);
if !serialized return "", serialize_error;
path, folder_error := screen_capture_out_path(name);
if folder_error return "", folder_error;
written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data});
if !written return "", write_error;
return path, "";
}
// Where a capture named name goes in SCREEN_CAPTURE_FOLDER, made if need be: name, or name_2 ... when
// another file has it. Temp.
screen_capture_out_path :: (name: string) -> path: string, error: string {
folder := tprint("%/%", viewer_folder(), SCREEN_CAPTURE_FOLDER);
if !make_directory_if_it_does_not_exist(folder, recursive = true) return "", tprint("% could not be made", folder);
path := tprint("%/%", folder, name);
@ -174,8 +213,6 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
path = tprint("%/%_%.%", folder, path_strip_extension(name), number, MCAP_EXTENSION);
number += 1;
}
written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data});
if !written return "", write_error;
return path, "";
}
@ -193,7 +230,7 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
shot.* = .{};
}
platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
return .{}, false, "not available on this system yet";
}

View File

@ -5,6 +5,11 @@
// position or the selection's size, so a region can be chosen to the pixel; the arrow keys move the
// cursor a pixel at a time (ten with Shift). Escape or a right click cancels.
//
// For a recording (video), the outline and magnifier are red, the drag is kept on the screen it began
// on (a recording is of one screen), and a region smaller than RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT is
// outlined dashed, with the minimum in the label, and letting go of it takes nothing: the overlay stays
// to drag again. No pixels are copied out for a recording.
//
// Drawn with GDI into a bitmap the size of the desktop, so it needs neither ImGui nor the D3D11 device
// and works while the main window is hidden in the tray. Only the parts that change are drawn again.
// The selection runs in a modal loop of its own: platform_screen_capture_select returns once a region
@ -30,31 +35,29 @@ screen_shot_free :: (shot: *Screen_Shot) {
shot.* = .{};
}
// Freezes the screens and lets a region be chosen. ok is false when it was cancelled (error "") or
// could not start (error says why).
platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
// Freezes the screens and lets a region be chosen: for a still, or with video for a recording. With
// immediate, no overlay: the whole screen under the cursor is taken at once. ok is false when it was
// cancelled (error "") or could not start (error says why).
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
shot: Screen_Shot;
fallback_window := GetForegroundWindow(); // before the overlay takes the focus
overlay.video = video;
// The window procedure runs in this context, during the modal loop below, and in no other.
overlay.app_context = *context;
defer overlay.app_context = null;
if !overlay_freeze() { overlay_release(); return shot, false, "the screen could not be copied"; }
defer overlay_release();
if !overlay_open() return shot, false, "the capture window could not be opened";
overlay.app_context = *context;
while !overlay.done {
msg: MSG;
got := GetMessageW(*msg, null, 0, 0);
if got == -1 { overlay.cancelled = true; break; }
if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again
PostQuitMessage(cast(s32) msg.wParam);
overlay.cancelled = true;
break;
}
TranslateMessage(*msg);
DispatchMessageW(*msg);
if immediate {
cursor: POINT;
GetCursorPos(*cursor);
overlay.result = overlay_monitor_rect(.{cursor.x - overlay.left, cursor.y - overlay.top});
overlay.whole_screen = true;
overlay.cancelled = false;
} else {
if !overlay_open() return shot, false, "the capture window could not be opened";
overlay_choose();
}
DestroyWindow(overlay.hwnd);
overlay.hwnd = null;
if overlay.cancelled return shot, false, "";
r := overlay.result;
@ -66,9 +69,9 @@ platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
shot.whole_screen = overlay.whole_screen;
// The desktop's bitmap is BGRA (its alpha undefined), top-down.
shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
if !video shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
out := shot.pixels.data;
for y: r.top..r.bottom - 1 {
if !video for y: r.top..r.bottom - 1 {
row := overlay.screen_pixels + cast(s64) y * overlay.width;
for x: r.left..r.right - 1 {
p := row[x];
@ -130,6 +133,7 @@ overlay: struct {
hwnd: HWND;
class_registered: bool;
app_context: *#Context;
video: bool; // choosing a region to record
left, top: s32; // the virtual desktop's top left
width, height: s32; // and size, in pixels
@ -157,10 +161,11 @@ Overlay_Layout :: struct {
valid: bool;
dragging: bool;
selection: RECT; // the pixels selected, while dragging
too_small: bool; // to record: smaller than RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT
cursor: POINT;
magnifier: RECT; // its frame, and the label under it
label: RECT;
text: [48] u16;
text: [80] u16;
text_count: s32;
}
@ -171,6 +176,9 @@ BORDER :: 2; // the selection's outline, at 100%
CLICK_SLOP :: 3; // a drag smaller than this either way is a click
ACCENT_COLOR :: 0x0000A5FF; // COLORREF, 0x00BBGGRR: orange
RECORD_COLOR :: 0x002828E8; // red: choosing a region to record
DASH :: 6; // a too-small region's dashes and gaps, at 100%
GAP :: 4;
LABEL_COLOR :: 0x00202020;
FRAME_COLOR :: 0x00FFFFFF;
TEXT_COLOR :: 0x00FFFFFF;
@ -256,7 +264,7 @@ overlay_open :: () -> bool {
font_height := cast(s32) (15 * overlay.scale + 0.5);
overlay.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
if overlay.font overlay.font_old = SelectObject(overlay.back_dc, overlay.font);
overlay.accent_brush = CreateSolidBrush(ACCENT_COLOR);
overlay.accent_brush = CreateSolidBrush(ifx overlay.video then cast(u32) RECORD_COLOR else ACCENT_COLOR);
overlay.black_brush = CreateSolidBrush(0);
overlay.label_brush = CreateSolidBrush(LABEL_COLOR);
overlay.frame_brush = CreateSolidBrush(FRAME_COLOR);
@ -376,15 +384,40 @@ overlay_key :: (key: WPARAM) {
capture_set_cursor_pos(overlay.left + x, overlay.top + y);
}
// The overlay's own message loop, until a region is taken or it is cancelled; then it is closed.
overlay_choose :: () {
while !overlay.done {
msg: MSG;
got := GetMessageW(*msg, null, 0, 0);
if got == -1 { overlay.cancelled = true; break; }
if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again
PostQuitMessage(cast(s32) msg.wParam);
overlay.cancelled = true;
break;
}
TranslateMessage(*msg);
DispatchMessageW(*msg);
}
DestroyWindow(overlay.hwnd);
overlay.hwnd = null;
}
overlay_cancel :: () {
overlay.cancelled = true;
overlay.done = true;
}
// The selection, or on a click the screen under the cursor.
// The selection, or on a click the screen under the cursor. A region too small to record is not
// taken: the overlay stays, to drag again.
overlay_take :: () {
selection := overlay_selection();
if !overlay.dragging || (selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP) {
click := !overlay.dragging || overlay_is_click(selection);
if overlay.video && !click && overlay_too_small(selection) {
overlay.dragging = false;
overlay_update();
return;
}
if click {
selection = overlay_monitor_rect(overlay.cursor);
overlay.whole_screen = true;
}
@ -397,14 +430,23 @@ overlay_take :: () {
overlay.done = true;
}
// From the anchor to the cursor, both pixels in it.
// From the anchor to the cursor, both pixels in it. To record, on the screen the drag began on.
overlay_selection :: () -> RECT {
a, c := overlay.anchor, overlay.cursor;
c.x = clamp(c.x, 0, overlay.width - 1);
c.y = clamp(c.y, 0, overlay.height - 1);
bounds := ifx overlay.video then overlay_monitor_rect(a) else RECT.{0, 0, overlay.width, overlay.height};
c.x = clamp(c.x, bounds.left, bounds.right - 1);
c.y = clamp(c.y, bounds.top, bounds.bottom - 1);
return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1};
}
overlay_is_click :: (selection: RECT) -> bool {
return selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP;
}
overlay_too_small :: (selection: RECT) -> bool {
return !record_size_allowed(selection.right - selection.left, selection.bottom - selection.top);
}
// The screen a point is on, in the overlay's pixels.
overlay_monitor_rect :: (point: POINT) -> RECT {
monitor := MonitorFromPoint(.{point.x + overlay.left, point.y + overlay.top}, MONITOR_DEFAULTTONEAREST);
@ -422,11 +464,15 @@ overlay_update :: () {
layout.valid = true;
layout.dragging = overlay.dragging;
layout.cursor = .{clamp(overlay.cursor.x, 0, overlay.width - 1), clamp(overlay.cursor.y, 0, overlay.height - 1)};
if layout.dragging layout.selection = overlay_selection();
if layout.dragging {
layout.selection = overlay_selection();
layout.too_small = overlay.video && !overlay_is_click(layout.selection) && overlay_too_small(layout.selection);
}
text: string;
if layout.dragging text = tprint("% × %", layout.selection.right - layout.selection.left, layout.selection.bottom - layout.selection.top);
else text = tprint("%, %", layout.cursor.x + overlay.left, layout.cursor.y + overlay.top);
if layout.too_small text = tprint("% · at least % × %", text, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT);
wide := utf8_to_wide(text,, temp);
while wide && wide[layout.text_count] && layout.text_count < layout.text.count layout.text_count += 1;
memcpy(layout.text.data, wide, layout.text_count * size_of(u16));
@ -506,11 +552,29 @@ overlay_draw :: (dc: HDC, layout: Overlay_Layout) {
if layout.dragging {
s := layout.selection;
BitBlt(dc, s.left, s.top, s.right - s.left, s.bottom - s.top, overlay.screen_dc, s.left, s.top, .SRCCOPY);
// The outline just outside it, so every pixel taken shows as it is.
fill(.{s.left - border, s.top - border, s.right + border, s.top}, overlay.accent_brush);
fill(.{s.left - border, s.bottom, s.right + border, s.bottom + border}, overlay.accent_brush);
fill(.{s.left - border, s.top, s.left, s.bottom}, overlay.accent_brush);
fill(.{s.right, s.top, s.right + border, s.bottom}, overlay.accent_brush);
// The outline just outside it, so every pixel taken shows as it is; dashed when too small to record.
outline := RECT.[
.{s.left - border, s.top - border, s.right + border, s.top},
.{s.left - border, s.bottom, s.right + border, s.bottom + border},
.{s.left - border, s.top, s.left, s.bottom},
.{s.right, s.top, s.right + border, s.bottom},
];
for outline {
if !layout.too_small { fill(it, overlay.accent_brush); continue; }
dash := max(cast(s32) (DASH * overlay.scale + 0.5), 2);
step := dash + max(cast(s32) (GAP * overlay.scale + 0.5), 2);
across := it.right - it.left > it.bottom - it.top;
start := ifx across then it.left else it.top;
end := ifx across then it.right else it.bottom;
position := start;
while position < end {
piece := it;
if across { piece.left = position; piece.right = min(position + dash, end); }
else { piece.top = position; piece.bottom = min(position + dash, end); }
fill(piece, overlay.accent_brush);
position += step;
}
}
} else {
fill(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}, overlay.accent_brush);
fill(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}, overlay.accent_brush);

238
src/app/screen_record.jai Normal file
View File

@ -0,0 +1,238 @@
// Screen recording (video-capture-plan.html): Ctrl+PrintScreen (the record_region global hotkey, from
// any app, even with mcap hidden in the tray) freezes the screens as PrintScreen does, in the
// overlay's video mode (app/screen_capture_win32.jai): a region of one screen, at least 240 x 180, or
// a click for the whole screen. It is recorded with modules/Screen_Record to an MP4 in the temporary
// folder until Ctrl+PrintScreen is pressed again. The MP4 then goes into the library as a video .mcap
// in SCREEN_CAPTURE_FOLDER, beside the stills, through app/screen_capture.jai's jobs: the MP4 as its
// 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.
//
// 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.
//
// Windows only, as modules/Screen_Record is.
SCREEN_RECORD_FPS :: 30;
screen_record: struct {
recording: bool;
flag_started: bool; // -record has started its recording
flag_start: Apollo_Time;
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
#if OS == .WINDOWS recorder: Recorder;
}
// From app_frame when the hotkey fired, outside the ImGui frame: starts recording, or stops.
screen_record_toggle :: () {
if screen_record.recording screen_record_stop();
else screen_record_start();
}
// Every frame (from screen_capture_frame): a recording that stopped by itself is finished and kept.
screen_record_frame :: () {
if app.record_and_exit > 0 screen_record_flag_frame();
#if OS == .WINDOWS {
if !screen_record.recording return;
failed, why := record_failed(*screen_record.recorder);
if failed {
toast_show(.Error, "The recording stopped: %", why);
screen_record_stop();
}
}
}
// -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.
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
screen_record.flag_started = true;
screen_record.flag_start = current_time_monotonic();
screen_record_start(immediate = true);
if !screen_record.recording {
log_error("[record] -record could not start.");
app.exit_code = 1; app.should_exit = true; app.record_and_exit = 0;
}
return;
}
if screen_record.recording && to_float64_seconds(current_time_monotonic() - screen_record.flag_start) >= app.record_and_exit {
if array_find(app.args, "-record-quit") { log("[record] -record-quit: quitting while recording."); app.should_exit = true; app.record_and_exit = 0; }
else screen_record_stop();
}
}
// A recording is in the library (path) or could not be put there (error): -record's end.
screen_record_saved :: (path: string, error: string) {
if app.record_and_exit <= 0 return;
if error log_error("[record] -record failed: %", error);
else log("[record] -record is done: %", path);
app.exit_code = ifx error then cast(s32) 1 else 0;
app.should_exit = true;
app.record_and_exit = 0;
}
// On the shutdown thread: a recording running is stopped and kept.
screen_record_shutdown :: () {
if screen_record.recording screen_record_stop(quiet = true);
}
#if OS == .WINDOWS {
// immediate: the screen under the cursor, without the overlay (-record).
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);
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);
make_directory_if_it_does_not_exist(folder, recursive = true);
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
path := tprint("%/recording-%.mp4", folder, stamp);
number := 2;
while file_exists(path) { path = tprint("%/recording-%_%.mp4", folder, 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;
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);
toast_show(.Error, "Could not record: %", start_error);
screen_shot_free(*shot);
return;
}
stats := record_stats(*screen_record.recorder);
screen_record.recording = true;
screen_record.shot = shot;
screen_record.date = copy_string(date,, context.default_allocator);
screen_record.path = copy_string(path,, context.default_allocator);
if stats.fell_back_from log("[record] Desktop Duplication could not run: %", stats.fell_back_from);
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());
}
// Stops, and hands the MP4 to a job that puts it in the library. quiet: no toasts (shutdown).
screen_record_stop :: (quiet := false) {
if !screen_record.recording return;
screen_record.recording = false;
stopped, stop_error := record_stop(*screen_record.recorder);
stats := screen_record.recorder.stats;
if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
log("[record] Stopped after % s: % frames written, % dropped, % KB.", FF(stats.seconds, 1), stats.written, stats.dropped, stats.bytes / 1024);
if !stats.written {
log_error("[record] Nothing was recorded; % is deleted.", screen_record.path);
screen_record_saved("", "nothing was recorded");
if !quiet toast_show(.Error, "Nothing was recorded");
file_delete(screen_record.path);
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 = "", "";
return;
}
job := New(Screen_Capture_Job,, context.default_allocator);
job.video = true;
job.shot = screen_record.shot;
job.shot.width, job.shot.height = stats.width, stats.height; // as recorded: rounded down to even
job.date = screen_record.date;
job.recording = screen_record.path;
job.encoder = copy_string(stats.encoder,, context.default_allocator);
job.source = copy_string(stats.source,, context.default_allocator);
job.dropped = stats.dropped;
screen_record.shot = .{};
screen_record.date, screen_record.path = "", "";
screen_capture_job_start(job);
}
// The recording's .mcap in the library, from the job's MP4, which is deleted once it is in: its path,
// or why it could not be (temp). On the job's thread.
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
_, size, found := file_modtime_and_size(job.recording);
if !found || size <= 0 return "", tprint("% is missing", job.recording);
image, video, decoded, decode_error := video_poster(job.recording);
if !decoded return "", tprint("the recording cannot be read back: %", decode_error);
defer image_free(*image);
preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE);
if !preview_ok return "", preview_error;
poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size
if !poster_ok return "", poster_error;
hash, hashed := import_hash_file(.{path = job.recording, offset = 0, size = size});
if !hashed return "", tprint("% cannot be read", job.recording);
shot := job.shot;
doc := New(Capture_Document,, temp);
capture_document_init(doc, temp);
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
metadata := capture_add_file(doc, "metadata.json");
import_add(doc, metadata, "AppName", import_text(shot.app_name));
import_add(doc, metadata, "CaptureDate", job.date);
import_add(doc, metadata, "WindowName", import_text(shot.window_title));
info := capture_add_file(doc, "capture.json");
import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection");
import_add_number(doc, info, "Left", xx shot.left);
import_add_number(doc, info, "Top", xx shot.top);
import_add_number(doc, info, "Width", xx shot.width);
import_add_number(doc, info, "Height", xx shot.height);
if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path));
import_add(doc, info, "ContentHash", import_hash_text(hash));
import_add(doc, info, "Format", "MP4");
import_add_number(doc, info, "Duration", import_milliseconds(video.duration));
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));
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
name := import_out_name(job.date, hash);
text, text_ok := import_document_text(doc, name);
if !text_ok return "", "its Capture Text does not read back cleanly (see the Event Log)";
m: Mcap;
m.text = text;
m.preview = .{width = cast(u16) preview_width, height = cast(u16) preview_height, png = preview};
m.original_name = tprint("%.mp4", path_strip_extension(name));
m.rendered_kind = .OWN;
m.rendered = poster;
path, folder_error := screen_capture_out_path(name);
if folder_error return "", folder_error;
written, write_error := mcap_write_streamed(path, m, .{path = job.recording, offset = 0, size = size});
if !written return "", write_error;
file_delete(job.recording);
return path, "";
}
// The record_region hotkey as it is bound, for the toast: "Ctrl+PrintScreen". Temp.
screen_record_hotkey_text :: () -> string {
if !record_region_hotkey.binding_count return "the record_region hotkey";
return to_string(record_region_hotkey.bindings[0]);
}
} else {
screen_record_start :: (immediate := false) {
toast_show(.Error, "Screen recording is not available on this system yet");
if app.record_and_exit > 0 screen_record_saved("", "screen recording is Windows only");
}
screen_record_stop :: (quiet := false) {}
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
return "", "screen recording is Windows only";
}
}

View File

@ -7,7 +7,7 @@
checks_run := 0;
checks_failed := 0;
MAX_GROUPS :: 12;
MAX_GROUPS :: 16;
group_names : [MAX_GROUPS] string;
group_checks : [MAX_GROUPS] int;
group_fails : [MAX_GROUPS] int;
@ -561,7 +561,10 @@ segment_overlay_tests :: () {
expect(same(segment_class_colour("person", 0), segment_class_default_colour("person")), "other classes keep their own");
segment_colour_set("dog", .[0, 0, 0], default = true, save = false);
expect(same(segment_class_colour("dog", 0), own), "back to its own colour");
expect(!segment_ui.overlay_state.colours.count, "and nothing chosen is left");
// Only dog's choice is gone: the colours chosen in the app's own data folder are loaded too.
dog_chosen := false;
for segment_ui.overlay_state.colours if it.class == "dog" dog_chosen = true;
expect(!dog_chosen, "and its choice is gone");
hidden := segment_overlay_pixels(*overlay, .["person"], 0.5);
expect(pixel(hidden, 0, 0)[3] == 0 && pixel(hidden, 2, 1)[3] == 0, "a hidden class is clear");
@ -670,6 +673,85 @@ screen_capture_tests :: () {
file_delete(again);
}
// A screen recording written into the library (app/screen_record.jai): the MP4 as its original, a
// frame from it as its rendered image, and capture.json saying it is a video, as the catalog reads it.
// The MP4 is made here with modules/Screen_Record's encoder, from frames of a changing colour.
screen_record_tests :: () {
group("screen recording");
#if OS == .WINDOWS {
expect(record_size_allowed(240, 180), "240 x 180 may be recorded");
expect(!record_size_allowed(239, 180) && !record_size_allowed(240, 179), "smaller may not");
expect(record_size_allowed(3840, 2160), "a 4K screen may");
folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
library := tprint("%/library", folder);
saved_library := app.library_folder;
app.library_folder = library;
defer app.library_folder = saved_library;
make_directory_if_it_does_not_exist(folder, recursive = true);
WIDTH :: 320;
HEIGHT :: 240;
FRAMES :: 30;
mp4 := tprint("%/recording-test.mp4", folder);
encoder: Encoder;
opened, open_error := encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
expect(opened, "an encoder opened: %", open_error);
if !opened return;
pixels := NewArray(WIDTH * HEIGHT * 4, u8,, temp);
for frame: 0..FRAMES - 1 {
for i: 0..WIDTH * HEIGHT - 1 {
pixels[i * 4 + 0] = cast,trunc(u8) (frame * 8);
pixels[i * 4 + 1] = cast,trunc(u8) (i % WIDTH);
pixels[i * 4 + 2] = 128;
pixels[i * 4 + 3] = 255;
}
encoder_write(*encoder, pixels.data, frame * 3000); // 30 a second, in 90 kHz ticks
}
encoder_name := copy_string(encoder.name,, temp);
closed, close_error := encoder_close(*encoder);
expect(closed, "the MP4 finished: %", close_error);
_, mp4_size, _ := file_modtime_and_size(mp4);
job: Screen_Capture_Job;
job.video = true;
job.recording = mp4;
job.date = "2026-10-10 15:20:00";
job.encoder = encoder_name;
job.source = "DXGI DuplicateOutput1 on a test";
job.shot.width, job.shot.height = WIDTH, HEIGHT;
job.shot.left, job.shot.top = 100, 200;
job.shot.window_title = "Recording test - KiCad";
job.shot.app_name = "KiCad";
path, error := screen_record_write(*job);
expect(!error, "written: %", error);
if error return;
expect(begins_with(path, tprint("%/%/2026-10-10_15-20-00_", library, SCREEN_CAPTURE_FOLDER)), "named by its date, in its folder: %", path);
expect(!file_exists(mp4), "the MP4 is deleted once it is in the library");
m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
expect(parsed, "it reads back");
if parsed {
expect(m.original.count == mp4_size && ends_with(m.original_name, ".mp4"), "the MP4 is its original: % of % bytes, %", m.original.count, mp4_size, m.original_name);
expect(m.rendered_kind == .OWN, "a frame of it is its rendered image");
poster, decoded := image_decode(m.rendered);
expect(decoded && poster.width == WIDTH && poster.height == HEIGHT, "at the recording's size");
if decoded image_free(*poster);
}
entry, entry_error := catalog_load(path);
expect(entry != null, "catalogued: %", entry_error);
if entry {
expect(entry.media == .VIDEO, "a video, from capture.json");
expect(entry.video_codec == "h264", "H.264: %", entry.video_codec);
expect(entry.duration > 0.9 && entry.duration < 1.1, "about a second: %", entry.duration);
expect(entry.title == "Recording test - KiCad" && entry.app == "KiCad", "the window it was recorded from");
catalog_entry_free(entry);
}
file_delete(path);
}
}
// What mcap_chunks_size counts, here where it cannot be seen.
mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 {
size: s64 = 4;
@ -694,5 +776,6 @@ run_import_tests :: (builder: *String_Builder = null) -> bool {
library_folders_tests();
segment_overlay_tests();
screen_capture_tests();
screen_record_tests();
return report();
}

View File

@ -309,14 +309,17 @@ hotkeys_missing_lines :: (actions: [] Hotkey_Action, bindings: [] Hotkey_Binding
// RegisterEventHotKey on macOS, none on Linux. They are never matched against ImGui's key state.
bring_to_foreground_hotkey: Hotkey;
capture_region_hotkey: Hotkey;
record_region_hotkey: Hotkey;
GLOBAL_HOTKEY_BRING_TO_FOREGROUND :: 0; // indices into global_hotkey_actions
GLOBAL_HOTKEY_CAPTURE_REGION :: 1;
GLOBAL_HOTKEY_ACTION_COUNT :: 2; // sizes the platforms' registration arrays
GLOBAL_HOTKEY_RECORD_REGION :: 2;
GLOBAL_HOTKEY_ACTION_COUNT :: 3; // sizes the platforms' registration arrays
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false});
array_add(out, .{"capture_region", *capture_region_hotkey, true}); // app/screen_capture.jai
array_add(out, .{"record_region", *record_region_hotkey, true}); // app/screen_record.jai
}
global_hotkey_actions: [..] Hotkey_Action;
@ -408,6 +411,7 @@ global_hotkey_fired :: (id: s64) #c_call {
action := (id - 1) / MAX_HOTKEY_BINDINGS;
if action == GLOBAL_HOTKEY_BRING_TO_FOREGROUND app.restore_window_this_frame = true;
if action == GLOBAL_HOTKEY_CAPTURE_REGION app.capture_region_this_frame = true;
if action == GLOBAL_HOTKEY_RECORD_REGION app.record_region_this_frame = true;
}
// Parses the lines of section in data, or every line when data has no such header. Returns the

View File

@ -68,7 +68,7 @@
<header>
<h1>Screen video capture in mcap</h1>
<p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 and 2 built, mcap 0.1.4</strong></p>
<p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 to 3 built, mcap 0.1.5</strong></p>
</header>
<div class="summary">
@ -82,6 +82,7 @@
<li>It's built in five phases. Phase 1 is a command-line probe that answers the two-GPU question on your laptop before anything depends on it.</li>
<li><span class="tag decided">Done</span> <strong>Phase 1</strong> (2026-10-10, mcap 0.1.3): <code>modules/Screen_Record</code> and <code>record-probe</code>. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see <a href="#phases">phase 1's result</a>).</li>
<li><span class="tag decided">Done</span> <strong>Phase 2</strong> (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated <code>DXGI_ERROR_UNSUPPORTED</code>. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11.</li>
<li><span class="tag decided">Done</span> <strong>Phase 3</strong> (2026-10-10, mcap 0.1.5): recording in the app. Ctrl+PrintScreen opens the overlay's video mode; Ctrl+PrintScreen again stops; the recording becomes a video <code>.mcap</code> in Screen Captures. In the app, Desktop Duplication first failed with <code>DXGI_ERROR_UNSUPPORTED</code>: only the main thread was DPI aware. The capture thread now makes itself aware.</li>
<li><span class="tag later">Later</span> Audio is phase 5. Recording one window, and regions that span two screens, come after.</li>
</ul>
</div>
@ -362,7 +363,7 @@ Captured_Frame :: struct {
</div>
<div class="phase">
<h3>Phase 3: Recording in the app</h3>
<h3>Phase 3: Recording in the app <span class="tag decided">Done</span></h3>
<p>The pieces:</p>
<ul>
<li>The <code>record_region</code> action, which starts a recording or, during one, stops it.</li>
@ -379,6 +380,35 @@ Captured_Frame :: struct {
<li>A 239 × 300 drag doesn't start a recording.</li>
<li>Quitting mid-recording keeps it.</li>
</ul>
<div class="note"><strong>Result (2026-10-10).</strong>
<ul>
<li><strong>Built:</strong>
<ul>
<li><code>src/app/screen_record.jai</code>: start, stop, and writing the recording's <code>.mcap</code>.</li>
<li>The overlay's video mode, in <code>screen_capture_win32.jai</code>.</li>
<li>The <code>record_region</code> hotkey, <code>Ctrl-PrintScreen</code> by default. Existing settings files get it appended, as yours did.</li>
<li>The catalog reading a video's keys from <code>capture.json</code> as well as <code>import.json</code>.</li>
<li>Saving reuses the stills' jobs: a thread copies the MP4 into the <code>.mcap</code> with <code>mcap_write_streamed</code>, then the grid, the Capture window and the OCR and label scan take it as they take a still.</li>
</ul></li>
<li><strong>A real bug, which the fallback hid:</strong> the first run in the app recorded with Windows.Graphics.Capture. Desktop Duplication had failed with <code>DXGI_ERROR_UNSUPPORTED</code>, which <code>record-probe</code> never saw. The cause is that ImGui's <code>EnableDpiAwareness</code> sets per-monitor DPI awareness for the main thread only, and Desktop Duplication refuses a thread that isn't aware. The capture thread now makes itself aware (<code>gpu_thread_dpi_aware</code>), and the app records with <code>DuplicateOutput1</code>. This also answers phase 1's question: the plain call isn't what fails for an unaware thread; both do.</li>
<li><strong>End to end</strong>, with <code>-record 5</code> on a scratch library: the internal screen (2560 × 1600), Desktop Duplication, NVENC, 5 s. Its <code>.mcap</code> lands in Screen Captures, and the temporary MP4 is deleted.</li>
<li><strong>Quitting while recording</strong> (<code>-record 4 -record-quit</code>): the shutdown stopped the recording, saved it and finished in 0.6 s.</li>
<li><strong>Tests:</strong> a "screen recording" group of 17 checks in the import suite:
<ul>
<li>The size rule: 240 × 180 allowed, 239 × 180 and 240 × 179 not.</li>
<li>An MP4 made with the module's encoder, written as a recording, reads back with the MP4 as its original and a frame of it, at its size, as its rendered image.</li>
<li>The catalog takes it as an H.264 video of about a second, with its window's title and app.</li>
</ul>
Every suite passes.</li>
<li><strong>Not checked by me: the overlay's video mode by hand.</strong> That means the red outline, the dashed outline under 240 × 180 and letting go of it, the drag held to one screen, and Ctrl+PrintScreen to start and stop. It needs someone at the keyboard and mouse.</li>
</ul>
<p><strong>Changes from the plan:</strong></p>
<ul>
<li><strong>PrintScreen while recording</strong> takes the whole screen under the cursor at once, without the overlay: open question 1's answer, for now.</li>
<li><strong><code>-record SECONDS</code></strong> (and <code>-record-quit</code>) on the app's command line, for end-to-end checks like the existing <code>-scan</code>.</li>
<li><strong>No indicator yet:</strong> a toast says recording started and which key stops it. The border and toolbar are phase 4.</li>
<li><strong>A settings-dependent test fixed:</strong> the segment overlay test expected no segment colours chosen, but it loads the app's real <code>segment-colours.config</code>, where you had chosen one. It now checks only the colour it set.</li>
</ul></div>
</div>
<div class="phase">