diff --git a/README.md b/README.md
index 1d9355e..4b662e6 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/assets/data/default_global_hotkeys.config b/assets/data/default_global_hotkeys.config
index da33f91..78f39ad 100644
--- a/assets/data/default_global_hotkeys.config
+++ b/assets/data/default_global_hotkeys.config
@@ -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
diff --git a/assets/data/default_global_hotkeys_macos.config b/assets/data/default_global_hotkeys_macos.config
index 7c4b25e..2247969 100644
--- a/assets/data/default_global_hotkeys_macos.config
+++ b/assets/data/default_global_hotkeys_macos.config
@@ -6,3 +6,4 @@
none bring_to_foreground
none capture_region
+none record_region
diff --git a/check_targets.jai b/check_targets.jai
index 1eb1126..05bf049 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.4";
+VERSION :: "0.1.5";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;
diff --git a/first.jai b/first.jai
index 48f6b14..10d5f41 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.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";
diff --git a/modules/Capture_File/README.md b/modules/Capture_File/README.md
index 147969c..c36881c 100644
--- a/modules/Capture_File/README.md
+++ b/modules/Capture_File/README.md
@@ -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
diff --git a/modules/Screen_Record/capture_gpu.jai b/modules/Screen_Record/capture_gpu.jai
index 2cbc44c..4b64b09 100644
--- a/modules/Screen_Record/capture_gpu.jai
+++ b/modules/Screen_Record/capture_gpu.jai
@@ -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; }
}
diff --git a/modules/Screen_Record/module.jai b/modules/Screen_Record/module.jai
index 2788c77..ec12bc8 100644
--- a/modules/Screen_Record/module.jai
+++ b/modules/Screen_Record/module.jai
@@ -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);
diff --git a/src/App.jai b/src/App.jai
index 848e8c7..76438b7 100644
--- a/src/App.jai
+++ b/src/App.jai
@@ -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;
diff --git a/src/app/catalog.jai b/src/app/catalog.jai
index 75e8c55..a561c7e 100644
--- a/src/app/catalog.jai
+++ b/src/app/catalog.jai
@@ -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");
diff --git a/src/app/main.jai b/src/app/main.jai
index 328b1e9..292af2a 100644
--- a/src/app/main.jai
+++ b/src/app/main.jai
@@ -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();
diff --git a/src/app/screen_capture.jai b/src/app/screen_capture.jai
index 1a245b8..d168721 100644
--- a/src/app/screen_capture.jai
+++ b/src/app/screen_capture.jai
@@ -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";
}
diff --git a/src/app/screen_capture_win32.jai b/src/app/screen_capture_win32.jai
index c451c8f..3967183 100644
--- a/src/app/screen_capture_win32.jai
+++ b/src/app/screen_capture_win32.jai
@@ -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);
diff --git a/src/app/screen_record.jai b/src/app/screen_record.jai
new file mode 100644
index 0000000..a2c6975
--- /dev/null
+++ b/src/app/screen_record.jai
@@ -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";
+}
+
+}
diff --git a/src/test/import_tests.jai b/src/test/import_tests.jai
index 0afcc79..cdcc0bd 100644
--- a/src/test/import_tests.jai
+++ b/src/test/import_tests.jai
@@ -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();
}
diff --git a/src/ui/imgui_keyboard.jai b/src/ui/imgui_keyboard.jai
index 476d76f..3edf8f5 100644
--- a/src/ui/imgui_keyboard.jai
+++ b/src/ui/imgui_keyboard.jai
@@ -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
diff --git a/video-capture-plan.html b/video-capture-plan.html
index 8bf0cf2..36e8360 100644
--- a/video-capture-plan.html
+++ b/video-capture-plan.html
@@ -68,7 +68,7 @@
Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 and 2 built, mcap 0.1.4 Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 to 3 built, mcap 0.1.5Screen video capture in mcap
-
modules/Screen_Record and record-probe. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see phase 1's result).DXGI_ERROR_UNSUPPORTED. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11..mcap in Screen Captures. In the app, Desktop Duplication first failed with DXGI_ERROR_UNSUPPORTED: only the main thread was DPI aware. The capture thread now makes itself aware.The pieces:
record_region action, which starts a recording or, during one, stops it.src/app/screen_record.jai: start, stop, and writing the recording's .mcap.screen_capture_win32.jai.record_region hotkey, Ctrl-PrintScreen by default. Existing settings files get it appended, as yours did.capture.json as well as import.json..mcap with mcap_write_streamed, then the grid, the Capture window and the OCR and label scan take it as they take a still.DXGI_ERROR_UNSUPPORTED, which record-probe never saw. The cause is that ImGui's EnableDpiAwareness sets per-monitor DPI awareness for the main thread only, and Desktop Duplication refuses a thread that isn't aware. The capture thread now makes itself aware (gpu_thread_dpi_aware), and the app records with DuplicateOutput1. This also answers phase 1's question: the plain call isn't what fails for an unaware thread; both do.-record 5 on a scratch library: the internal screen (2560 × 1600), Desktop Duplication, NVENC, 5 s. Its .mcap lands in Screen Captures, and the temporary MP4 is deleted.-record 4 -record-quit): the shutdown stopped the recording, saved it and finished in 0.6 s.Changes from the plan:
+-record SECONDS (and -record-quit) on the app's command line, for end-to-end checks like the existing -scan.segment-colours.config, where you had chosen one. It now checks only the colour it set.