[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.
|
- Escape or a right click cancels.
|
||||||
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
|
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.
|
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.
|
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.
|
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).
|
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
|
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
|
||||||
Foundation, or libx264.
|
Foundation, or libx264.
|
||||||
- **Losing nothing:** a recording that stops by itself keeps what it has, and so does quitting while
|
- **Losing nothing:**
|
||||||
recording.
|
- 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
|
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
|
||||||
the screens, which would show in the recording.
|
the screens, which would show in the recording.
|
||||||
- **The key** is `record_region` in `[global_hotkeys]`.
|
- **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. |
|
| `-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. |
|
| `-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. |
|
| `-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. |
|
| `-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). |
|
| `-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. |
|
| `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. |
|
||||||
|
|||||||
@ -29,7 +29,7 @@ APP_NAME :: "mcap";
|
|||||||
APP_FOLDER_NAME :: "mcap";
|
APP_FOLDER_NAME :: "mcap";
|
||||||
GIT_BRANCH :: "check";
|
GIT_BRANCH :: "check";
|
||||||
GIT_HASH :: "-";
|
GIT_HASH :: "-";
|
||||||
VERSION :: "0.1.5";
|
VERSION :: "0.1.6";
|
||||||
BUILD_DATE :: "-";
|
BUILD_DATE :: "-";
|
||||||
DEBUG :: true;
|
DEBUG :: true;
|
||||||
COMPILE_TESTS :: true;
|
COMPILE_TESTS :: true;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ APP_NAME :: "mcap";
|
|||||||
APP_FOLDER_NAME :: "mcap";
|
APP_FOLDER_NAME :: "mcap";
|
||||||
APP_BUNDLE_IDENTIFIER :: "com.example.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
|
// 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.
|
// builds never write over the ones you run by hand.
|
||||||
build_directory := "bin";
|
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"
|
Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU"
|
||||||
or "Windows.Graphics.Capture on ..., border off"
|
or "Windows.Graphics.Capture on ..., border off"
|
||||||
DroppedFrames: 0 frames the encoder fell too far behind for
|
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.
|
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
|
||||||
|
|
||||||
## Imported videos
|
## Imported videos
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
// dropped and counted.
|
// dropped and counted.
|
||||||
//
|
//
|
||||||
// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
|
// 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;
|
// recorder: Recorder;
|
||||||
// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
|
// 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)
|
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
|
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
|
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 {
|
Record_Options :: struct {
|
||||||
@ -78,6 +80,7 @@ Record_Stats :: struct {
|
|||||||
written: s64; // encoded
|
written: s64; // encoded
|
||||||
dropped: s64; // the encoder was too far behind
|
dropped: s64; // the encoder was too far behind
|
||||||
lost: s64; // times the duplication was lost and made again
|
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
|
copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
|
||||||
bytes: s64; // encoded
|
bytes: s64; // encoded
|
||||||
seconds: float64; // from start to stop
|
seconds: float64; // from start to stop
|
||||||
@ -99,6 +102,7 @@ Recorder :: struct {
|
|||||||
capture_started, encoder_started: bool;
|
capture_started, encoder_started: bool;
|
||||||
running: bool;
|
running: bool;
|
||||||
stopping: bool; // set by record_stop; the capture thread ends, then the encoder
|
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;
|
capture_ended: bool;
|
||||||
|
|
||||||
opened: Semaphore; // the capture thread has opened its source, or failed to
|
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, "";
|
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.
|
// As they are now; strings are the recorder's.
|
||||||
record_stats :: (r: *Recorder) -> Record_Stats {
|
record_stats :: (r: *Recorder) -> Record_Stats {
|
||||||
if !r.running return r.stats;
|
if !r.running return r.stats;
|
||||||
@ -318,6 +331,8 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
|||||||
have_image, changed := false, false;
|
have_image, changed := false, false;
|
||||||
staged: [RECORD_STAGING] Staged;
|
staged: [RECORD_STAGING] Staged;
|
||||||
emits: s64;
|
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) {
|
while !atomic_read(*r.stopping) {
|
||||||
now := ticks_now();
|
now := ticks_now();
|
||||||
@ -327,6 +342,9 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
|||||||
error: string;
|
error: string;
|
||||||
if r.using_wgc next, error = wgc_next(*r.wgc, wait);
|
if r.using_wgc next, error = wgc_next(*r.wgc, wait);
|
||||||
else next, error = dxgi_next(*r.dxgi, 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 == {
|
if next == {
|
||||||
case .FAILED;
|
case .FAILED;
|
||||||
record_fail(r, error);
|
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();
|
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;
|
if !have_image continue;
|
||||||
repeat := !changed && now - last_emit >= r.frequency;
|
repeat := !changed && now - last_emit >= r.frequency;
|
||||||
if !(changed && now >= turn - early) && !repeat continue;
|
if !(changed && now >= turn - early) && !repeat continue;
|
||||||
@ -369,7 +400,7 @@ record_capture_proc :: (thread: *Thread) -> s64 {
|
|||||||
slot := emits % RECORD_STAGING;
|
slot := emits % RECORD_STAGING;
|
||||||
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
|
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
|
||||||
ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
|
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;
|
if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
|
||||||
emits += 1;
|
emits += 1;
|
||||||
last_emit = now;
|
last_emit = now;
|
||||||
|
|||||||
@ -30,6 +30,7 @@ USING_WINDOWS :: OS == .WINDOWS;
|
|||||||
#load "app/screen_capture.jai";
|
#load "app/screen_capture.jai";
|
||||||
#load "app/screen_record.jai";
|
#load "app/screen_record.jai";
|
||||||
#if OS == .WINDOWS #load "app/screen_capture_win32.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/circle_buffer.jai";
|
||||||
#load "base/debug.jai";
|
#load "base/debug.jai";
|
||||||
@ -200,6 +201,7 @@ App :: struct {
|
|||||||
start_search: string; // -search QUERY: the search box's text at startup
|
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
|
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_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)
|
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
|
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;
|
last_window_restore_time: Apollo_Time;
|
||||||
@ -267,6 +269,7 @@ App :: struct {
|
|||||||
|
|
||||||
#load "base/platform/platform_windows.jai";
|
#load "base/platform/platform_windows.jai";
|
||||||
#load "base/platform/win32_bindings.jai";
|
#load "base/platform/win32_bindings.jai";
|
||||||
|
#load "base/platform/win32_gdi_bindings.jai";
|
||||||
#load "graphics/DX11.jai";
|
#load "graphics/DX11.jai";
|
||||||
|
|
||||||
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
|
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
|
||||||
|
|||||||
@ -11,6 +11,7 @@ app_ui_startup :: () {
|
|||||||
ocr_ui_startup();
|
ocr_ui_startup();
|
||||||
labels_ui_startup();
|
labels_ui_startup();
|
||||||
segment_ui_startup(); // the library folders' options: after the viewer, which knows the library
|
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.
|
// Every frame, before input is handed to ImGui. Poll background work here.
|
||||||
@ -99,4 +100,5 @@ app_main_window_ui :: () {
|
|||||||
segment_ui_frame();
|
segment_ui_frame();
|
||||||
stats_ui_frame();
|
stats_ui_frame();
|
||||||
import_frame();
|
import_frame();
|
||||||
|
screen_record_leftovers_popup();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,10 @@ main :: () {
|
|||||||
app.scan_and_exit = array_find(app.args, "-scan");
|
app.scan_and_exit = array_find(app.args, "-scan");
|
||||||
app.segment_and_exit = array_find(app.args, "-segment");
|
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" && 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");
|
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 == "-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];
|
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"
|
encoder: string; // "h264_nvenc"
|
||||||
source: string; // how the screen was taken: Record_Stats.source
|
source: string; // how the screen was taken: Record_Stats.source
|
||||||
dropped: s64; // frames the encoder fell too far behind for
|
dropped: s64; // frames the encoder fell too far behind for
|
||||||
|
paused_seconds: float64; // left out of it
|
||||||
|
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
busy: bool; // cleared by the thread as it ends
|
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
|
text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator
|
||||||
return ifx ok then trim(text) else "";
|
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
|
// 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.
|
// 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
|
// 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
|
// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
|
||||||
// once, without the overlay, whose frozen screen would be in the recording.
|
// 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.
|
// 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 {
|
screen_record: struct {
|
||||||
recording: bool;
|
recording: bool;
|
||||||
flag_started: bool; // -record has started its recording
|
flag_started: bool; // -record has started its recording
|
||||||
flag_start: Apollo_Time;
|
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
|
shot: Screen_Shot; // the region and the window it is of; no pixels
|
||||||
date: string; // when it started, as CaptureDate; heap
|
date: string; // when it started, as CaptureDate; heap
|
||||||
path: string; // the MP4 being written; heap
|
path: string; // the MP4 being written; heap
|
||||||
@ -40,13 +48,25 @@ screen_record_frame :: () {
|
|||||||
if failed {
|
if failed {
|
||||||
toast_show(.Error, "The recording stopped: %", why);
|
toast_show(.Error, "The recording stopped: %", why);
|
||||||
screen_record_stop();
|
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
|
// -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
|
||||||
// screen_record_saved ends the app once it is in the library. With -record-quit, the app is quit while
|
// -record-region X Y W H; then screen_record_saved ends the app once it is in the library. With
|
||||||
// still recording instead, as quitting from the tray would: the shutdown keeps the recording.
|
// -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 :: () {
|
screen_record_flag_frame :: () {
|
||||||
if !screen_record.flag_started {
|
if !screen_record.flag_started {
|
||||||
if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it
|
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.
|
// On the shutdown thread: a recording running is stopped and kept.
|
||||||
screen_record_shutdown :: () {
|
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 {
|
#if OS == .WINDOWS {
|
||||||
@ -87,23 +181,33 @@ screen_record_start :: (immediate := false) {
|
|||||||
if app_exit_shutdown_started() return;
|
if app_exit_shutdown_started() return;
|
||||||
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||||
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
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 !ok {
|
||||||
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
||||||
else log("[record] Cancelled.");
|
else log("[record] Cancelled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
folder := tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
|
folder := screen_record_folder();
|
||||||
make_directory_if_it_does_not_exist(folder, recursive = true);
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||||
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
|
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
|
||||||
path := tprint("%/recording-%.mp4", folder, stamp);
|
path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
|
||||||
number := 2;
|
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: Record_Options;
|
||||||
options.path = path;
|
options.path = path;
|
||||||
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
|
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);
|
started, start_error := record_start(*screen_record.recorder, options);
|
||||||
if !started {
|
if !started {
|
||||||
log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error);
|
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,
|
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);
|
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());
|
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).
|
// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
|
||||||
screen_record_stop :: (quiet := false) {
|
screen_record_discard :: () {
|
||||||
if !screen_record.recording return;
|
if !screen_record.recording return;
|
||||||
screen_record.recording = false;
|
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);
|
stopped, stop_error := record_stop(*screen_record.recorder);
|
||||||
stats := screen_record.recorder.stats;
|
stats := screen_record.recorder.stats;
|
||||||
if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
|
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.encoder = copy_string(stats.encoder,, context.default_allocator);
|
||||||
job.source = copy_string(stats.source,, context.default_allocator);
|
job.source = copy_string(stats.source,, context.default_allocator);
|
||||||
job.dropped = stats.dropped;
|
job.dropped = stats.dropped;
|
||||||
|
job.paused_seconds = stats.paused_seconds;
|
||||||
screen_record.shot = .{};
|
screen_record.shot = .{};
|
||||||
screen_record.date, screen_record.path = "", "";
|
screen_record.date, screen_record.path = "", "";
|
||||||
screen_capture_job_start(job);
|
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);
|
if !hashed return "", tprint("% cannot be read", job.recording);
|
||||||
|
|
||||||
shot := job.shot;
|
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);
|
doc := New(Capture_Document,, temp);
|
||||||
capture_document_init(doc, temp);
|
capture_document_init(doc, temp);
|
||||||
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
|
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);
|
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(doc, info, "VideoCodec", import_text(video.codec));
|
||||||
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
|
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
|
||||||
import_add(doc, info, "Encoder", import_text(job.encoder));
|
if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
|
||||||
import_add(doc, info, "Capture", import_text(job.source));
|
if job.source import_add(doc, info, "Capture", import_text(job.source));
|
||||||
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
|
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);
|
name := import_out_name(job.date, hash);
|
||||||
text, text_ok := import_document_text(doc, name);
|
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");
|
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 {
|
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||||
return "", "screen recording is Windows only";
|
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);
|
catalog_entry_free(entry);
|
||||||
}
|
}
|
||||||
file_delete(path);
|
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.
|
// 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;
|
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.
|
// The [hotkeys] lines. Only meaningful when the section was seen; see hotkeys_install.
|
||||||
hotkey_bindings: [..] Hotkey_Binding;
|
hotkey_bindings: [..] Hotkey_Binding;
|
||||||
// The [global_hotkeys] lines, likewise; see global_hotkeys_install.
|
// The [global_hotkeys] lines, likewise; see global_hotkeys_install.
|
||||||
@ -315,6 +321,7 @@ App_User_Settings :: struct {
|
|||||||
settings_free :: (s: *App_User_Settings) {
|
settings_free :: (s: *App_User_Settings) {
|
||||||
free(s.theme,, context.default_allocator);
|
free(s.theme,, context.default_allocator);
|
||||||
free(s.ocr_model,, 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.hotkey_bindings);
|
||||||
array_free(s.global_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_DEFAULT :: 4096;
|
||||||
EVENT_LOG_CAPACITY_MIN :: 64;
|
EVENT_LOG_CAPACITY_MIN :: 64;
|
||||||
EVENT_LOG_CAPACITY_MAX :: 1_000_000;
|
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
|
// 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.
|
// 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 hotkeys_missing.count || global_missing.count return true;
|
||||||
|
|
||||||
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
|
#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 {
|
Settings_Seen :: struct {
|
||||||
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool;
|
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
|
// 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);
|
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" {
|
} 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);
|
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" {
|
} 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);
|
seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX);
|
||||||
} else {
|
} 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.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.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.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;
|
builder: String_Builder;
|
||||||
@ -658,6 +688,13 @@ app_save_user_settings_file :: () -> bool {
|
|||||||
append(*builder, "[ocr]\n");
|
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");
|
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));
|
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, "[logging]\n");
|
||||||
append(*builder, "# Entries the Event Log keeps in memory. Past it the oldest are dropped; the log file keeps every line.\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);
|
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.
|
// -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.
|
// -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.
|
// -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;
|
// -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.
|
// 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
|
// -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]
|
// 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]
|
// [-out FILE.mp4] [-no-cursor] [-legacy] [-cuda] [-exclude-test] [-snapshot DIR]
|
||||||
// [-animate] [-capture auto|dxgi|wgc] [-border-test] [-keep-border] [-cursor-test]
|
// [-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).
|
// Exit code: 0 recorded, 1 failed, 3 Desktop Duplication cannot run here (the fallback's case).
|
||||||
|
|
||||||
main :: () {
|
main :: () {
|
||||||
@ -37,6 +39,7 @@ main :: () {
|
|||||||
seconds: float64 = 10;
|
seconds: float64 = 10;
|
||||||
list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false;
|
list_only, cuda, exclude_test, dpi_unaware, animate, border_test, cursor_test := false, false, false, false, false, false, false;
|
||||||
snapshot_folder: string;
|
snapshot_folder: string;
|
||||||
|
pause_seconds: float64;
|
||||||
i := 1;
|
i := 1;
|
||||||
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
|
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
|
||||||
while i < args.count {
|
while i < args.count {
|
||||||
@ -64,6 +67,8 @@ main :: () {
|
|||||||
if a == "-keep-border" { options.testing |= .KEEP_BORDER; i += 1; continue; }
|
if a == "-keep-border" { options.testing |= .KEEP_BORDER; i += 1; continue; }
|
||||||
if a == "-simulate-unsupported" { options.testing |= .DXGI_UNSUPPORTED; 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 == "-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 == "-cursor-test" { cursor_test = true; i += 1; continue; }
|
||||||
if a == "-capture" && more >= 1 {
|
if a == "-capture" && more >= 1 {
|
||||||
how := args[i + 1];
|
how := args[i + 1];
|
||||||
@ -161,6 +166,14 @@ main :: () {
|
|||||||
print(" % s: % captured, % sent (% repeats), % written, % dropped, % lost, % KB\n",
|
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);
|
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
|
if border_test && border_during < 0 && to_float64_seconds(current_time_monotonic() - start) >= 1.5
|
||||||
border_during = yellow_edge_pixels(border_screen);
|
border_during = yellow_edge_pixels(border_screen);
|
||||||
failed, _ := record_failed(*recorder);
|
failed, _ := record_failed(*recorder);
|
||||||
@ -184,7 +197,7 @@ main :: () {
|
|||||||
print(" seconds: %\n", FF(s.seconds, 1));
|
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(" 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(" 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",
|
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));
|
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));
|
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>
|
<header>
|
||||||
<h1>Screen video capture in mcap</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="summary">
|
<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 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 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 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>
|
<li><span class="tag later">Later</span> Audio is phase 5. Recording one window, and regions that span two screens, come after.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -412,7 +413,7 @@ Captured_Frame :: struct {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="phase">
|
<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>
|
<p>The pieces:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The border and toolbar windows, kept out of the capture.</li>
|
<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>
|
<li>The <code>[video]</code> settings: frame rate, cursor, capture method.</li>
|
||||||
</ul>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div class="phase">
|
<div class="phase">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user