[0.1.6] Video capture phase 4: border, toolbar, pause, discard, recovery, leftovers, [video]
- src/app/screen_record_win32.jai: while recording, a red frame just outside the region (one
window shaped by a window region, clicks let through) and a toolbar beside it (below the
region at its right, else above, else inside a whole screen's top right): a blinking dot,
the time less time paused, the size, frames dropped and screen losses, and Pause/Resume,
Stop, and Discard, confirmed in the toolbar itself ("Yes, discard / No"). GDI windows that
never take the focus, kept out of every capture (WDA_EXCLUDEFROMCAPTURE). Buttons only ask;
screen_record_frame acts on the next frame. On the shutdown thread they are hidden, not
destroyed.
- modules/Screen_Record: record_pause; while paused the source is drained but nothing is sent,
and frames' times leave the pause out, so the video has no gap. Record_Stats.paused_seconds;
Record_Testing.LOSE_DUPLICATION loses Desktop Duplication every 3 s.
- Recordings left in %TEMP%/mcap by a crash are offered at startup: into the library (no window
known; size from its frames, date from its name), deleted, or later.
- [video] in settings.config: fps (1-120, 30), cursor (true), capture (auto, dxgi, wgc); written
into existing files on the next launch.
- The Win32 GDI bindings move to src/base/platform/win32_gdi_bindings.jai, shared by the overlay
and the toolbar.
- -record-region X Y W H and -record-ui-visible for -record; -pause SECONDS and -lose for
record-probe. capture.json's Paused.
- Checked: the toolbar is not in a whole-screen recording's frame; a 10 s pause leaves a 6.2 s
video of a 16 s run at a steady 30 fps (DXGI and WGC); 3 simulated losses in 10 s recover with
no gap. Tests: a leftover's date and its write (22 checks in the recording group); all pass.
- Fixed on the way: a recording's path repeated the temporary folder ("%1" is the first argument).
This commit is contained in:
parent
93274a2b04
commit
59c5b8dd62
17
README.md
17
README.md
@ -188,7 +188,11 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
|
||||
- Escape or a right click cancels.
|
||||
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
|
||||
only when the screen changes, so a still screen costs almost nothing.
|
||||
4. **Ctrl+PrintScreen again** stops it. The video goes into **Screen Captures** beside the stills,
|
||||
- **A red border** is drawn just outside the region.
|
||||
- **A toolbar** beside it shows the time, the size and any frames dropped, with **Pause** (the time
|
||||
paused is left out of the video), **Stop**, and **Discard**, which asks first.
|
||||
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
|
||||
4. **Ctrl+PrintScreen again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
|
||||
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
|
||||
OCR and labels read a frame from it.
|
||||
|
||||
@ -196,8 +200,13 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
|
||||
cannot run (a laptop with two GPUs, a rotated screen).
|
||||
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
|
||||
Foundation, or libx264.
|
||||
- **Losing nothing:** a recording that stops by itself keeps what it has, and so does quitting while
|
||||
recording.
|
||||
- **Losing nothing:**
|
||||
- A recording that stops by itself keeps what it has, and so does quitting while recording.
|
||||
- When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the
|
||||
recording carries on once it is back.
|
||||
- A recording a crash cut short is offered at the next start: into the library, or deleted.
|
||||
- **Settings:** `[video]` in the settings file: `fps` (30), `cursor` (true), and `capture`: `auto`
|
||||
(Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
|
||||
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
|
||||
the screens, which would show in the recording.
|
||||
- **The key** is `record_region` in `[global_hotkeys]`.
|
||||
@ -331,7 +340,7 @@ then in that order instead of by date.
|
||||
| `-search QUERY` | Starts with QUERY in the search box. |
|
||||
| `-scan` | Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed. |
|
||||
| `-stats` | Logs the Stats window's numbers once the catalog has loaded, then exits; with `-search`, for the matches, and the first ten are logged. |
|
||||
| `-record SECONDS` | Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed. `-record-quit` quits while still recording instead, to check the recording is kept. |
|
||||
| `-record SECONDS` | Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed. `-record-region X Y W H` records that region instead. `-record-quit` quits while still recording, to check the recording is kept. `-record-ui-visible` leaves the border and toolbar in screenshots, to look at them. |
|
||||
| `-segment` | Segments the images not yet segmented in the library folders set to segment (see the Segmentation window), once the library has loaded, then exits: 0, or 1 if any image failed. |
|
||||
| `-erase ocr`, `-erase labels`, `-erase segments`, `-erase all` | Erases that from every file in the library, then exits (for testing). |
|
||||
| `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. |
|
||||
|
||||
@ -29,7 +29,7 @@ APP_NAME :: "mcap";
|
||||
APP_FOLDER_NAME :: "mcap";
|
||||
GIT_BRANCH :: "check";
|
||||
GIT_HASH :: "-";
|
||||
VERSION :: "0.1.5";
|
||||
VERSION :: "0.1.6";
|
||||
BUILD_DATE :: "-";
|
||||
DEBUG :: true;
|
||||
COMPILE_TESTS :: true;
|
||||
|
||||
@ -8,7 +8,7 @@ APP_NAME :: "mcap";
|
||||
APP_FOLDER_NAME :: "mcap";
|
||||
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
|
||||
|
||||
VERSION :: "0.1.5";
|
||||
VERSION :: "0.1.6";
|
||||
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
|
||||
// builds never write over the ones you run by hand.
|
||||
build_directory := "bin";
|
||||
|
||||
@ -133,8 +133,12 @@ Encoder: "h264_nvenc" FFmpeg's encoder: h264_nvenc, h264_amf, h264_
|
||||
Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU"
|
||||
or "Windows.Graphics.Capture on ..., border off"
|
||||
DroppedFrames: 0 frames the encoder fell too far behind for
|
||||
Paused: 10.2 seconds paused, left out of the video; only when it was paused
|
||||
```
|
||||
|
||||
A recording a crash cut short, put in the library at the next start, has no window, Encoder or
|
||||
Capture: its Width and Height are its frames', and its CaptureDate is from its file's name.
|
||||
|
||||
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
|
||||
|
||||
## Imported videos
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
// dropped and counted.
|
||||
//
|
||||
// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
|
||||
// one a second while nothing does, so players can seek. Each carries the time it was taken.
|
||||
// one a second while nothing does, so players can seek. Each carries the time it was taken, less the
|
||||
// time spent paused (record_pause), so a pause leaves no gap in the video.
|
||||
//
|
||||
// recorder: Recorder;
|
||||
// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
|
||||
@ -52,6 +53,7 @@ Record_Testing :: enum_flags u8 {
|
||||
DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED)
|
||||
WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler
|
||||
KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on
|
||||
LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
|
||||
}
|
||||
|
||||
Record_Options :: struct {
|
||||
@ -78,6 +80,7 @@ Record_Stats :: struct {
|
||||
written: s64; // encoded
|
||||
dropped: s64; // the encoder was too far behind
|
||||
lost: s64; // times the duplication was lost and made again
|
||||
paused_seconds: float64; // left out of the video
|
||||
copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
|
||||
bytes: s64; // encoded
|
||||
seconds: float64; // from start to stop
|
||||
@ -99,6 +102,7 @@ Recorder :: struct {
|
||||
capture_started, encoder_started: bool;
|
||||
running: bool;
|
||||
stopping: bool; // set by record_stop; the capture thread ends, then the encoder
|
||||
paused: bool; // set by record_pause; the capture thread takes nothing while it is
|
||||
capture_ended: bool;
|
||||
|
||||
opened: Semaphore; // the capture thread has opened its source, or failed to
|
||||
@ -220,6 +224,15 @@ record_stop :: (r: *Recorder) -> ok: bool, error: string {
|
||||
return true, "";
|
||||
}
|
||||
|
||||
// Pauses or resumes. While paused nothing is taken, and the time is left out of the video.
|
||||
record_pause :: (r: *Recorder, pause: bool) {
|
||||
if r.running atomic_swap(*r.paused, pause);
|
||||
}
|
||||
|
||||
record_paused :: (r: *Recorder) -> bool {
|
||||
return r.running && atomic_read(*r.paused);
|
||||
}
|
||||
|
||||
// As they are now; strings are the recorder's.
|
||||
record_stats :: (r: *Recorder) -> Record_Stats {
|
||||
if !r.running return r.stats;
|
||||
@ -318,6 +331,8 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
||||
have_image, changed := false, false;
|
||||
staged: [RECORD_STAGING] Staged;
|
||||
emits: s64;
|
||||
paused_ticks, pause_start: s64; // time paused so far, left out of the frames' times; when this pause began
|
||||
last_lost := ticks_now(); // for Record_Testing.LOSE_DUPLICATION
|
||||
|
||||
while !atomic_read(*r.stopping) {
|
||||
now := ticks_now();
|
||||
@ -327,6 +342,9 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
||||
error: string;
|
||||
if r.using_wgc next, error = wgc_next(*r.wgc, wait);
|
||||
else next, error = dxgi_next(*r.dxgi, wait);
|
||||
if r.options.testing & .LOSE_DUPLICATION && !r.using_wgc && ticks_now() - last_lost >= 3 * r.frequency {
|
||||
next, last_lost = Capture_Next.LOST, ticks_now();
|
||||
}
|
||||
if next == {
|
||||
case .FAILED;
|
||||
record_fail(r, error);
|
||||
@ -359,7 +377,20 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
||||
}
|
||||
}
|
||||
|
||||
// Paused: the source is still drained, so it never stalls, but nothing is sent. On resuming, the
|
||||
// screen as it is now is sent at once.
|
||||
now = ticks_now();
|
||||
if atomic_read(*r.paused) {
|
||||
if !pause_start pause_start = now;
|
||||
lock(*r.mutex); r.stats.paused_seconds = cast(float64) (paused_ticks + now - pause_start) / r.frequency; unlock(*r.mutex);
|
||||
continue;
|
||||
}
|
||||
if pause_start {
|
||||
paused_ticks += now - pause_start;
|
||||
pause_start = 0;
|
||||
changed = true;
|
||||
turn = now;
|
||||
}
|
||||
if !have_image continue;
|
||||
repeat := !changed && now - last_emit >= r.frequency;
|
||||
if !(changed && now >= turn - early) && !repeat continue;
|
||||
@ -369,7 +400,7 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
||||
slot := emits % RECORD_STAGING;
|
||||
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
|
||||
ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
|
||||
staged[slot] = .{pending = true, ticks = now};
|
||||
staged[slot] = .{pending = true, ticks = now - paused_ticks};
|
||||
if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
|
||||
emits += 1;
|
||||
last_emit = now;
|
||||
|
||||
@ -30,6 +30,7 @@ USING_WINDOWS :: OS == .WINDOWS;
|
||||
#load "app/screen_capture.jai";
|
||||
#load "app/screen_record.jai";
|
||||
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
|
||||
#if OS == .WINDOWS #load "app/screen_record_win32.jai";
|
||||
|
||||
#load "base/circle_buffer.jai";
|
||||
#load "base/debug.jai";
|
||||
@ -200,6 +201,7 @@ App :: struct {
|
||||
start_search: string; // -search QUERY: the search box's text at startup
|
||||
erase_and_exit: string; // -erase ocr|labels|segments|all: erase that from the whole library once it has loaded, then exit
|
||||
record_and_exit: float64; // -record SECONDS: record the screen under the cursor that long, put it in the library, then exit (0, or 1 if it failed)
|
||||
record_region: RECT; // -record-region X Y W H: -record records that, on the virtual desktop, not the whole screen
|
||||
segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed)
|
||||
import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit
|
||||
last_window_restore_time: Apollo_Time;
|
||||
@ -267,6 +269,7 @@ App :: struct {
|
||||
|
||||
#load "base/platform/platform_windows.jai";
|
||||
#load "base/platform/win32_bindings.jai";
|
||||
#load "base/platform/win32_gdi_bindings.jai";
|
||||
#load "graphics/DX11.jai";
|
||||
|
||||
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
|
||||
|
||||
@ -11,6 +11,7 @@ app_ui_startup :: () {
|
||||
ocr_ui_startup();
|
||||
labels_ui_startup();
|
||||
segment_ui_startup(); // the library folders' options: after the viewer, which knows the library
|
||||
screen_record_startup(); // recordings a crash left behind, to offer
|
||||
}
|
||||
|
||||
// Every frame, before input is handed to ImGui. Poll background work here.
|
||||
@ -99,4 +100,5 @@ app_main_window_ui :: () {
|
||||
segment_ui_frame();
|
||||
stats_ui_frame();
|
||||
import_frame();
|
||||
screen_record_leftovers_popup();
|
||||
}
|
||||
|
||||
@ -26,6 +26,10 @@ main :: () {
|
||||
app.scan_and_exit = array_find(app.args, "-scan");
|
||||
app.segment_and_exit = array_find(app.args, "-segment");
|
||||
for app.args if it == "-record" && it_index + 1 < app.args.count app.record_and_exit = max(string_to_float64(app.args[it_index + 1]), 1.0);
|
||||
for app.args if it == "-record-region" && it_index + 4 < app.args.count {
|
||||
x, y := cast(s32) string_to_int(app.args[it_index + 1]), cast(s32) string_to_int(app.args[it_index + 2]);
|
||||
app.record_region = .{x, y, x + cast(s32) string_to_int(app.args[it_index + 3]), y + cast(s32) string_to_int(app.args[it_index + 4])};
|
||||
}
|
||||
app.stats_and_exit = array_find(app.args, "-stats");
|
||||
for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1];
|
||||
for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1];
|
||||
|
||||
@ -22,6 +22,7 @@ Screen_Capture_Job :: struct {
|
||||
encoder: string; // "h264_nvenc"
|
||||
source: string; // how the screen was taken: Record_Stats.source
|
||||
dropped: s64; // frames the encoder fell too far behind for
|
||||
paused_seconds: float64; // left out of it
|
||||
|
||||
thread: Thread;
|
||||
busy: bool; // cleared by the thread as it ends
|
||||
|
||||
@ -658,59 +658,3 @@ overlay_file_description :: (path: *u16) -> string {
|
||||
text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator
|
||||
return ifx ok then trim(text) else "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bindings
|
||||
|
||||
gdi32 :: #library,system "gdi32";
|
||||
version_dll :: #library,system "version";
|
||||
|
||||
HFONT :: HANDLE;
|
||||
HRGN :: HANDLE;
|
||||
|
||||
PAINTSTRUCT :: struct {
|
||||
hdc: HDC;
|
||||
fErase: BOOL;
|
||||
rcPaint: RECT;
|
||||
fRestore: BOOL;
|
||||
fIncUpdate: BOOL;
|
||||
rgbReserved: [32] u8;
|
||||
}
|
||||
|
||||
CAPTUREBLT :: 0x40000000;
|
||||
GA_ROOT :: 2;
|
||||
WA_INACTIVE :: 0;
|
||||
WM_ACTIVATE :: 0x0006;
|
||||
TRANSPARENT :: 1;
|
||||
CLEARTYPE_QUALITY :: 5;
|
||||
PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000;
|
||||
|
||||
GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32;
|
||||
BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32;
|
||||
EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32;
|
||||
FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32;
|
||||
GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32;
|
||||
GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32;
|
||||
GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32;
|
||||
GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32;
|
||||
WindowFromPoint :: (point: POINT) -> HWND #foreign user32;
|
||||
GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32;
|
||||
capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos";
|
||||
|
||||
CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32;
|
||||
CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32;
|
||||
SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32;
|
||||
SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32;
|
||||
SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32;
|
||||
TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32;
|
||||
GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32;
|
||||
CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32,
|
||||
strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32,
|
||||
pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32;
|
||||
|
||||
OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32;
|
||||
QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32;
|
||||
GlobalFree :: (memory: HGLOBAL) -> HGLOBAL #foreign kernel32;
|
||||
|
||||
GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll;
|
||||
GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll;
|
||||
VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll;
|
||||
|
||||
@ -7,18 +7,26 @@
|
||||
// original, a frame from it as its rendered image (which OCR and labels read), the window it was
|
||||
// recorded from in metadata.json, as for a still, and how it was recorded in capture.json.
|
||||
//
|
||||
// While it records, a red border is drawn just outside the region and a toolbar beside it gives the
|
||||
// time, Pause, Stop and Discard (app/screen_record_win32.jai); both are kept out of the recording.
|
||||
// Time paused is left out of the video. The frame rate, the cursor and how the screen is taken are
|
||||
// [video] in the settings file.
|
||||
//
|
||||
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
|
||||
// so does quitting while recording. PrintScreen while recording takes the screen under the cursor at
|
||||
// once, without the overlay, whose frozen screen would be in the recording.
|
||||
// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
|
||||
// start (screen_record_leftovers_popup). PrintScreen while recording takes the screen under the cursor
|
||||
// at once, without the overlay, whose frozen screen would be in the recording.
|
||||
//
|
||||
// Windows only, as modules/Screen_Record is.
|
||||
|
||||
SCREEN_RECORD_FPS :: 30;
|
||||
SCREEN_RECORD_PREFIX :: "recording-"; // the MP4s in the temporary folder: recording-2026-10-10_07-12-43.mp4
|
||||
|
||||
screen_record: struct {
|
||||
recording: bool;
|
||||
flag_started: bool; // -record has started its recording
|
||||
flag_start: Apollo_Time;
|
||||
leftovers: [..] string; // MP4s a crash left in the temporary folder, to offer; heap
|
||||
leftovers_asked: bool;
|
||||
shot: Screen_Shot; // the region and the window it is of; no pixels
|
||||
date: string; // when it started, as CaptureDate; heap
|
||||
path: string; // the MP4 being written; heap
|
||||
@ -40,13 +48,25 @@ screen_record_frame :: () {
|
||||
if failed {
|
||||
toast_show(.Error, "The recording stopped: %", why);
|
||||
screen_record_stop();
|
||||
return;
|
||||
}
|
||||
if record_ui_take_ask() == {
|
||||
case .PAUSE;
|
||||
pause := !record_paused(*screen_record.recorder);
|
||||
record_pause(*screen_record.recorder, pause);
|
||||
log("[record] %.", ifx pause then "Paused" else "Resumed");
|
||||
record_ui_refresh();
|
||||
case .STOP; screen_record_stop();
|
||||
case .DISCARD; screen_record_discard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -record SECONDS: the screen under the cursor, without the overlay, for that long; then
|
||||
// screen_record_saved ends the app once it is in the library. With -record-quit, the app is quit while
|
||||
// still recording instead, as quitting from the tray would: the shutdown keeps the recording.
|
||||
// -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
|
||||
// -record-region X Y W H; then screen_record_saved ends the app once it is in the library. With
|
||||
// -record-quit, the app is quit while still recording instead, as quitting from the tray would: the
|
||||
// shutdown keeps the recording. -record-ui-visible leaves the border and toolbar in screenshots, to
|
||||
// look at them.
|
||||
screen_record_flag_frame :: () {
|
||||
if !screen_record.flag_started {
|
||||
if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it
|
||||
@ -77,7 +97,81 @@ screen_record_saved :: (path: string, error: string) {
|
||||
|
||||
// On the shutdown thread: a recording running is stopped and kept.
|
||||
screen_record_shutdown :: () {
|
||||
if screen_record.recording screen_record_stop(quiet = true);
|
||||
if screen_record.recording screen_record_stop(shutdown = true);
|
||||
}
|
||||
|
||||
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
|
||||
screen_record_startup :: () {
|
||||
#if OS == .WINDOWS {
|
||||
screen_record.leftovers.allocator = context.default_allocator;
|
||||
for file_list(screen_record_folder(),, temp) {
|
||||
name := path_filename(it);
|
||||
if !begins_with(name, SCREEN_RECORD_PREFIX) || !ends_with_nocase(name, ".mp4") continue;
|
||||
array_add(*screen_record.leftovers, copy_string(it,, context.default_allocator));
|
||||
}
|
||||
if screen_record.leftovers.count log("[record] % recording% left in % by an earlier run.", screen_record.leftovers.count, ifx screen_record.leftovers.count == 1 then "" else "s", screen_record_folder());
|
||||
}
|
||||
}
|
||||
|
||||
// The offer of what screen_record_startup found: into the library, or deleted, or asked again next
|
||||
// time. A modal popup, at the top level, each frame.
|
||||
screen_record_leftovers_popup :: () { using ImGui;
|
||||
POPUP :: "Unsaved recordings";
|
||||
if !screen_record.leftovers.count || app.record_and_exit > 0 return;
|
||||
if !screen_record.leftovers_asked && !IsPopupOpen(POPUP) { OpenPopup(POPUP); screen_record.leftovers_asked = true; }
|
||||
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
|
||||
count := screen_record.leftovers.count;
|
||||
Text(ifx count == 1 then "A screen recording was not saved: mcap closed while it was recording." else tprint("% screen recordings were not saved: mcap closed while they were recording.", count));
|
||||
for screen_record.leftovers {
|
||||
_, size, _ := file_modtime_and_size(it);
|
||||
TextColored(app_theme.palette.dim, "% (% MB)", screen_record_leftover_date(it), FF(size / (1024.0 * 1024.0), 1));
|
||||
}
|
||||
TextColored(app_theme.palette.dim, "Each plays up to its last two seconds or so.");
|
||||
Spacing();
|
||||
done := false;
|
||||
if Button("Save to the library") {
|
||||
for screen_record.leftovers {
|
||||
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||
job.video = true;
|
||||
job.recording = it; // the job's now
|
||||
job.date = copy_string(screen_record_leftover_date(it),, context.default_allocator);
|
||||
screen_capture_job_start(job);
|
||||
}
|
||||
array_reset_keeping_memory(*screen_record.leftovers);
|
||||
done = true;
|
||||
}
|
||||
SameLine();
|
||||
if Button("Delete") {
|
||||
for screen_record.leftovers { file_delete(it); free(it,, context.default_allocator); }
|
||||
array_reset_keeping_memory(*screen_record.leftovers);
|
||||
done = true;
|
||||
}
|
||||
SameLine();
|
||||
if Button("Later") || IsKeyPressed(.Escape) {
|
||||
for screen_record.leftovers free(it,, context.default_allocator);
|
||||
array_reset_keeping_memory(*screen_record.leftovers);
|
||||
done = true;
|
||||
}
|
||||
if done CloseCurrentPopup();
|
||||
EndPopup();
|
||||
}
|
||||
|
||||
// "2026-10-10 07:12:43" from recording-2026-10-10_07-12-43.mp4 (or _2.mp4 ...): when it began. Temp.
|
||||
screen_record_leftover_date :: (path: string) -> string {
|
||||
name := path_strip_extension(path_filename(path));
|
||||
stamp := advance(name, SCREEN_RECORD_PREFIX.count);
|
||||
if stamp.count < 19 return name;
|
||||
stamp.count = 19;
|
||||
date := copy_string(stamp,, temp);
|
||||
date[10] = #char " ";
|
||||
date[13] = #char ":";
|
||||
date[16] = #char ":";
|
||||
return date;
|
||||
}
|
||||
|
||||
screen_record_folder :: () -> string {
|
||||
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
|
||||
else return tprint("/tmp/%", APP_FOLDER_NAME);
|
||||
}
|
||||
|
||||
#if OS == .WINDOWS {
|
||||
@ -87,23 +181,33 @@ screen_record_start :: (immediate := false) {
|
||||
if app_exit_shutdown_started() return;
|
||||
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
||||
r := app.record_region;
|
||||
if ok && immediate && r.right > r.left { // -record-region
|
||||
shot.left, shot.top, shot.width, shot.height = r.left, r.top, r.right - r.left, r.bottom - r.top;
|
||||
shot.whole_screen = false;
|
||||
}
|
||||
if !ok {
|
||||
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
||||
else log("[record] Cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
folder := tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
|
||||
folder := screen_record_folder();
|
||||
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
|
||||
path := tprint("%/recording-%.mp4", folder, stamp);
|
||||
path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
|
||||
number := 2;
|
||||
while file_exists(path) { path = tprint("%/recording-%_%.mp4", folder, stamp, number); number += 1; }
|
||||
while file_exists(path) { path = tprint("%/%%_%.mp4", folder, SCREEN_RECORD_PREFIX, stamp, number); number += 1; }
|
||||
|
||||
options: Record_Options;
|
||||
options.path = path;
|
||||
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
|
||||
options.fps = SCREEN_RECORD_FPS;
|
||||
options.fps = cast(s32) app_user_settings.video_fps;
|
||||
options.cursor = app_user_settings.video_cursor;
|
||||
if app_user_settings.video_capture == {
|
||||
case "dxgi"; options.capture = .DXGI;
|
||||
case "wgc"; options.capture = .WGC;
|
||||
}
|
||||
started, start_error := record_start(*screen_record.recorder, options);
|
||||
if !started {
|
||||
log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error);
|
||||
@ -121,12 +225,32 @@ screen_record_start :: (immediate := false) {
|
||||
log("[record] Recording % x % at %, % (%) of \"%\" (%) with % and %, to %.", stats.width, stats.height, shot.left, shot.top,
|
||||
ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, stats.source, stats.encoder, path);
|
||||
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
|
||||
record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
|
||||
}
|
||||
|
||||
// Stops, and hands the MP4 to a job that puts it in the library. quiet: no toasts (shutdown).
|
||||
screen_record_stop :: (quiet := false) {
|
||||
// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
|
||||
screen_record_discard :: () {
|
||||
if !screen_record.recording return;
|
||||
screen_record.recording = false;
|
||||
record_ui_close();
|
||||
record_stop(*screen_record.recorder);
|
||||
file_delete(screen_record.path);
|
||||
log("[record] Discarded % (% s).", screen_record.path, FF(screen_record.recorder.stats.seconds, 1));
|
||||
toast_show(.Success, "Recording discarded");
|
||||
screen_shot_free(*screen_record.shot);
|
||||
free(screen_record.date,, context.default_allocator);
|
||||
free(screen_record.path,, context.default_allocator);
|
||||
screen_record.date, screen_record.path = "", "";
|
||||
}
|
||||
|
||||
// Stops, and hands the MP4 to a job that puts it in the library. shutdown: on the shutdown thread, so
|
||||
// no toasts, and the border and toolbar, which are the main thread's, are only hidden.
|
||||
screen_record_stop :: (shutdown := false) {
|
||||
if !screen_record.recording return;
|
||||
screen_record.recording = false;
|
||||
quiet := shutdown;
|
||||
if shutdown record_ui_hide();
|
||||
else record_ui_close();
|
||||
stopped, stop_error := record_stop(*screen_record.recorder);
|
||||
stats := screen_record.recorder.stats;
|
||||
if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
|
||||
@ -153,6 +277,7 @@ screen_record_stop :: (quiet := false) {
|
||||
job.encoder = copy_string(stats.encoder,, context.default_allocator);
|
||||
job.source = copy_string(stats.source,, context.default_allocator);
|
||||
job.dropped = stats.dropped;
|
||||
job.paused_seconds = stats.paused_seconds;
|
||||
screen_record.shot = .{};
|
||||
screen_record.date, screen_record.path = "", "";
|
||||
screen_capture_job_start(job);
|
||||
@ -174,6 +299,7 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
|
||||
if !hashed return "", tprint("% cannot be read", job.recording);
|
||||
|
||||
shot := job.shot;
|
||||
if !shot.width { shot.width, shot.height = image.width, image.height; } // a crash's leftover: no more is known
|
||||
doc := New(Capture_Document,, temp);
|
||||
capture_document_init(doc, temp);
|
||||
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
|
||||
@ -194,9 +320,10 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
|
||||
if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate);
|
||||
import_add(doc, info, "VideoCodec", import_text(video.codec));
|
||||
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
|
||||
import_add(doc, info, "Encoder", import_text(job.encoder));
|
||||
import_add(doc, info, "Capture", import_text(job.source));
|
||||
if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
|
||||
if job.source import_add(doc, info, "Capture", import_text(job.source));
|
||||
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
|
||||
if job.paused_seconds > 0 import_add_number(doc, info, "Paused", import_milliseconds(job.paused_seconds));
|
||||
|
||||
name := import_out_name(job.date, hash);
|
||||
text, text_ok := import_document_text(doc, name);
|
||||
@ -229,7 +356,7 @@ screen_record_start :: (immediate := false) {
|
||||
if app.record_and_exit > 0 screen_record_saved("", "screen recording is Windows only");
|
||||
}
|
||||
|
||||
screen_record_stop :: (quiet := false) {}
|
||||
screen_record_stop :: (shutdown := false) {}
|
||||
|
||||
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||
return "", "screen recording is Windows only";
|
||||
|
||||
329
src/app/screen_record_win32.jai
Normal file
329
src/app/screen_record_win32.jai
Normal file
@ -0,0 +1,329 @@
|
||||
// While recording (app/screen_record.jai), on Windows: a thin red border just outside the region, and a
|
||||
// toolbar beside it with the time, the size, frames dropped, and Pause, Stop and Discard. Both are
|
||||
// plain GDI windows, as the overlay is, so they work while mcap is hidden in the tray, and both are
|
||||
// kept out of every capture (WDA_EXCLUDEFROMCAPTURE), the recording's own included. The border is
|
||||
// outside the region anyway, so it could never be in the video.
|
||||
//
|
||||
// Neither takes the focus: the window being recorded keeps it. The border lets clicks through. A
|
||||
// toolbar button only asks (record_ui.asked); screen_record_frame does it, outside the window's own
|
||||
// message, as Stop and Discard close the toolbar. Discard asks first, in the toolbar itself: a
|
||||
// message box would be in the recording.
|
||||
|
||||
Record_UI_Ask :: enum u8 { NONE; PAUSE; STOP; DISCARD; }
|
||||
|
||||
record_ui: struct {
|
||||
border, toolbar: HWND;
|
||||
classes_registered: bool;
|
||||
scale: float;
|
||||
font: HFONT;
|
||||
asked: Record_UI_Ask; // a button pressed, for screen_record_frame
|
||||
confirming: bool; // Discard pressed once: "Discard it? Yes No"
|
||||
hovered: s32 = -1; // the button under the mouse
|
||||
buttons: [3] Record_UI_Button;
|
||||
button_count: s32;
|
||||
started: Apollo_Time;
|
||||
size_text: string; // "1920 × 1080"; heap
|
||||
}
|
||||
|
||||
Record_UI_Button :: struct { rect: RECT; label: string; ask: Record_UI_Ask; confirm: bool; }
|
||||
|
||||
// Shows the border around region (on the virtual desktop) and the toolbar beside it.
|
||||
record_ui_open :: (region: RECT, whole_screen: bool, width: s32, height: s32) {
|
||||
record_ui_close();
|
||||
instance := GetModuleHandleW(null);
|
||||
if !record_ui.classes_registered {
|
||||
wc: WNDCLASSEXW;
|
||||
wc.cbSize = size_of(WNDCLASSEXW);
|
||||
wc.lpfnWndProc = xx DefWindowProcW;
|
||||
wc.hInstance = instance;
|
||||
wc.hbrBackground = CreateSolidBrush(RECORD_UI_RED);
|
||||
wc.lpszClassName = utf8_to_wide(RECORD_BORDER_CLASS,, context.default_allocator);
|
||||
RegisterClassExW(*wc);
|
||||
wc.lpfnWndProc = xx record_toolbar_proc;
|
||||
wc.hbrBackground = null;
|
||||
wc.hCursor = LoadCursorW(null, IDC_ARROW);
|
||||
wc.lpszClassName = utf8_to_wide(RECORD_TOOLBAR_CLASS,, context.default_allocator);
|
||||
RegisterClassExW(*wc);
|
||||
record_ui.classes_registered = true;
|
||||
}
|
||||
record_ui.started = current_time_monotonic();
|
||||
record_ui.asked = .NONE;
|
||||
record_ui.confirming = false;
|
||||
record_ui.hovered = -1;
|
||||
free(record_ui.size_text,, context.default_allocator);
|
||||
record_ui.size_text = sprint("% × %", width, height,, context.default_allocator);
|
||||
|
||||
monitor := MonitorFromPoint(.{region.left, region.top}, MONITOR_DEFAULTTONEAREST);
|
||||
info: MONITORINFO;
|
||||
info.cbSize = size_of(MONITORINFO);
|
||||
GetMonitorInfoW(monitor, *info);
|
||||
screen := info.rcMonitor;
|
||||
|
||||
// The toolbar first, to know the screen's DPI.
|
||||
ex_style :: WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
|
||||
record_ui.toolbar = CreateWindowExW(ex_style, utf8_to_wide(RECORD_TOOLBAR_CLASS,, temp), utf8_to_wide("Recording",, temp),
|
||||
WS_POPUP, screen.left, screen.top, 10, 10, null, null, instance, null);
|
||||
if !record_ui.toolbar return;
|
||||
dpi := GetDpiForWindow(record_ui.toolbar);
|
||||
record_ui.scale = ifx dpi then dpi / 96.0 else 1.0;
|
||||
font_height := cast(s32) (14 * record_ui.scale + 0.5);
|
||||
record_ui.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
|
||||
|
||||
// Below the region, at its right; else above; else inside its top right (kept out of the recording).
|
||||
toolbar_width, toolbar_height := record_toolbar_size();
|
||||
border := record_ui_px(3);
|
||||
gap := record_ui_px(6);
|
||||
x := region.right - toolbar_width;
|
||||
y := region.bottom + border + gap;
|
||||
if y + toolbar_height > screen.bottom y = region.top - border - gap - toolbar_height;
|
||||
if y < screen.top || whole_screen {
|
||||
y = region.top + gap;
|
||||
x = region.right - toolbar_width - gap;
|
||||
}
|
||||
x = clamp(x, screen.left, screen.right - toolbar_width);
|
||||
SetWindowPos(record_ui.toolbar, HWND_TOPMOST, x, y, toolbar_width, toolbar_height, SWP_NOACTIVATE);
|
||||
visible := array_find(app.args, "-record-ui-visible"); // to look at them in a screenshot
|
||||
if !visible SetWindowDisplayAffinity(record_ui.toolbar, WDA_EXCLUDEFROMCAPTURE);
|
||||
ShowWindow(record_ui.toolbar, SW_SHOWNOACTIVATE);
|
||||
SetTimer(record_ui.toolbar, RECORD_UI_TIMER, 250, null);
|
||||
|
||||
// The border: a frame border pixels wide just outside the region, which lets clicks through. Not
|
||||
// for a whole screen, where it would be on the screen beside it.
|
||||
if !whole_screen {
|
||||
outer := RECT.{region.left - border, region.top - border, region.right + border, region.bottom + border};
|
||||
record_ui.border = CreateWindowExW(ex_style | WS_EX_LAYERED | WS_EX_TRANSPARENT, utf8_to_wide(RECORD_BORDER_CLASS,, temp), utf8_to_wide("Recording",, temp),
|
||||
WS_POPUP, outer.left, outer.top, outer.right - outer.left, outer.bottom - outer.top, null, null, instance, null);
|
||||
if record_ui.border {
|
||||
SetLayeredWindowAttributes(record_ui.border, 0, 255, LWA_ALPHA);
|
||||
frame := CreateRectRgn(0, 0, outer.right - outer.left, outer.bottom - outer.top);
|
||||
inside := CreateRectRgn(border, border, outer.right - outer.left - border, outer.bottom - outer.top - border);
|
||||
CombineRgn(frame, frame, inside, RGN_DIFF);
|
||||
DeleteObject(inside);
|
||||
SetWindowRgn(record_ui.border, frame, .TRUE); // the window owns the region from here
|
||||
if !visible SetWindowDisplayAffinity(record_ui.border, WDA_EXCLUDEFROMCAPTURE);
|
||||
ShowWindow(record_ui.border, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record_ui_close :: () {
|
||||
if record_ui.toolbar { KillTimer(record_ui.toolbar, RECORD_UI_TIMER); DestroyWindow(record_ui.toolbar); record_ui.toolbar = null; }
|
||||
if record_ui.border { DestroyWindow(record_ui.border); record_ui.border = null; }
|
||||
if record_ui.font { DeleteObject(record_ui.font); record_ui.font = null; }
|
||||
}
|
||||
|
||||
// From another thread (the shutdown's): hidden, not destroyed, which only the main thread may do.
|
||||
record_ui_hide :: () {
|
||||
if record_ui.toolbar ShowWindowAsync(record_ui.toolbar, SW_HIDE);
|
||||
if record_ui.border ShowWindowAsync(record_ui.border, SW_HIDE);
|
||||
}
|
||||
|
||||
// The button pressed since the last call, once.
|
||||
record_ui_take_ask :: () -> Record_UI_Ask {
|
||||
ask := record_ui.asked;
|
||||
record_ui.asked = .NONE;
|
||||
return ask;
|
||||
}
|
||||
|
||||
record_ui_refresh :: () {
|
||||
if record_ui.toolbar InvalidateRect(record_ui.toolbar, null, .FALSE);
|
||||
}
|
||||
|
||||
#scope_file
|
||||
|
||||
RECORD_BORDER_CLASS :: "mcapRecordBorder";
|
||||
RECORD_TOOLBAR_CLASS :: "mcapRecordToolbar";
|
||||
RECORD_UI_TIMER :: 1;
|
||||
RECORD_UI_RED :: 0x002828E8; // COLORREF 0x00BBGGRR, as the overlay's video mode
|
||||
RECORD_UI_GREY :: 0x00909090;
|
||||
RECORD_UI_AMBER :: 0x0000B4FF;
|
||||
RECORD_UI_BACK :: 0x00202020;
|
||||
RECORD_UI_BUTTON :: 0x00383838;
|
||||
RECORD_UI_HOVER :: 0x00505050;
|
||||
RECORD_UI_TEXT :: 0x00F0F0F0;
|
||||
|
||||
record_ui_px :: (pixels: float) -> s32 {
|
||||
return max(cast(s32) (pixels * record_ui.scale + 0.5), 1);
|
||||
}
|
||||
|
||||
// What the toolbar shows now, left to right: the status, then the buttons.
|
||||
record_toolbar_texts :: () -> status: string, dropped: string {
|
||||
stats := record_stats(*screen_record.recorder);
|
||||
paused := record_paused(*screen_record.recorder);
|
||||
seconds := max(to_float64_seconds(current_time_monotonic() - record_ui.started) - stats.paused_seconds, 0.0);
|
||||
whole := cast(s64) seconds;
|
||||
two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); }
|
||||
time := ifx whole >= 3600 then tprint("%:%:%", whole / 3600, two(whole / 60 % 60), two(whole % 60)) else tprint("%:%", two(whole / 60), two(whole % 60));
|
||||
status := tprint("% % %", ifx paused then "PAUSED" else "REC", time, record_ui.size_text);
|
||||
dropped := ifx stats.dropped then tprint("% dropped", stats.dropped) else "";
|
||||
if stats.lost {
|
||||
lost := tprint("screen lost % time%", stats.lost, ifx stats.lost == 1 then "" else "s");
|
||||
dropped = ifx dropped then tprint("%, %", dropped, lost) else lost;
|
||||
}
|
||||
return status, dropped;
|
||||
}
|
||||
|
||||
// The buttons for the state the toolbar is in.
|
||||
record_toolbar_buttons :: () -> [] Record_UI_Button {
|
||||
if record_ui.confirming {
|
||||
record_ui.buttons[0] = .{label = "Yes, discard", ask = .DISCARD};
|
||||
record_ui.buttons[1] = .{label = "No", confirm = true};
|
||||
record_ui.button_count = 2;
|
||||
} else {
|
||||
record_ui.buttons[0] = .{label = ifx record_paused(*screen_record.recorder) then "Resume" else "Pause", ask = .PAUSE};
|
||||
record_ui.buttons[1] = .{label = "Stop", ask = .STOP};
|
||||
record_ui.buttons[2] = .{label = "Discard", confirm = true};
|
||||
record_ui.button_count = 3;
|
||||
}
|
||||
return array_view(record_ui.buttons, 0, record_ui.button_count);
|
||||
}
|
||||
|
||||
text_width :: (dc: HDC, text: string) -> s32 {
|
||||
wide := utf8_to_wide(text,, temp);
|
||||
count: s32;
|
||||
while wide[count] count += 1;
|
||||
size: SIZE;
|
||||
GetTextExtentPoint32W(dc, wide, count, *size);
|
||||
return size.cx;
|
||||
}
|
||||
|
||||
// The toolbar's size, and where its buttons are, for the longest it is likely to be.
|
||||
record_toolbar_size :: () -> width: s32, height: s32 {
|
||||
dc := GetDC(record_ui.toolbar);
|
||||
defer ReleaseDC(record_ui.toolbar, dc);
|
||||
old := SelectObject(dc, record_ui.font);
|
||||
defer SelectObject(dc, old);
|
||||
padding, gap := record_ui_px(10), record_ui_px(8);
|
||||
status_width := text_width(dc, tprint("PAUSED 00:00:00 %", record_ui.size_text));
|
||||
dropped_width := text_width(dc, "999 dropped, screen lost 9 times");
|
||||
buttons_width := text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 3 * 2 * padding + 2 * gap;
|
||||
height := record_ui_px(34);
|
||||
width := padding + record_ui_px(12) + gap + status_width + gap + dropped_width + gap + buttons_width + padding;
|
||||
return width, height;
|
||||
}
|
||||
|
||||
record_toolbar_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call {
|
||||
if !platform.main_context return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||
push_context platform.main_context.* {
|
||||
if message == {
|
||||
case WM_MOUSEACTIVATE;
|
||||
return MA_NOACTIVATE; // the window being recorded keeps the focus
|
||||
case WM_ERASEBKGND;
|
||||
return 1;
|
||||
case WM_TIMER;
|
||||
// The time moves on, and a mouse that left the toolbar no longer lights a button.
|
||||
cursor: POINT;
|
||||
GetCursorPos(*cursor);
|
||||
window: RECT;
|
||||
GetWindowRect(hwnd, *window);
|
||||
if cursor.x < window.left || cursor.x >= window.right || cursor.y < window.top || cursor.y >= window.bottom record_ui.hovered = -1;
|
||||
InvalidateRect(hwnd, null, .FALSE);
|
||||
return 0;
|
||||
case WM_MOUSEMOVE;
|
||||
hovered := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
|
||||
if hovered != record_ui.hovered { record_ui.hovered = hovered; InvalidateRect(hwnd, null, .FALSE); }
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN;
|
||||
hit := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
|
||||
if hit >= 0 {
|
||||
button := record_ui.buttons[hit];
|
||||
if button.confirm record_ui.confirming = !record_ui.confirming;
|
||||
else if button.ask != .NONE { record_ui.asked = button.ask; record_ui.confirming = false; }
|
||||
InvalidateRect(hwnd, null, .FALSE);
|
||||
}
|
||||
return 0;
|
||||
case WM_PAINT;
|
||||
record_toolbar_paint(hwnd);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||
}
|
||||
}
|
||||
|
||||
record_toolbar_hit :: (p: POINT) -> s32 {
|
||||
for array_view(record_ui.buttons, 0, record_ui.button_count) {
|
||||
r := it.rect;
|
||||
if p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom return cast(s32) it_index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
record_toolbar_paint :: (hwnd: HWND) {
|
||||
paint: PAINTSTRUCT;
|
||||
window_dc := BeginPaint(hwnd, *paint);
|
||||
defer EndPaint(hwnd, *paint);
|
||||
client: RECT;
|
||||
GetClientRect(hwnd, *client);
|
||||
width, height := client.right, client.bottom;
|
||||
|
||||
// Drawn whole into a bitmap, then put on screen: no flicker as the time changes.
|
||||
dc := CreateCompatibleDC(window_dc);
|
||||
defer DeleteDC(dc);
|
||||
bitmap := CreateCompatibleBitmap(window_dc, width, height);
|
||||
defer DeleteObject(bitmap);
|
||||
old_bitmap := SelectObject(dc, bitmap);
|
||||
defer SelectObject(dc, old_bitmap);
|
||||
old_font := SelectObject(dc, record_ui.font);
|
||||
defer SelectObject(dc, old_font);
|
||||
|
||||
fill :: (r: RECT, colour: u32) #expand {
|
||||
brush := CreateSolidBrush(colour);
|
||||
rect := r;
|
||||
FillRect(`dc, *rect, brush);
|
||||
DeleteObject(brush);
|
||||
}
|
||||
text_at :: (x: s32, text: string, colour: u32) #expand {
|
||||
SetTextColor(`dc, colour);
|
||||
wide := utf8_to_wide(text,, temp);
|
||||
count: s32;
|
||||
while wide[count] count += 1;
|
||||
size: SIZE;
|
||||
GetTextExtentPoint32W(`dc, wide, count, *size);
|
||||
TextOutW(`dc, x, (`height - size.cy) / 2, wide, count);
|
||||
}
|
||||
|
||||
fill(.{0, 0, width, height}, RECORD_UI_BACK);
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
padding, gap := record_ui_px(10), record_ui_px(8);
|
||||
paused := record_paused(*screen_record.recorder);
|
||||
|
||||
// The dot: red while recording, blinking each half second; grey while paused.
|
||||
dot := record_ui_px(12);
|
||||
blink := (cast(s64) (to_float64_seconds(current_time_monotonic() - record_ui.started) * 2)) % 2 == 0;
|
||||
dot_colour: u32 = ifx paused then cast(u32) RECORD_UI_GREY else ifx blink then cast(u32) RECORD_UI_RED else cast(u32) 0x00602020;
|
||||
brush := CreateSolidBrush(dot_colour);
|
||||
old_brush := SelectObject(dc, brush);
|
||||
old_pen := SelectObject(dc, GetStockObject(NULL_PEN));
|
||||
Ellipse(dc, padding, (height - dot) / 2, padding + dot, (height - dot) / 2 + dot);
|
||||
SelectObject(dc, old_pen);
|
||||
SelectObject(dc, old_brush);
|
||||
DeleteObject(brush);
|
||||
|
||||
status, dropped := record_toolbar_texts();
|
||||
x := padding + dot + gap;
|
||||
text_at(x, status, RECORD_UI_TEXT);
|
||||
x += text_width(dc, status) + gap;
|
||||
if dropped text_at(x, dropped, RECORD_UI_AMBER);
|
||||
|
||||
// The buttons, from the right.
|
||||
buttons := record_toolbar_buttons();
|
||||
button_height := height - 2 * record_ui_px(5);
|
||||
right := width - padding;
|
||||
for < buttons {
|
||||
label_width := text_width(dc, it.label);
|
||||
r := RECT.{right - label_width - 2 * padding, (height - button_height) / 2, right, (height - button_height) / 2 + button_height};
|
||||
record_ui.buttons[it_index].rect = r;
|
||||
fill(r, ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
|
||||
SetTextColor(dc, ifx it.ask == .DISCARD then cast(u32) RECORD_UI_AMBER else cast(u32) RECORD_UI_TEXT);
|
||||
wide := utf8_to_wide(it.label,, temp);
|
||||
count: s32;
|
||||
while wide[count] count += 1;
|
||||
size: SIZE;
|
||||
GetTextExtentPoint32W(dc, wide, count, *size);
|
||||
TextOutW(dc, r.left + padding, r.top + (button_height - size.cy) / 2, wide, count);
|
||||
right = r.left - gap;
|
||||
}
|
||||
if record_ui.confirming text_at(right - text_width(dc, "Discard it?"), "Discard it?", RECORD_UI_AMBER);
|
||||
|
||||
BitBlt(window_dc, 0, 0, width, height, dc, 0, 0, .SRCCOPY);
|
||||
}
|
||||
68
src/base/platform/win32_gdi_bindings.jai
Normal file
68
src/base/platform/win32_gdi_bindings.jai
Normal file
@ -0,0 +1,68 @@
|
||||
// Win32 calls and types for the GDI windows the app draws itself (app/screen_capture_win32.jai's
|
||||
// overlay, app/screen_record_win32.jai's border and toolbar) that modules/Windows.jai does not have.
|
||||
|
||||
|
||||
gdi32 :: #library,system "gdi32";
|
||||
version_dll :: #library,system "version";
|
||||
|
||||
HFONT :: HANDLE;
|
||||
HRGN :: HANDLE;
|
||||
|
||||
PAINTSTRUCT :: struct {
|
||||
hdc: HDC;
|
||||
fErase: BOOL;
|
||||
rcPaint: RECT;
|
||||
fRestore: BOOL;
|
||||
fIncUpdate: BOOL;
|
||||
rgbReserved: [32] u8;
|
||||
}
|
||||
|
||||
CAPTUREBLT :: 0x40000000;
|
||||
GA_ROOT :: 2;
|
||||
WA_INACTIVE :: 0;
|
||||
WM_ACTIVATE :: 0x0006;
|
||||
TRANSPARENT :: 1;
|
||||
CLEARTYPE_QUALITY :: 5;
|
||||
PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000;
|
||||
|
||||
GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32;
|
||||
BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32;
|
||||
EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32;
|
||||
FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32;
|
||||
GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32;
|
||||
GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32;
|
||||
GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32;
|
||||
GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32;
|
||||
GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32;
|
||||
capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos";
|
||||
|
||||
CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32;
|
||||
CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32;
|
||||
SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32;
|
||||
SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32;
|
||||
SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32;
|
||||
TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32;
|
||||
GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32;
|
||||
CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32,
|
||||
strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32,
|
||||
pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32;
|
||||
|
||||
OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32;
|
||||
QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32;
|
||||
|
||||
GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll;
|
||||
GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll;
|
||||
VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll;
|
||||
|
||||
// The recording's border and toolbar (app/screen_record_win32.jai).
|
||||
MA_NOACTIVATE :: 3;
|
||||
LWA_ALPHA :: 0x2;
|
||||
RGN_DIFF :: 4;
|
||||
WDA_EXCLUDEFROMCAPTURE :: 0x11; // Windows 10 2004 on: left out of screenshots and recordings
|
||||
|
||||
ShowWindowAsync :: (hwnd: HWND, show: s32) -> BOOL #foreign user32;
|
||||
SetWindowRgn :: (hwnd: HWND, region: HRGN, redraw: BOOL) -> s32 #foreign user32;
|
||||
SetLayeredWindowAttributes :: (hwnd: HWND, key: u32, alpha: u8, flags: u32) -> BOOL #foreign user32;
|
||||
SetWindowDisplayAffinity :: (hwnd: HWND, affinity: u32) -> BOOL #foreign user32;
|
||||
CombineRgn :: (destination: HRGN, a: HRGN, b: HRGN, mode: s32) -> s32 #foreign gdi32;
|
||||
Ellipse :: (dc: HDC, left: s32, top: s32, right: s32, bottom: s32) -> BOOL #foreign gdi32;
|
||||
@ -749,6 +749,36 @@ screen_record_tests :: () {
|
||||
catalog_entry_free(entry);
|
||||
}
|
||||
file_delete(path);
|
||||
|
||||
// A recording a crash left behind: its date from its name, and no window, size or encoder known.
|
||||
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43.mp4") == "2026-10-10 07:12:43", "a leftover's date from its name");
|
||||
expect(screen_record_leftover_date("C:/Temp/mcap/recording-2026-10-10_07-12-43_2.mp4") == "2026-10-10 07:12:43", "and from a second one's");
|
||||
encoder = .{};
|
||||
opened, open_error = encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
|
||||
for frame: 0..9 encoder_write(*encoder, pixels.data, frame * 3000);
|
||||
encoder_close(*encoder);
|
||||
leftover: Screen_Capture_Job;
|
||||
leftover.video = true;
|
||||
leftover.recording = mp4;
|
||||
leftover.date = "2026-10-10 07:12:43";
|
||||
leftover_path, leftover_error := screen_record_write(*leftover);
|
||||
expect(!leftover_error, "a leftover is written: %", leftover_error);
|
||||
if !leftover_error {
|
||||
leftover_entry := catalog_load(leftover_path);
|
||||
expect(leftover_entry && leftover_entry.media == .VIDEO, "as a video");
|
||||
if leftover_entry catalog_entry_free(leftover_entry);
|
||||
m2, parsed2 := mcap_parse(cast([] u8) read_entire_file(leftover_path,, temp));
|
||||
if parsed2 {
|
||||
doc2: Capture_Document;
|
||||
capture_document_init(*doc2, temp);
|
||||
capture_text_parse(*doc2, leftover_path, m2.text);
|
||||
info2 := capture_find_file(*doc2, "capture.json");
|
||||
width2 := ifx info2 then capture_object_find(*info2.root, "Width") else null;
|
||||
encoder2 := ifx info2 then capture_object_find(*info2.root, "Encoder") else null;
|
||||
expect(width2 && width2.kind == .NUMBER && width2.number == WIDTH && !encoder2, "its size from its frames, and no encoder claimed");
|
||||
}
|
||||
file_delete(leftover_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -306,6 +306,12 @@ App_User_Settings :: struct {
|
||||
// Entries the Event Log keeps in memory; the oldest are dropped past it. The log file keeps every line.
|
||||
event_log_capacity: s64 = EVENT_LOG_CAPACITY_DEFAULT;
|
||||
|
||||
// [video]: screen recording (app/screen_record.jai), set by hand. video_capture is "auto", "dxgi"
|
||||
// or "wgc"; heap-allocated, "" meaning auto.
|
||||
video_fps: s64 = VIDEO_FPS_DEFAULT;
|
||||
video_cursor := true;
|
||||
video_capture: string;
|
||||
|
||||
// The [hotkeys] lines. Only meaningful when the section was seen; see hotkeys_install.
|
||||
hotkey_bindings: [..] Hotkey_Binding;
|
||||
// The [global_hotkeys] lines, likewise; see global_hotkeys_install.
|
||||
@ -315,6 +321,7 @@ App_User_Settings :: struct {
|
||||
settings_free :: (s: *App_User_Settings) {
|
||||
free(s.theme,, context.default_allocator);
|
||||
free(s.ocr_model,, context.default_allocator);
|
||||
free(s.video_capture,, context.default_allocator);
|
||||
array_free(s.hotkey_bindings);
|
||||
array_free(s.global_hotkey_bindings);
|
||||
}
|
||||
@ -326,6 +333,9 @@ FONT_SIZE_BASE_MAX :: 64.0;
|
||||
EVENT_LOG_CAPACITY_DEFAULT :: 4096;
|
||||
EVENT_LOG_CAPACITY_MIN :: 64;
|
||||
EVENT_LOG_CAPACITY_MAX :: 1_000_000;
|
||||
VIDEO_FPS_DEFAULT :: 30;
|
||||
VIDEO_FPS_MIN :: 1;
|
||||
VIDEO_FPS_MAX :: 120;
|
||||
|
||||
// Fills app_user_settings. Returns true when the file should be written back: it is missing, or
|
||||
// is the current version but lacks a key, so every setting ends up listed there to edit by hand.
|
||||
@ -370,11 +380,12 @@ app_load_user_settings_file :: () -> write_back: bool {
|
||||
if hotkeys_missing.count || global_missing.count return true;
|
||||
|
||||
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
|
||||
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model);
|
||||
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.video_fps && seen.video_cursor && seen.video_capture);
|
||||
}
|
||||
|
||||
Settings_Seen :: struct {
|
||||
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool;
|
||||
video_fps, video_cursor, video_capture: bool;
|
||||
}
|
||||
|
||||
// Of the file's bytes as last loaded or written, so a watcher event for our own save, or for an
|
||||
@ -505,6 +516,19 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) ->
|
||||
seen.font_size = settings_read_float(*out.font_size_base, path, line_number, key, value, FONT_SIZE_BASE_MIN, FONT_SIZE_BASE_MAX);
|
||||
} else if section == "[font]" && key == "scale_main" {
|
||||
seen.font_scale = settings_read_float(*out.font_scale_main, path, line_number, key, value, FONT_SCALE_MIN, FONT_SCALE_MAX);
|
||||
} else if section == "[video]" && key == "fps" {
|
||||
seen.video_fps = settings_read_int(*out.video_fps, path, line_number, key, value, VIDEO_FPS_MIN, VIDEO_FPS_MAX);
|
||||
} else if section == "[video]" && key == "cursor" {
|
||||
seen.video_cursor = settings_read_bool(*out.video_cursor, path, line_number, key, value);
|
||||
} else if section == "[video]" && key == "capture" {
|
||||
capture := to_lower_copy(settings_unquote(value),, temp);
|
||||
if capture == "auto" || capture == "dxgi" || capture == "wgc" {
|
||||
free(out.video_capture,, context.default_allocator);
|
||||
out.video_capture = copy_string(capture,, context.default_allocator);
|
||||
seen.video_capture = true;
|
||||
} else {
|
||||
log_error("[settings] %:%: capture is %; it takes auto, dxgi or wgc. Using auto.", path, line_number, value);
|
||||
}
|
||||
} else if section == "[logging]" && key == "event_log_capacity" {
|
||||
seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX);
|
||||
} else {
|
||||
@ -638,6 +662,12 @@ app_save_user_settings_file :: () -> bool {
|
||||
if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction;
|
||||
if seen.height app_user_settings.window_height_fraction = on_disk.window_height_fraction;
|
||||
if seen.event_log app_user_settings.event_log_capacity = on_disk.event_log_capacity;
|
||||
if seen.video_fps app_user_settings.video_fps = on_disk.video_fps;
|
||||
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
|
||||
if seen.video_capture {
|
||||
free(app_user_settings.video_capture,, context.default_allocator);
|
||||
app_user_settings.video_capture = copy_string(on_disk.video_capture,, context.default_allocator);
|
||||
}
|
||||
}
|
||||
|
||||
builder: String_Builder;
|
||||
@ -658,6 +688,13 @@ app_save_user_settings_file :: () -> bool {
|
||||
append(*builder, "[ocr]\n");
|
||||
append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n");
|
||||
print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model));
|
||||
append(*builder, "[video]\n");
|
||||
append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\n");
|
||||
append(*builder, "# screen changes. cursor: true or false. capture: auto (Desktop Duplication, else\n");
|
||||
append(*builder, "# Windows.Graphics.Capture), dxgi or wgc.\n");
|
||||
print_to_builder(*builder, "fps: %\n", app_user_settings.video_fps);
|
||||
print_to_builder(*builder, "cursor: %\n", ifx app_user_settings.video_cursor then "true" else "false");
|
||||
print_to_builder(*builder, "capture: %\n\n", ifx app_user_settings.video_capture.count then app_user_settings.video_capture else "auto");
|
||||
append(*builder, "[logging]\n");
|
||||
append(*builder, "# Entries the Event Log keeps in memory. Past it the oldest are dropped; the log file keeps every line.\n");
|
||||
print_to_builder(*builder, "event_log_capacity: %\n", app_user_settings.event_log_capacity);
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
// -keep-border leaves Windows.Graphics.Capture's border on: the border test's control.
|
||||
// -simulate-unsupported Desktop Duplication fails as on a laptop with two GPUs: the fallback's path.
|
||||
// -poll Windows.Graphics.Capture polled, as when it will not take its frame handler.
|
||||
// -pause SECONDS pauses 2 s in, for that long: the video should be that much shorter, with no gap.
|
||||
// -lose Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it.
|
||||
// -cursor-test puts the mouse in the middle of the region while recording, and back after;
|
||||
// with -snapshot, cursor.png is the frame around it, four times as large.
|
||||
// -legacy the plain DuplicateOutput, not DuplicateOutput1: does it fail for a process aware
|
||||
@ -27,7 +29,7 @@
|
||||
// Run: bin/record-probe [-list] [-screen N] [-region X Y W H] [-seconds S] [-fps F] [-encoder NAME]
|
||||
// [-out FILE.mp4] [-no-cursor] [-legacy] [-cuda] [-exclude-test] [-snapshot DIR]
|
||||
// [-animate] [-capture auto|dxgi|wgc] [-border-test] [-keep-border] [-cursor-test]
|
||||
// [-simulate-unsupported] [-poll] [-dpi-unaware]
|
||||
// [-simulate-unsupported] [-poll] [-pause SECONDS] [-lose] [-dpi-unaware]
|
||||
// Exit code: 0 recorded, 1 failed, 3 Desktop Duplication cannot run here (the fallback's case).
|
||||
|
||||
main :: () {
|
||||
@ -37,6 +39,7 @@ main :: () {
|
||||
seconds: float64 = 10;
|
||||
list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false;
|
||||
snapshot_folder: string;
|
||||
pause_seconds: float64;
|
||||
i := 1;
|
||||
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
|
||||
while i < args.count {
|
||||
@ -64,6 +67,8 @@ main :: () {
|
||||
if a == "-keep-border" { options.testing |= .KEEP_BORDER; i += 1; continue; }
|
||||
if a == "-simulate-unsupported" { options.testing |= .DXGI_UNSUPPORTED; i += 1; continue; }
|
||||
if a == "-poll" { options.testing |= .WGC_POLL; i += 1; continue; }
|
||||
if a == "-lose" { options.testing |= .LOSE_DUPLICATION; i += 1; continue; }
|
||||
if a == "-pause" && more >= 1 { pause_seconds = string_to_float64(args[i + 1]); i += 2; continue; }
|
||||
if a == "-cursor-test" { cursor_test = true; i += 1; continue; }
|
||||
if a == "-capture" && more >= 1 {
|
||||
how := args[i + 1];
|
||||
@ -161,6 +166,14 @@ main :: () {
|
||||
print(" % s: % captured, % sent (% repeats), % written, % dropped, % lost, % KB\n",
|
||||
FF(to_float64_seconds(last_print - start), 0), s.captured, s.emitted, s.repeats, s.written, s.dropped, s.lost, s.bytes / 1024);
|
||||
}
|
||||
if pause_seconds > 0 {
|
||||
elapsed := to_float64_seconds(current_time_monotonic() - start);
|
||||
should := elapsed >= 2 && elapsed < 2 + pause_seconds;
|
||||
if should != record_paused(*recorder) {
|
||||
record_pause(*recorder, should);
|
||||
print(" % s: %\n", FF(elapsed, 1), ifx should then "paused" else "resumed");
|
||||
}
|
||||
}
|
||||
if border_test && border_during < 0 && to_float64_seconds(current_time_monotonic() - start) >= 1.5
|
||||
border_during = yellow_edge_pixels(border_screen);
|
||||
failed, _ := record_failed(*recorder);
|
||||
@ -184,7 +197,7 @@ main :: () {
|
||||
print(" seconds: %\n", FF(s.seconds, 1));
|
||||
print(" captured: % new images of the screen (% a second), % cursor-only changes\n", s.captured, FF(s.captured / s.seconds, 1), s.pointer_moves);
|
||||
print(" sent: % frames (% a second), % of them repeats of an unchanged screen\n", s.emitted, FF(s.emitted / s.seconds, 1), s.repeats);
|
||||
print(" written: %, dropped %, duplication lost %\n", s.written, s.dropped, s.lost);
|
||||
print(" written: %, dropped %, duplication lost %, paused % s\n", s.written, s.dropped, s.lost, FF(s.paused_seconds, 1));
|
||||
print(" each: copy % (per image), read back % , encode % (per frame)\n",
|
||||
ms_each(s.copy_ticks, s.captured), ms_each(s.readback_ticks, s.emitted), ms_each(s.encode_ticks, s.written));
|
||||
print(" file: % KB, % MB a minute\n", file_size / 1024, FF(cast(float64) file_size / (1024 * 1024) / s.seconds * 60, 1));
|
||||
|
||||
@ -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 to 3 built, mcap 0.1.5</strong></p>
|
||||
<p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 to 4 built, mcap 0.1.6</strong></p>
|
||||
</header>
|
||||
|
||||
<div class="summary">
|
||||
@ -83,6 +83,7 @@
|
||||
<li><span class="tag decided">Done</span> <strong>Phase 1</strong> (2026-10-10, mcap 0.1.3): <code>modules/Screen_Record</code> and <code>record-probe</code>. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see <a href="#phases">phase 1's result</a>).</li>
|
||||
<li><span class="tag decided">Done</span> <strong>Phase 2</strong> (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated <code>DXGI_ERROR_UNSUPPORTED</code>. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11.</li>
|
||||
<li><span class="tag decided">Done</span> <strong>Phase 3</strong> (2026-10-10, mcap 0.1.5): recording in the app. Ctrl+PrintScreen opens the overlay's video mode; Ctrl+PrintScreen again stops; the recording becomes a video <code>.mcap</code> in Screen Captures. In the app, Desktop Duplication first failed with <code>DXGI_ERROR_UNSUPPORTED</code>: only the main thread was DPI aware. The capture thread now makes itself aware.</li>
|
||||
<li><span class="tag decided">Done</span> <strong>Phase 4</strong> (2026-10-10, mcap 0.1.6): the border and toolbar, both kept out of the recording; pause, with no gap in the video; discard, confirmed in the toolbar; recovery when the screen is lost; leftover recordings offered at startup; the <code>[video]</code> settings.</li>
|
||||
<li><span class="tag later">Later</span> Audio is phase 5. Recording one window, and regions that span two screens, come after.</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -412,7 +413,7 @@ Captured_Frame :: struct {
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h3>Phase 4: Border, toolbar, pause and recovery</h3>
|
||||
<h3>Phase 4: Border, toolbar, pause and recovery <span class="tag decided">Done</span></h3>
|
||||
<p>The pieces:</p>
|
||||
<ul>
|
||||
<li>The border and toolbar windows, kept out of the capture.</li>
|
||||
@ -423,6 +424,39 @@ Captured_Frame :: struct {
|
||||
<li>The <code>[video]</code> settings: frame rate, cursor, capture method.</li>
|
||||
</ul>
|
||||
<p><strong>Done when:</strong> neither the border nor the toolbar appears in a recording. Pausing for 10 s leaves no gap in the video. A UAC prompt or a resolution change mid-recording leaves a video that plays through.</p>
|
||||
<div class="note"><strong>Result (2026-10-10).</strong>
|
||||
<ul>
|
||||
<li><strong>Built:</strong>
|
||||
<ul>
|
||||
<li><code>src/app/screen_record_win32.jai</code>: the border, one red frame window shaped with a window region, which lets clicks through; and the toolbar. Both are plain GDI windows that never take the focus.</li>
|
||||
<li><code>record_pause</code> in <code>modules/Screen_Record</code>.</li>
|
||||
<li>The <code>[video]</code> settings: <code>fps</code>, <code>cursor</code>, <code>capture</code>. Your settings file received the section on the next launch.</li>
|
||||
<li>Leftover recordings offered at startup, in a popup in the main window.</li>
|
||||
<li>The Win32 GDI bindings moved to <code>src/base/platform/win32_gdi_bindings.jai</code>, shared by the overlay and the toolbar.</li>
|
||||
</ul></li>
|
||||
<li><strong>Kept out of the recording:</strong> a whole-screen recording with the app (<code>-record 6</code>), where the toolbar sits inside the recorded screen. The frame stored in its <code>.mcap</code> has no toolbar. With <code>-record-ui-visible</code> and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.</li>
|
||||
<li><strong>Pause:</strong> 16 s with a 10 s pause from 2 s in gives a 6.2 s video at a steady 30 fps, with Desktop Duplication and with Windows.Graphics.Capture alike. The pause is cut out, with no gap.</li>
|
||||
<li><strong>Losing the screen</strong> (<code>-lose</code>, which loses Desktop Duplication every 3 s through the real recovery path): 3 losses in 10 s, 304 frames, 30 fps throughout. A real UAC prompt or resolution change is still to try by hand.</li>
|
||||
<li><strong>Tests:</strong> 5 more checks in the recording group, 22 in all:
|
||||
<ul>
|
||||
<li>A leftover's date read from its file name, including a <code>_2</code> one.</li>
|
||||
<li>A leftover written with nothing known about it but its frames: catalogued as a video, its size from its frames, and no encoder claimed.</li>
|
||||
</ul>
|
||||
Every suite passes.</li>
|
||||
<li><strong>Not checked by me</strong>, needing someone at the mouse:
|
||||
<ul>
|
||||
<li>Clicking Pause, Stop and Discard, and Discard's "Yes, discard / No".</li>
|
||||
<li>The popup offering leftovers.</li>
|
||||
<li>A real UAC prompt or resolution change mid-recording.</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
<p><strong>Changes from the plan:</strong></p>
|
||||
<ul>
|
||||
<li><strong>The toolbar's buttons only ask.</strong> The app does the asking on its next frame, as Stop and Discard close the toolbar itself.</li>
|
||||
<li><strong>No border for a whole screen.</strong> It would be drawn on the screen beside it. The toolbar then sits inside the screen's top right, kept out of the recording.</li>
|
||||
<li><strong>Test switches</strong> for <code>-record</code>: <code>-record-region</code>, and <code>-record-ui-visible</code>, which leaves the border and toolbar in screenshots to look at them. The probe gained <code>-pause SECONDS</code> and <code>-lose</code>.</li>
|
||||
<li><strong>On the shutdown thread</strong> the border and toolbar are hidden, not destroyed: only the thread that made a window may destroy it.</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user