[0.1.5] Video capture phase 3: Ctrl+PrintScreen records a region into the library
- record_region, a new [global_hotkeys] action on Ctrl-PrintScreen (Windows only; none on macOS): starts a recording, or stops the one running. - The overlay's video mode (screen_capture_win32.jai): red outline and magnifier; the drag held to the screen it began on; under 240 x 180 (record_size_allowed, in Screen_Record) the outline is dashed, the label gives the minimum, and letting go takes nothing; a click or Enter takes the whole screen; no pixels copied. With immediate, no overlay at all: the screen under the cursor, for PrintScreen while recording. - src/app/screen_record.jai: records with modules/Screen_Record to %TEMP%/mcap at 30 fps; a recording that stops by itself keeps what it has, as does quitting (the shutdown step stops it). The MP4 goes into the library through the stills' jobs: a video .mcap in Screen Captures, the MP4 its original, a frame of it its rendered image, metadata.json as a still's, capture.json with Duration, FrameRate, VideoCodec, PosterTime, Encoder, Capture and DroppedFrames; the MP4 is deleted once it is in. The catalog reads a video's keys from capture.json too. - The capture thread makes itself aware of each monitor's DPI: ImGui makes only the main thread aware, and Desktop Duplication refused the app's capture thread with DXGI_ERROR_UNSUPPORTED (the fallback recorded with Windows.Graphics.Capture, and the log said so). - -record SECONDS records the screen under the cursor, puts it in the library and exits; -record-quit quits while recording instead. Both pass on a scratch library. - Tests: a "screen recording" group (the size rule; an MP4 from the module's encoder written as a recording reads back, and the catalog takes it as a video). The segment overlay test no longer depends on the colours chosen in the app's own data folder. - README, the format's README and the plan describe it.
This commit is contained in:
parent
9953ba6f1f
commit
93274a2b04
27
README.md
27
README.md
@ -176,6 +176,32 @@ have PrintScreen: if Snagit is running it has it, and so does Windows while Sett
|
|||||||
Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot
|
Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot
|
||||||
register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`.
|
register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`.
|
||||||
|
|
||||||
|
### Recording the screen
|
||||||
|
|
||||||
|
**Ctrl+PrintScreen** records a region of one screen as a video:
|
||||||
|
|
||||||
|
1. **The screens freeze**, as for a still, with the outline and magnifier in red.
|
||||||
|
2. **Drag a region**, or click (or press Enter) for the whole screen under the cursor:
|
||||||
|
- The drag stays on the screen it began on: a recording is of one screen.
|
||||||
|
- A region smaller than **240 × 180** is outlined dashed, with the minimum in the label, and letting
|
||||||
|
go of it starts nothing; drag again.
|
||||||
|
- Escape or a right click cancels.
|
||||||
|
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
|
||||||
|
only when the screen changes, so a still screen costs almost nothing.
|
||||||
|
4. **Ctrl+PrintScreen again** stops it. The video goes into **Screen Captures** beside the stills,
|
||||||
|
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
|
||||||
|
OCR and labels read a frame from it.
|
||||||
|
|
||||||
|
- **The screen** is taken with DXGI Desktop Duplication, or with Windows.Graphics.Capture where that
|
||||||
|
cannot run (a laptop with two GPUs, a rotated screen).
|
||||||
|
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
|
||||||
|
Foundation, or libx264.
|
||||||
|
- **Losing nothing:** a recording that stops by itself keeps what it has, and so does quitting while
|
||||||
|
recording.
|
||||||
|
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
|
||||||
|
the screens, which would show in the recording.
|
||||||
|
- **The key** is `record_region` in `[global_hotkeys]`.
|
||||||
|
|
||||||
## Importing
|
## Importing
|
||||||
|
|
||||||
**Import** (above the grid) brings a folder of screenshots, photos and videos into the library:
|
**Import** (above the grid) brings a folder of screenshots, photos and videos into the library:
|
||||||
@ -305,6 +331,7 @@ then in that order instead of by date.
|
|||||||
| `-search QUERY` | Starts with QUERY in the search box. |
|
| `-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. |
|
||||||
| `-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. |
|
||||||
|
|||||||
@ -12,3 +12,7 @@ none bring_to_foreground
|
|||||||
# key, such as Ctrl-Shift-S. Windows takes it as well while Settings > Accessibility > Keyboard >
|
# key, such as Ctrl-Shift-S. Windows takes it as well while Settings > Accessibility > Keyboard >
|
||||||
# "Use the Print screen key to open screen capture" is on.
|
# "Use the Print screen key to open screen capture" is on.
|
||||||
PrintScreen capture_region
|
PrintScreen capture_region
|
||||||
|
|
||||||
|
# Records a region of one screen (dragged in the same frozen view, at least 240 x 180, or a click
|
||||||
|
# for the whole screen) until pressed again; it goes into the library as a video.
|
||||||
|
Ctrl-PrintScreen record_region
|
||||||
|
|||||||
@ -6,3 +6,4 @@
|
|||||||
|
|
||||||
none bring_to_foreground
|
none bring_to_foreground
|
||||||
none capture_region
|
none capture_region
|
||||||
|
none record_region
|
||||||
|
|||||||
@ -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.4";
|
VERSION :: "0.1.5";
|
||||||
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.4";
|
VERSION :: "0.1.5";
|
||||||
// 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";
|
||||||
|
|||||||
@ -6,8 +6,8 @@ then the original and rendered image, then any large chunks. There is no ZIP and
|
|||||||
it; the images and videos keep their own compression.
|
it; the images and videos keep their own compression.
|
||||||
|
|
||||||
A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image
|
A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image
|
||||||
format), an imported video (the mcap app's Import window), or a screen capture (the mcap app's
|
format), an imported video (the mcap app's Import window), or a screen capture or recording (the mcap app's
|
||||||
PrintScreen).
|
PrintScreen and Ctrl+PrintScreen).
|
||||||
|
|
||||||
The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
|
The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
|
||||||
the start of each file and never touches the images; a search, which needs no preview, reads less still.
|
the start of each file and never touches the images; a search, which needs no preview, reads less still.
|
||||||
@ -118,6 +118,25 @@ ContentHash: "5c1e9a0d77b2f413" 64-bit FNV-1a of the PNG's bytes
|
|||||||
Format: "PNG"
|
Format: "PNG"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Screen recordings
|
||||||
|
|
||||||
|
A recording made with the mcap app's Ctrl+PrintScreen is a video: its original is the H.264 MP4 as
|
||||||
|
recorded, and its rendered image (kind 2) a frame from it as a PNG, as for an imported video. Its
|
||||||
|
metadata.json is a still's. Its capture.json has a still's keys, with Format "MP4", and these:
|
||||||
|
|
||||||
|
```
|
||||||
|
Duration: 12.48 seconds
|
||||||
|
FrameRate: 30 as the container says; frames are written only when the screen changes
|
||||||
|
VideoCodec: "h264"
|
||||||
|
PosterTime: 1.25 seconds into it of the rendered frame
|
||||||
|
Encoder: "h264_nvenc" FFmpeg's encoder: h264_nvenc, h264_amf, h264_qsv, h264_mf or libx264
|
||||||
|
Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU"
|
||||||
|
or "Windows.Graphics.Capture on ..., border off"
|
||||||
|
DroppedFrames: 0 frames the encoder fell too far behind for
|
||||||
|
```
|
||||||
|
|
||||||
|
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
|
||||||
|
|
||||||
## Imported videos
|
## Imported videos
|
||||||
|
|
||||||
A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is
|
A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is
|
||||||
|
|||||||
@ -66,8 +66,18 @@ gpu_close :: (g: *Capture_Gpu) {
|
|||||||
com_release(*g.adapter);
|
com_release(*g.adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The calling thread made aware of each monitor's DPI (Windows 10 1703 on), so the desktop it is told
|
||||||
|
// of is in the screens' own pixels.
|
||||||
|
gpu_thread_dpi_aware :: () {
|
||||||
|
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 :: cast(HANDLE) -4;
|
||||||
|
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||||
|
}
|
||||||
|
|
||||||
#scope_module
|
#scope_module
|
||||||
|
|
||||||
|
user32_dll :: #library,system "user32";
|
||||||
|
SetThreadDpiAwarenessContext :: (value: HANDLE) -> HANDLE #foreign user32_dll;
|
||||||
|
|
||||||
com_release :: (p: *$T) {
|
com_release :: (p: *$T) {
|
||||||
if p.* { IUnknown_Release(cast(*IUnknown) p.*); p.* = null; }
|
if p.* { IUnknown_Release(cast(*IUnknown) p.*); p.* = null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,12 @@ RECORD_MIN_HEIGHT :: 180;
|
|||||||
RECORD_QUEUE :: 8; // frames waiting for the encoder at most
|
RECORD_QUEUE :: 8; // frames waiting for the encoder at most
|
||||||
RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded
|
RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded
|
||||||
|
|
||||||
|
// Whether a region this size may be recorded: at least RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT. It is
|
||||||
|
// rounded down to even sizes after, which keeps it at least that, as both are even.
|
||||||
|
record_size_allowed :: (width: s64, height: s64) -> bool {
|
||||||
|
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
Record_Screen :: struct {
|
Record_Screen :: struct {
|
||||||
adapter_index, output_index: u32;
|
adapter_index, output_index: u32;
|
||||||
adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
|
adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
|
||||||
@ -144,7 +150,7 @@ record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: stri
|
|||||||
// The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
|
// The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
|
||||||
// RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
|
// RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
|
||||||
#assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
|
#assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
|
||||||
if region.right - region.left < RECORD_MIN_WIDTH || region.bottom - region.top < RECORD_MIN_HEIGHT
|
if !record_size_allowed(region.right - region.left, region.bottom - region.top)
|
||||||
return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
|
return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
|
||||||
d := r.screen.desktop;
|
d := r.screen.desktop;
|
||||||
r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
|
r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
|
||||||
@ -291,6 +297,9 @@ record_open_source :: (r: *Recorder) -> bool {
|
|||||||
|
|
||||||
record_capture_proc :: (thread: *Thread) -> s64 {
|
record_capture_proc :: (thread: *Thread) -> s64 {
|
||||||
r := cast(*Recorder) thread.data;
|
r := cast(*Recorder) thread.data;
|
||||||
|
// Aware of each monitor's DPI, whatever the process is: Desktop Duplication refuses a thread that is
|
||||||
|
// not (DXGI_ERROR_UNSUPPORTED), and the mcap app's ImGui makes only its main thread aware.
|
||||||
|
gpu_thread_dpi_aware();
|
||||||
wgc_thread_start();
|
wgc_thread_start();
|
||||||
defer wgc_thread_end();
|
defer wgc_thread_end();
|
||||||
r.open_ok = record_open_source(r);
|
r.open_ok = record_open_source(r);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ USING_WINDOWS :: OS == .WINDOWS;
|
|||||||
#load "app/library_folders.jai";
|
#load "app/library_folders.jai";
|
||||||
#load "app/exit_shutdown.jai";
|
#load "app/exit_shutdown.jai";
|
||||||
#load "app/screen_capture.jai";
|
#load "app/screen_capture.jai";
|
||||||
|
#load "app/screen_record.jai";
|
||||||
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
|
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
|
||||||
|
|
||||||
#load "base/circle_buffer.jai";
|
#load "base/circle_buffer.jai";
|
||||||
@ -74,6 +75,7 @@ Shared_Channel :: #import "Shared_Memory_Channel";
|
|||||||
#import "Capture_Text";
|
#import "Capture_Text";
|
||||||
#import "Base64";
|
#import "Base64";
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
|
#if OS == .WINDOWS #import "Screen_Record"; // screen recording: Desktop Duplication or Windows.Graphics.Capture, to NVENC
|
||||||
|
|
||||||
// Basic's arenas import Secure_Memory at its defaults; this import adds its test suite.
|
// Basic's arenas import Secure_Memory at its defaults; this import adds its test suite.
|
||||||
using Secure_Memory :: #import "Secure_Memory"()(LOAD_TESTS=COMPILE_TESTS);
|
using Secure_Memory :: #import "Secure_Memory"()(LOAD_TESTS=COMPILE_TESTS);
|
||||||
@ -185,6 +187,7 @@ App :: struct {
|
|||||||
minimize_to_tray_this_frame: bool;
|
minimize_to_tray_this_frame: bool;
|
||||||
restore_window_this_frame: bool;
|
restore_window_this_frame: bool;
|
||||||
capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai
|
capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai
|
||||||
|
record_region_this_frame: bool; // the record_region global hotkey fired: app/screen_record.jai
|
||||||
is_master: bool; // Are we the first instance of this app launched?
|
is_master: bool; // Are we the first instance of this app launched?
|
||||||
allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one.
|
allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one.
|
||||||
should_exit_early: bool;
|
should_exit_early: bool;
|
||||||
@ -196,6 +199,7 @@ App :: struct {
|
|||||||
stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit
|
stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit
|
||||||
start_search: string; // -search QUERY: the search box's text at startup
|
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)
|
||||||
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;
|
||||||
|
|||||||
@ -92,7 +92,9 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
|||||||
entry.url = catalog_string(metadata.root, "WebURL");
|
entry.url = catalog_string(metadata.root, "WebURL");
|
||||||
entry.app = catalog_string(metadata.root, "AppName");
|
entry.app = catalog_string(metadata.root, "AppName");
|
||||||
}
|
}
|
||||||
|
// An import's import.json, or a screen recording's capture.json, says what kind of media it is.
|
||||||
imported := capture_find_file(*doc, "import.json");
|
imported := capture_find_file(*doc, "import.json");
|
||||||
|
if !imported imported = capture_find_file(*doc, "capture.json");
|
||||||
if imported {
|
if imported {
|
||||||
entry.video_codec = catalog_string(imported.root, "VideoCodec");
|
entry.video_codec = catalog_string(imported.root, "VideoCodec");
|
||||||
entry.duration = catalog_number(imported.root, "Duration");
|
entry.duration = catalog_number(imported.root, "Duration");
|
||||||
|
|||||||
@ -25,6 +25,7 @@ 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);
|
||||||
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];
|
||||||
@ -472,6 +473,12 @@ app_frame :: (in_live_resize := false) -> bool {
|
|||||||
screen_capture_take();
|
screen_capture_take();
|
||||||
app.capture_region_this_frame = false; // the hotkey pressed again while choosing
|
app.capture_region_this_frame = false; // the hotkey pressed again while choosing
|
||||||
}
|
}
|
||||||
|
if app.record_region_this_frame && !in_live_resize {
|
||||||
|
app.record_region_this_frame = false;
|
||||||
|
screen_record_toggle();
|
||||||
|
app.record_region_this_frame = false;
|
||||||
|
app.capture_region_this_frame = false;
|
||||||
|
}
|
||||||
|
|
||||||
if app.should_exit {
|
if app.should_exit {
|
||||||
app_exit_shutdown_start();
|
app_exit_shutdown_start();
|
||||||
|
|||||||
@ -7,13 +7,22 @@
|
|||||||
// it in the Capture window, and OCR and labels read it once they are free.
|
// it in the Capture window, and OCR and labels read it once they are free.
|
||||||
//
|
//
|
||||||
// The PNG is made and the file written on a thread of its own, as a large capture takes a second or
|
// The PNG is made and the file written on a thread of its own, as a large capture takes a second or
|
||||||
// so to compress.
|
// so to compress. A screen recording (app/screen_record.jai) goes into the library through the same
|
||||||
|
// jobs.
|
||||||
|
|
||||||
SCREEN_CAPTURE_FOLDER :: "Screen Captures"; // in the library
|
SCREEN_CAPTURE_FOLDER :: "Screen Captures"; // in the library
|
||||||
|
|
||||||
Screen_Capture_Job :: struct {
|
Screen_Capture_Job :: struct {
|
||||||
shot: Screen_Shot;
|
shot: Screen_Shot;
|
||||||
date: string; // CaptureDate; heap
|
date: string; // CaptureDate; heap
|
||||||
|
|
||||||
|
// A recording (app/screen_record.jai), not a still; its strings are heap.
|
||||||
|
video: bool;
|
||||||
|
recording: string; // the MP4 recorded, deleted once in the library
|
||||||
|
encoder: string; // "h264_nvenc"
|
||||||
|
source: string; // how the screen was taken: Record_Stats.source
|
||||||
|
dropped: s64; // frames the encoder fell too far behind for
|
||||||
|
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
busy: bool; // cleared by the thread as it ends
|
busy: bool; // cleared by the thread as it ends
|
||||||
path: string; // what it wrote; heap
|
path: string; // what it wrote; heap
|
||||||
@ -32,7 +41,9 @@ screen_capture_take :: () {
|
|||||||
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));
|
||||||
start := current_time_monotonic();
|
start := current_time_monotonic();
|
||||||
shot, ok, error := platform_screen_capture_select();
|
// While recording, the overlay's frozen screen would be in the recording: the screen under the
|
||||||
|
// cursor is taken at once instead.
|
||||||
|
shot, ok, error := platform_screen_capture_select(immediate = screen_record.recording);
|
||||||
if !ok {
|
if !ok {
|
||||||
if error { log_error("[capture] %.", error); toast_show(.Error, "Screen capture: %", error); }
|
if error { log_error("[capture] %.", error); toast_show(.Error, "Screen capture: %", error); }
|
||||||
else log("[capture] Cancelled.");
|
else log("[capture] Cancelled.");
|
||||||
@ -47,29 +58,40 @@ screen_capture_take :: () {
|
|||||||
job := New(Screen_Capture_Job,, context.default_allocator);
|
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||||
job.shot = shot;
|
job.shot = shot;
|
||||||
job.date = copy_string(date,, context.default_allocator);
|
job.date = copy_string(date,, context.default_allocator);
|
||||||
|
screen_capture_job_start(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the job into the library on a thread of its own; screen_capture_frame takes it from there.
|
||||||
|
// The job is freed when it cannot start.
|
||||||
|
screen_capture_job_start :: (job: *Screen_Capture_Job) -> bool {
|
||||||
job.busy = true;
|
job.busy = true;
|
||||||
if !thread_init(*job.thread, screen_capture_save_proc, temporary_storage_size = 1024 * 1024, name = "screen capture") {
|
if !thread_init(*job.thread, screen_capture_save_proc, temporary_storage_size = 1024 * 1024, name = "screen capture") {
|
||||||
log_error("[capture] Could not start the thread to save the capture.");
|
what := ifx job.video then "recording" else "capture";
|
||||||
toast_show(.Error, "The screen capture could not be saved");
|
log_error("[capture] Could not start the thread to save the %.", what);
|
||||||
|
toast_show(.Error, "The screen % could not be saved", what);
|
||||||
screen_capture_job_free(job);
|
screen_capture_job_free(job);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
thread_start(*job.thread, job);
|
thread_start(*job.thread, job);
|
||||||
screen_capture.jobs.allocator = context.default_allocator;
|
screen_capture.jobs.allocator = context.default_allocator;
|
||||||
array_add(*screen_capture.jobs, job);
|
array_add(*screen_capture.jobs, job);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every frame, even while hidden in the tray: the captures written, into the grid and the Capture
|
// Every frame, even while hidden in the tray: the captures written, into the grid and the Capture
|
||||||
// window, and then read.
|
// window, and then read.
|
||||||
screen_capture_frame :: () {
|
screen_capture_frame :: () {
|
||||||
if app_exit_shutdown_started() return;
|
if app_exit_shutdown_started() return;
|
||||||
|
screen_record_frame();
|
||||||
for job: screen_capture.jobs {
|
for job: screen_capture.jobs {
|
||||||
if atomic_read(*job.busy) continue;
|
if atomic_read(*job.busy) continue;
|
||||||
thread_is_done(*job.thread, -1);
|
thread_is_done(*job.thread, -1);
|
||||||
thread_deinit(*job.thread);
|
thread_deinit(*job.thread);
|
||||||
|
what := ifx job.video then "recording" else "capture";
|
||||||
|
if job.video screen_record_saved(job.path, job.error);
|
||||||
if job.error {
|
if job.error {
|
||||||
log_error("[capture] Could not save the capture: %", job.error);
|
log_error("[capture] Could not save the %: %", what, job.error);
|
||||||
toast_show(.Error, "The screen capture could not be saved: %", job.error);
|
toast_show(.Error, "The screen % could not be saved: %", what, job.error);
|
||||||
} else {
|
} else {
|
||||||
log("[capture] Saved %.", job.path);
|
log("[capture] Saved %.", job.path);
|
||||||
index := viewer_add_capture(job.path);
|
index := viewer_add_capture(job.path);
|
||||||
@ -78,10 +100,11 @@ screen_capture_frame :: () {
|
|||||||
screen_capture.scan_pending.allocator = context.default_allocator;
|
screen_capture.scan_pending.allocator = context.default_allocator;
|
||||||
screen_capture.segment_pending.allocator = context.default_allocator;
|
screen_capture.segment_pending.allocator = context.default_allocator;
|
||||||
array_add(*screen_capture.scan_pending, index);
|
array_add(*screen_capture.scan_pending, index);
|
||||||
if library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index);
|
if !job.video && library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index);
|
||||||
}
|
}
|
||||||
app.restore_window_this_frame = true;
|
app.restore_window_this_frame = true;
|
||||||
toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height);
|
if job.video toast_show(.Success, "% x % recorded", job.shot.width, job.shot.height);
|
||||||
|
else toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height);
|
||||||
}
|
}
|
||||||
screen_capture_job_free(job);
|
screen_capture_job_free(job);
|
||||||
remove job;
|
remove job;
|
||||||
@ -105,6 +128,7 @@ screen_capture_frame :: () {
|
|||||||
|
|
||||||
// On the shutdown thread: a capture being written is finished, not lost.
|
// On the shutdown thread: a capture being written is finished, not lost.
|
||||||
screen_capture_shutdown :: () {
|
screen_capture_shutdown :: () {
|
||||||
|
screen_record_shutdown(); // a recording running is stopped and becomes a job, finished below
|
||||||
for screen_capture.jobs thread_is_done(*it.thread, -1);
|
for screen_capture.jobs thread_is_done(*it.thread, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +138,9 @@ screen_capture_job_free :: (job: *Screen_Capture_Job) {
|
|||||||
free(job.date);
|
free(job.date);
|
||||||
free(job.path);
|
free(job.path);
|
||||||
free(job.error);
|
free(job.error);
|
||||||
|
free(job.recording);
|
||||||
|
free(job.encoder);
|
||||||
|
free(job.source);
|
||||||
free(job);
|
free(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +148,9 @@ screen_capture_save_proc :: (thread: *Thread) -> s64 {
|
|||||||
job := cast(*Screen_Capture_Job) thread.data;
|
job := cast(*Screen_Capture_Job) thread.data;
|
||||||
context.allocator = context.default_allocator;
|
context.allocator = context.default_allocator;
|
||||||
defer atomic_swap(*job.busy, false);
|
defer atomic_swap(*job.busy, false);
|
||||||
path, error := screen_capture_write(job.shot, job.date);
|
path, error: string;
|
||||||
|
if job.video path, error = screen_record_write(job);
|
||||||
|
else path, error = screen_capture_write(job.shot, job.date);
|
||||||
if error job.error = copy_string(error);
|
if error job.error = copy_string(error);
|
||||||
else job.path = copy_string(path);
|
else job.path = copy_string(path);
|
||||||
return 0;
|
return 0;
|
||||||
@ -166,6 +195,16 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
|
|||||||
serializer, serialized, serialize_error := mcap_serialize(m,, temp);
|
serializer, serialized, serialize_error := mcap_serialize(m,, temp);
|
||||||
if !serialized return "", serialize_error;
|
if !serialized return "", serialize_error;
|
||||||
|
|
||||||
|
path, folder_error := screen_capture_out_path(name);
|
||||||
|
if folder_error return "", folder_error;
|
||||||
|
written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data});
|
||||||
|
if !written return "", write_error;
|
||||||
|
return path, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a capture named name goes in SCREEN_CAPTURE_FOLDER, made if need be: name, or name_2 ... when
|
||||||
|
// another file has it. Temp.
|
||||||
|
screen_capture_out_path :: (name: string) -> path: string, error: string {
|
||||||
folder := tprint("%/%", viewer_folder(), SCREEN_CAPTURE_FOLDER);
|
folder := tprint("%/%", viewer_folder(), SCREEN_CAPTURE_FOLDER);
|
||||||
if !make_directory_if_it_does_not_exist(folder, recursive = true) return "", tprint("% could not be made", folder);
|
if !make_directory_if_it_does_not_exist(folder, recursive = true) return "", tprint("% could not be made", folder);
|
||||||
path := tprint("%/%", folder, name);
|
path := tprint("%/%", folder, name);
|
||||||
@ -174,8 +213,6 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
|
|||||||
path = tprint("%/%_%.%", folder, path_strip_extension(name), number, MCAP_EXTENSION);
|
path = tprint("%/%_%.%", folder, path_strip_extension(name), number, MCAP_EXTENSION);
|
||||||
number += 1;
|
number += 1;
|
||||||
}
|
}
|
||||||
written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data});
|
|
||||||
if !written return "", write_error;
|
|
||||||
return path, "";
|
return path, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +230,7 @@ screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error
|
|||||||
shot.* = .{};
|
shot.* = .{};
|
||||||
}
|
}
|
||||||
|
|
||||||
platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
|
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
|
||||||
return .{}, false, "not available on this system yet";
|
return .{}, false, "not available on this system yet";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
// position or the selection's size, so a region can be chosen to the pixel; the arrow keys move the
|
// position or the selection's size, so a region can be chosen to the pixel; the arrow keys move the
|
||||||
// cursor a pixel at a time (ten with Shift). Escape or a right click cancels.
|
// cursor a pixel at a time (ten with Shift). Escape or a right click cancels.
|
||||||
//
|
//
|
||||||
|
// For a recording (video), the outline and magnifier are red, the drag is kept on the screen it began
|
||||||
|
// on (a recording is of one screen), and a region smaller than RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT is
|
||||||
|
// outlined dashed, with the minimum in the label, and letting go of it takes nothing: the overlay stays
|
||||||
|
// to drag again. No pixels are copied out for a recording.
|
||||||
|
//
|
||||||
// Drawn with GDI into a bitmap the size of the desktop, so it needs neither ImGui nor the D3D11 device
|
// Drawn with GDI into a bitmap the size of the desktop, so it needs neither ImGui nor the D3D11 device
|
||||||
// and works while the main window is hidden in the tray. Only the parts that change are drawn again.
|
// and works while the main window is hidden in the tray. Only the parts that change are drawn again.
|
||||||
// The selection runs in a modal loop of its own: platform_screen_capture_select returns once a region
|
// The selection runs in a modal loop of its own: platform_screen_capture_select returns once a region
|
||||||
@ -30,31 +35,29 @@ screen_shot_free :: (shot: *Screen_Shot) {
|
|||||||
shot.* = .{};
|
shot.* = .{};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freezes the screens and lets a region be chosen. ok is false when it was cancelled (error "") or
|
// Freezes the screens and lets a region be chosen: for a still, or with video for a recording. With
|
||||||
// could not start (error says why).
|
// immediate, no overlay: the whole screen under the cursor is taken at once. ok is false when it was
|
||||||
platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
|
// cancelled (error "") or could not start (error says why).
|
||||||
|
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
|
||||||
shot: Screen_Shot;
|
shot: Screen_Shot;
|
||||||
fallback_window := GetForegroundWindow(); // before the overlay takes the focus
|
fallback_window := GetForegroundWindow(); // before the overlay takes the focus
|
||||||
|
overlay.video = video;
|
||||||
|
|
||||||
|
// The window procedure runs in this context, during the modal loop below, and in no other.
|
||||||
|
overlay.app_context = *context;
|
||||||
|
defer overlay.app_context = null;
|
||||||
if !overlay_freeze() { overlay_release(); return shot, false, "the screen could not be copied"; }
|
if !overlay_freeze() { overlay_release(); return shot, false, "the screen could not be copied"; }
|
||||||
defer overlay_release();
|
defer overlay_release();
|
||||||
if !overlay_open() return shot, false, "the capture window could not be opened";
|
if immediate {
|
||||||
|
cursor: POINT;
|
||||||
overlay.app_context = *context;
|
GetCursorPos(*cursor);
|
||||||
while !overlay.done {
|
overlay.result = overlay_monitor_rect(.{cursor.x - overlay.left, cursor.y - overlay.top});
|
||||||
msg: MSG;
|
overlay.whole_screen = true;
|
||||||
got := GetMessageW(*msg, null, 0, 0);
|
overlay.cancelled = false;
|
||||||
if got == -1 { overlay.cancelled = true; break; }
|
} else {
|
||||||
if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again
|
if !overlay_open() return shot, false, "the capture window could not be opened";
|
||||||
PostQuitMessage(cast(s32) msg.wParam);
|
overlay_choose();
|
||||||
overlay.cancelled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
TranslateMessage(*msg);
|
|
||||||
DispatchMessageW(*msg);
|
|
||||||
}
|
}
|
||||||
DestroyWindow(overlay.hwnd);
|
|
||||||
overlay.hwnd = null;
|
|
||||||
if overlay.cancelled return shot, false, "";
|
if overlay.cancelled return shot, false, "";
|
||||||
|
|
||||||
r := overlay.result;
|
r := overlay.result;
|
||||||
@ -66,9 +69,9 @@ platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string {
|
|||||||
shot.whole_screen = overlay.whole_screen;
|
shot.whole_screen = overlay.whole_screen;
|
||||||
|
|
||||||
// The desktop's bitmap is BGRA (its alpha undefined), top-down.
|
// The desktop's bitmap is BGRA (its alpha undefined), top-down.
|
||||||
shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
|
if !video shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
|
||||||
out := shot.pixels.data;
|
out := shot.pixels.data;
|
||||||
for y: r.top..r.bottom - 1 {
|
if !video for y: r.top..r.bottom - 1 {
|
||||||
row := overlay.screen_pixels + cast(s64) y * overlay.width;
|
row := overlay.screen_pixels + cast(s64) y * overlay.width;
|
||||||
for x: r.left..r.right - 1 {
|
for x: r.left..r.right - 1 {
|
||||||
p := row[x];
|
p := row[x];
|
||||||
@ -130,6 +133,7 @@ overlay: struct {
|
|||||||
hwnd: HWND;
|
hwnd: HWND;
|
||||||
class_registered: bool;
|
class_registered: bool;
|
||||||
app_context: *#Context;
|
app_context: *#Context;
|
||||||
|
video: bool; // choosing a region to record
|
||||||
|
|
||||||
left, top: s32; // the virtual desktop's top left
|
left, top: s32; // the virtual desktop's top left
|
||||||
width, height: s32; // and size, in pixels
|
width, height: s32; // and size, in pixels
|
||||||
@ -157,10 +161,11 @@ Overlay_Layout :: struct {
|
|||||||
valid: bool;
|
valid: bool;
|
||||||
dragging: bool;
|
dragging: bool;
|
||||||
selection: RECT; // the pixels selected, while dragging
|
selection: RECT; // the pixels selected, while dragging
|
||||||
|
too_small: bool; // to record: smaller than RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT
|
||||||
cursor: POINT;
|
cursor: POINT;
|
||||||
magnifier: RECT; // its frame, and the label under it
|
magnifier: RECT; // its frame, and the label under it
|
||||||
label: RECT;
|
label: RECT;
|
||||||
text: [48] u16;
|
text: [80] u16;
|
||||||
text_count: s32;
|
text_count: s32;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,6 +176,9 @@ BORDER :: 2; // the selection's outline, at 100%
|
|||||||
CLICK_SLOP :: 3; // a drag smaller than this either way is a click
|
CLICK_SLOP :: 3; // a drag smaller than this either way is a click
|
||||||
|
|
||||||
ACCENT_COLOR :: 0x0000A5FF; // COLORREF, 0x00BBGGRR: orange
|
ACCENT_COLOR :: 0x0000A5FF; // COLORREF, 0x00BBGGRR: orange
|
||||||
|
RECORD_COLOR :: 0x002828E8; // red: choosing a region to record
|
||||||
|
DASH :: 6; // a too-small region's dashes and gaps, at 100%
|
||||||
|
GAP :: 4;
|
||||||
LABEL_COLOR :: 0x00202020;
|
LABEL_COLOR :: 0x00202020;
|
||||||
FRAME_COLOR :: 0x00FFFFFF;
|
FRAME_COLOR :: 0x00FFFFFF;
|
||||||
TEXT_COLOR :: 0x00FFFFFF;
|
TEXT_COLOR :: 0x00FFFFFF;
|
||||||
@ -256,7 +264,7 @@ overlay_open :: () -> bool {
|
|||||||
font_height := cast(s32) (15 * overlay.scale + 0.5);
|
font_height := cast(s32) (15 * overlay.scale + 0.5);
|
||||||
overlay.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
|
overlay.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
|
||||||
if overlay.font overlay.font_old = SelectObject(overlay.back_dc, overlay.font);
|
if overlay.font overlay.font_old = SelectObject(overlay.back_dc, overlay.font);
|
||||||
overlay.accent_brush = CreateSolidBrush(ACCENT_COLOR);
|
overlay.accent_brush = CreateSolidBrush(ifx overlay.video then cast(u32) RECORD_COLOR else ACCENT_COLOR);
|
||||||
overlay.black_brush = CreateSolidBrush(0);
|
overlay.black_brush = CreateSolidBrush(0);
|
||||||
overlay.label_brush = CreateSolidBrush(LABEL_COLOR);
|
overlay.label_brush = CreateSolidBrush(LABEL_COLOR);
|
||||||
overlay.frame_brush = CreateSolidBrush(FRAME_COLOR);
|
overlay.frame_brush = CreateSolidBrush(FRAME_COLOR);
|
||||||
@ -376,15 +384,40 @@ overlay_key :: (key: WPARAM) {
|
|||||||
capture_set_cursor_pos(overlay.left + x, overlay.top + y);
|
capture_set_cursor_pos(overlay.left + x, overlay.top + y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The overlay's own message loop, until a region is taken or it is cancelled; then it is closed.
|
||||||
|
overlay_choose :: () {
|
||||||
|
while !overlay.done {
|
||||||
|
msg: MSG;
|
||||||
|
got := GetMessageW(*msg, null, 0, 0);
|
||||||
|
if got == -1 { overlay.cancelled = true; break; }
|
||||||
|
if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again
|
||||||
|
PostQuitMessage(cast(s32) msg.wParam);
|
||||||
|
overlay.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TranslateMessage(*msg);
|
||||||
|
DispatchMessageW(*msg);
|
||||||
|
}
|
||||||
|
DestroyWindow(overlay.hwnd);
|
||||||
|
overlay.hwnd = null;
|
||||||
|
}
|
||||||
|
|
||||||
overlay_cancel :: () {
|
overlay_cancel :: () {
|
||||||
overlay.cancelled = true;
|
overlay.cancelled = true;
|
||||||
overlay.done = true;
|
overlay.done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The selection, or on a click the screen under the cursor.
|
// The selection, or on a click the screen under the cursor. A region too small to record is not
|
||||||
|
// taken: the overlay stays, to drag again.
|
||||||
overlay_take :: () {
|
overlay_take :: () {
|
||||||
selection := overlay_selection();
|
selection := overlay_selection();
|
||||||
if !overlay.dragging || (selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP) {
|
click := !overlay.dragging || overlay_is_click(selection);
|
||||||
|
if overlay.video && !click && overlay_too_small(selection) {
|
||||||
|
overlay.dragging = false;
|
||||||
|
overlay_update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if click {
|
||||||
selection = overlay_monitor_rect(overlay.cursor);
|
selection = overlay_monitor_rect(overlay.cursor);
|
||||||
overlay.whole_screen = true;
|
overlay.whole_screen = true;
|
||||||
}
|
}
|
||||||
@ -397,14 +430,23 @@ overlay_take :: () {
|
|||||||
overlay.done = true;
|
overlay.done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// From the anchor to the cursor, both pixels in it.
|
// From the anchor to the cursor, both pixels in it. To record, on the screen the drag began on.
|
||||||
overlay_selection :: () -> RECT {
|
overlay_selection :: () -> RECT {
|
||||||
a, c := overlay.anchor, overlay.cursor;
|
a, c := overlay.anchor, overlay.cursor;
|
||||||
c.x = clamp(c.x, 0, overlay.width - 1);
|
bounds := ifx overlay.video then overlay_monitor_rect(a) else RECT.{0, 0, overlay.width, overlay.height};
|
||||||
c.y = clamp(c.y, 0, overlay.height - 1);
|
c.x = clamp(c.x, bounds.left, bounds.right - 1);
|
||||||
|
c.y = clamp(c.y, bounds.top, bounds.bottom - 1);
|
||||||
return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1};
|
return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overlay_is_click :: (selection: RECT) -> bool {
|
||||||
|
return selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_too_small :: (selection: RECT) -> bool {
|
||||||
|
return !record_size_allowed(selection.right - selection.left, selection.bottom - selection.top);
|
||||||
|
}
|
||||||
|
|
||||||
// The screen a point is on, in the overlay's pixels.
|
// The screen a point is on, in the overlay's pixels.
|
||||||
overlay_monitor_rect :: (point: POINT) -> RECT {
|
overlay_monitor_rect :: (point: POINT) -> RECT {
|
||||||
monitor := MonitorFromPoint(.{point.x + overlay.left, point.y + overlay.top}, MONITOR_DEFAULTTONEAREST);
|
monitor := MonitorFromPoint(.{point.x + overlay.left, point.y + overlay.top}, MONITOR_DEFAULTTONEAREST);
|
||||||
@ -422,11 +464,15 @@ overlay_update :: () {
|
|||||||
layout.valid = true;
|
layout.valid = true;
|
||||||
layout.dragging = overlay.dragging;
|
layout.dragging = overlay.dragging;
|
||||||
layout.cursor = .{clamp(overlay.cursor.x, 0, overlay.width - 1), clamp(overlay.cursor.y, 0, overlay.height - 1)};
|
layout.cursor = .{clamp(overlay.cursor.x, 0, overlay.width - 1), clamp(overlay.cursor.y, 0, overlay.height - 1)};
|
||||||
if layout.dragging layout.selection = overlay_selection();
|
if layout.dragging {
|
||||||
|
layout.selection = overlay_selection();
|
||||||
|
layout.too_small = overlay.video && !overlay_is_click(layout.selection) && overlay_too_small(layout.selection);
|
||||||
|
}
|
||||||
|
|
||||||
text: string;
|
text: string;
|
||||||
if layout.dragging text = tprint("% × %", layout.selection.right - layout.selection.left, layout.selection.bottom - layout.selection.top);
|
if layout.dragging text = tprint("% × %", layout.selection.right - layout.selection.left, layout.selection.bottom - layout.selection.top);
|
||||||
else text = tprint("%, %", layout.cursor.x + overlay.left, layout.cursor.y + overlay.top);
|
else text = tprint("%, %", layout.cursor.x + overlay.left, layout.cursor.y + overlay.top);
|
||||||
|
if layout.too_small text = tprint("% · at least % × %", text, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT);
|
||||||
wide := utf8_to_wide(text,, temp);
|
wide := utf8_to_wide(text,, temp);
|
||||||
while wide && wide[layout.text_count] && layout.text_count < layout.text.count layout.text_count += 1;
|
while wide && wide[layout.text_count] && layout.text_count < layout.text.count layout.text_count += 1;
|
||||||
memcpy(layout.text.data, wide, layout.text_count * size_of(u16));
|
memcpy(layout.text.data, wide, layout.text_count * size_of(u16));
|
||||||
@ -506,11 +552,29 @@ overlay_draw :: (dc: HDC, layout: Overlay_Layout) {
|
|||||||
if layout.dragging {
|
if layout.dragging {
|
||||||
s := layout.selection;
|
s := layout.selection;
|
||||||
BitBlt(dc, s.left, s.top, s.right - s.left, s.bottom - s.top, overlay.screen_dc, s.left, s.top, .SRCCOPY);
|
BitBlt(dc, s.left, s.top, s.right - s.left, s.bottom - s.top, overlay.screen_dc, s.left, s.top, .SRCCOPY);
|
||||||
// The outline just outside it, so every pixel taken shows as it is.
|
// The outline just outside it, so every pixel taken shows as it is; dashed when too small to record.
|
||||||
fill(.{s.left - border, s.top - border, s.right + border, s.top}, overlay.accent_brush);
|
outline := RECT.[
|
||||||
fill(.{s.left - border, s.bottom, s.right + border, s.bottom + border}, overlay.accent_brush);
|
.{s.left - border, s.top - border, s.right + border, s.top},
|
||||||
fill(.{s.left - border, s.top, s.left, s.bottom}, overlay.accent_brush);
|
.{s.left - border, s.bottom, s.right + border, s.bottom + border},
|
||||||
fill(.{s.right, s.top, s.right + border, s.bottom}, overlay.accent_brush);
|
.{s.left - border, s.top, s.left, s.bottom},
|
||||||
|
.{s.right, s.top, s.right + border, s.bottom},
|
||||||
|
];
|
||||||
|
for outline {
|
||||||
|
if !layout.too_small { fill(it, overlay.accent_brush); continue; }
|
||||||
|
dash := max(cast(s32) (DASH * overlay.scale + 0.5), 2);
|
||||||
|
step := dash + max(cast(s32) (GAP * overlay.scale + 0.5), 2);
|
||||||
|
across := it.right - it.left > it.bottom - it.top;
|
||||||
|
start := ifx across then it.left else it.top;
|
||||||
|
end := ifx across then it.right else it.bottom;
|
||||||
|
position := start;
|
||||||
|
while position < end {
|
||||||
|
piece := it;
|
||||||
|
if across { piece.left = position; piece.right = min(position + dash, end); }
|
||||||
|
else { piece.top = position; piece.bottom = min(position + dash, end); }
|
||||||
|
fill(piece, overlay.accent_brush);
|
||||||
|
position += step;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fill(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}, overlay.accent_brush);
|
fill(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}, overlay.accent_brush);
|
||||||
fill(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}, overlay.accent_brush);
|
fill(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}, overlay.accent_brush);
|
||||||
|
|||||||
238
src/app/screen_record.jai
Normal file
238
src/app/screen_record.jai
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
// Screen recording (video-capture-plan.html): Ctrl+PrintScreen (the record_region global hotkey, from
|
||||||
|
// any app, even with mcap hidden in the tray) freezes the screens as PrintScreen does, in the
|
||||||
|
// overlay's video mode (app/screen_capture_win32.jai): a region of one screen, at least 240 x 180, or
|
||||||
|
// a click for the whole screen. It is recorded with modules/Screen_Record to an MP4 in the temporary
|
||||||
|
// folder until Ctrl+PrintScreen is pressed again. The MP4 then goes into the library as a video .mcap
|
||||||
|
// in SCREEN_CAPTURE_FOLDER, beside the stills, through app/screen_capture.jai's jobs: the MP4 as its
|
||||||
|
// original, a frame from it as its rendered image (which OCR and labels read), the window it was
|
||||||
|
// recorded from in metadata.json, as for a still, and how it was recorded in capture.json.
|
||||||
|
//
|
||||||
|
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
|
||||||
|
// so does quitting while recording. PrintScreen while recording takes the screen under the cursor at
|
||||||
|
// once, without the overlay, whose frozen screen would be in the recording.
|
||||||
|
//
|
||||||
|
// Windows only, as modules/Screen_Record is.
|
||||||
|
|
||||||
|
SCREEN_RECORD_FPS :: 30;
|
||||||
|
|
||||||
|
screen_record: struct {
|
||||||
|
recording: bool;
|
||||||
|
flag_started: bool; // -record has started its recording
|
||||||
|
flag_start: Apollo_Time;
|
||||||
|
shot: Screen_Shot; // the region and the window it is of; no pixels
|
||||||
|
date: string; // when it started, as CaptureDate; heap
|
||||||
|
path: string; // the MP4 being written; heap
|
||||||
|
#if OS == .WINDOWS recorder: Recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From app_frame when the hotkey fired, outside the ImGui frame: starts recording, or stops.
|
||||||
|
screen_record_toggle :: () {
|
||||||
|
if screen_record.recording screen_record_stop();
|
||||||
|
else screen_record_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every frame (from screen_capture_frame): a recording that stopped by itself is finished and kept.
|
||||||
|
screen_record_frame :: () {
|
||||||
|
if app.record_and_exit > 0 screen_record_flag_frame();
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
if !screen_record.recording return;
|
||||||
|
failed, why := record_failed(*screen_record.recorder);
|
||||||
|
if failed {
|
||||||
|
toast_show(.Error, "The recording stopped: %", why);
|
||||||
|
screen_record_stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -record SECONDS: the screen under the cursor, without the overlay, for that long; then
|
||||||
|
// screen_record_saved ends the app once it is in the library. With -record-quit, the app is quit while
|
||||||
|
// still recording instead, as quitting from the tray would: the shutdown keeps the recording.
|
||||||
|
screen_record_flag_frame :: () {
|
||||||
|
if !screen_record.flag_started {
|
||||||
|
if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it
|
||||||
|
screen_record.flag_started = true;
|
||||||
|
screen_record.flag_start = current_time_monotonic();
|
||||||
|
screen_record_start(immediate = true);
|
||||||
|
if !screen_record.recording {
|
||||||
|
log_error("[record] -record could not start.");
|
||||||
|
app.exit_code = 1; app.should_exit = true; app.record_and_exit = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen_record.recording && to_float64_seconds(current_time_monotonic() - screen_record.flag_start) >= app.record_and_exit {
|
||||||
|
if array_find(app.args, "-record-quit") { log("[record] -record-quit: quitting while recording."); app.should_exit = true; app.record_and_exit = 0; }
|
||||||
|
else screen_record_stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recording is in the library (path) or could not be put there (error): -record's end.
|
||||||
|
screen_record_saved :: (path: string, error: string) {
|
||||||
|
if app.record_and_exit <= 0 return;
|
||||||
|
if error log_error("[record] -record failed: %", error);
|
||||||
|
else log("[record] -record is done: %", path);
|
||||||
|
app.exit_code = ifx error then cast(s32) 1 else 0;
|
||||||
|
app.should_exit = true;
|
||||||
|
app.record_and_exit = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the shutdown thread: a recording running is stopped and kept.
|
||||||
|
screen_record_shutdown :: () {
|
||||||
|
if screen_record.recording screen_record_stop(quiet = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
|
||||||
|
// immediate: the screen under the cursor, without the overlay (-record).
|
||||||
|
screen_record_start :: (immediate := false) {
|
||||||
|
if app_exit_shutdown_started() return;
|
||||||
|
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||||
|
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
||||||
|
if !ok {
|
||||||
|
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
||||||
|
else log("[record] Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
|
||||||
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||||
|
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
|
||||||
|
path := tprint("%/recording-%.mp4", folder, stamp);
|
||||||
|
number := 2;
|
||||||
|
while file_exists(path) { path = tprint("%/recording-%_%.mp4", folder, stamp, number); number += 1; }
|
||||||
|
|
||||||
|
options: Record_Options;
|
||||||
|
options.path = path;
|
||||||
|
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
|
||||||
|
options.fps = SCREEN_RECORD_FPS;
|
||||||
|
started, start_error := record_start(*screen_record.recorder, options);
|
||||||
|
if !started {
|
||||||
|
log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error);
|
||||||
|
toast_show(.Error, "Could not record: %", start_error);
|
||||||
|
screen_shot_free(*shot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := record_stats(*screen_record.recorder);
|
||||||
|
screen_record.recording = true;
|
||||||
|
screen_record.shot = shot;
|
||||||
|
screen_record.date = copy_string(date,, context.default_allocator);
|
||||||
|
screen_record.path = copy_string(path,, context.default_allocator);
|
||||||
|
if stats.fell_back_from log("[record] Desktop Duplication could not run: %", stats.fell_back_from);
|
||||||
|
log("[record] Recording % x % at %, % (%) of \"%\" (%) with % and %, to %.", stats.width, stats.height, shot.left, shot.top,
|
||||||
|
ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, stats.source, stats.encoder, path);
|
||||||
|
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops, and hands the MP4 to a job that puts it in the library. quiet: no toasts (shutdown).
|
||||||
|
screen_record_stop :: (quiet := false) {
|
||||||
|
if !screen_record.recording return;
|
||||||
|
screen_record.recording = false;
|
||||||
|
stopped, stop_error := record_stop(*screen_record.recorder);
|
||||||
|
stats := screen_record.recorder.stats;
|
||||||
|
if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
|
||||||
|
log("[record] Stopped after % s: % frames written, % dropped, % KB.", FF(stats.seconds, 1), stats.written, stats.dropped, stats.bytes / 1024);
|
||||||
|
|
||||||
|
if !stats.written {
|
||||||
|
log_error("[record] Nothing was recorded; % is deleted.", screen_record.path);
|
||||||
|
screen_record_saved("", "nothing was recorded");
|
||||||
|
if !quiet toast_show(.Error, "Nothing was recorded");
|
||||||
|
file_delete(screen_record.path);
|
||||||
|
screen_shot_free(*screen_record.shot);
|
||||||
|
free(screen_record.date,, context.default_allocator);
|
||||||
|
free(screen_record.path,, context.default_allocator);
|
||||||
|
screen_record.date, screen_record.path = "", "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||||
|
job.video = true;
|
||||||
|
job.shot = screen_record.shot;
|
||||||
|
job.shot.width, job.shot.height = stats.width, stats.height; // as recorded: rounded down to even
|
||||||
|
job.date = screen_record.date;
|
||||||
|
job.recording = screen_record.path;
|
||||||
|
job.encoder = copy_string(stats.encoder,, context.default_allocator);
|
||||||
|
job.source = copy_string(stats.source,, context.default_allocator);
|
||||||
|
job.dropped = stats.dropped;
|
||||||
|
screen_record.shot = .{};
|
||||||
|
screen_record.date, screen_record.path = "", "";
|
||||||
|
screen_capture_job_start(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recording's .mcap in the library, from the job's MP4, which is deleted once it is in: its path,
|
||||||
|
// or why it could not be (temp). On the job's thread.
|
||||||
|
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||||
|
_, size, found := file_modtime_and_size(job.recording);
|
||||||
|
if !found || size <= 0 return "", tprint("% is missing", job.recording);
|
||||||
|
image, video, decoded, decode_error := video_poster(job.recording);
|
||||||
|
if !decoded return "", tprint("the recording cannot be read back: %", decode_error);
|
||||||
|
defer image_free(*image);
|
||||||
|
preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE);
|
||||||
|
if !preview_ok return "", preview_error;
|
||||||
|
poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size
|
||||||
|
if !poster_ok return "", poster_error;
|
||||||
|
hash, hashed := import_hash_file(.{path = job.recording, offset = 0, size = size});
|
||||||
|
if !hashed return "", tprint("% cannot be read", job.recording);
|
||||||
|
|
||||||
|
shot := job.shot;
|
||||||
|
doc := New(Capture_Document,, temp);
|
||||||
|
capture_document_init(doc, temp);
|
||||||
|
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
|
||||||
|
metadata := capture_add_file(doc, "metadata.json");
|
||||||
|
import_add(doc, metadata, "AppName", import_text(shot.app_name));
|
||||||
|
import_add(doc, metadata, "CaptureDate", job.date);
|
||||||
|
import_add(doc, metadata, "WindowName", import_text(shot.window_title));
|
||||||
|
info := capture_add_file(doc, "capture.json");
|
||||||
|
import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection");
|
||||||
|
import_add_number(doc, info, "Left", xx shot.left);
|
||||||
|
import_add_number(doc, info, "Top", xx shot.top);
|
||||||
|
import_add_number(doc, info, "Width", xx shot.width);
|
||||||
|
import_add_number(doc, info, "Height", xx shot.height);
|
||||||
|
if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path));
|
||||||
|
import_add(doc, info, "ContentHash", import_hash_text(hash));
|
||||||
|
import_add(doc, info, "Format", "MP4");
|
||||||
|
import_add_number(doc, info, "Duration", import_milliseconds(video.duration));
|
||||||
|
if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate);
|
||||||
|
import_add(doc, info, "VideoCodec", import_text(video.codec));
|
||||||
|
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
|
||||||
|
import_add(doc, info, "Encoder", import_text(job.encoder));
|
||||||
|
import_add(doc, info, "Capture", import_text(job.source));
|
||||||
|
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
|
||||||
|
|
||||||
|
name := import_out_name(job.date, hash);
|
||||||
|
text, text_ok := import_document_text(doc, name);
|
||||||
|
if !text_ok return "", "its Capture Text does not read back cleanly (see the Event Log)";
|
||||||
|
|
||||||
|
m: Mcap;
|
||||||
|
m.text = text;
|
||||||
|
m.preview = .{width = cast(u16) preview_width, height = cast(u16) preview_height, png = preview};
|
||||||
|
m.original_name = tprint("%.mp4", path_strip_extension(name));
|
||||||
|
m.rendered_kind = .OWN;
|
||||||
|
m.rendered = poster;
|
||||||
|
path, folder_error := screen_capture_out_path(name);
|
||||||
|
if folder_error return "", folder_error;
|
||||||
|
written, write_error := mcap_write_streamed(path, m, .{path = job.recording, offset = 0, size = size});
|
||||||
|
if !written return "", write_error;
|
||||||
|
file_delete(job.recording);
|
||||||
|
return path, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The record_region hotkey as it is bound, for the toast: "Ctrl+PrintScreen". Temp.
|
||||||
|
screen_record_hotkey_text :: () -> string {
|
||||||
|
if !record_region_hotkey.binding_count return "the record_region hotkey";
|
||||||
|
return to_string(record_region_hotkey.bindings[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
screen_record_start :: (immediate := false) {
|
||||||
|
toast_show(.Error, "Screen recording is not available on this system yet");
|
||||||
|
if app.record_and_exit > 0 screen_record_saved("", "screen recording is Windows only");
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_record_stop :: (quiet := false) {}
|
||||||
|
|
||||||
|
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||||
|
return "", "screen recording is Windows only";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
checks_run := 0;
|
checks_run := 0;
|
||||||
checks_failed := 0;
|
checks_failed := 0;
|
||||||
|
|
||||||
MAX_GROUPS :: 12;
|
MAX_GROUPS :: 16;
|
||||||
group_names : [MAX_GROUPS] string;
|
group_names : [MAX_GROUPS] string;
|
||||||
group_checks : [MAX_GROUPS] int;
|
group_checks : [MAX_GROUPS] int;
|
||||||
group_fails : [MAX_GROUPS] int;
|
group_fails : [MAX_GROUPS] int;
|
||||||
@ -561,7 +561,10 @@ segment_overlay_tests :: () {
|
|||||||
expect(same(segment_class_colour("person", 0), segment_class_default_colour("person")), "other classes keep their own");
|
expect(same(segment_class_colour("person", 0), segment_class_default_colour("person")), "other classes keep their own");
|
||||||
segment_colour_set("dog", .[0, 0, 0], default = true, save = false);
|
segment_colour_set("dog", .[0, 0, 0], default = true, save = false);
|
||||||
expect(same(segment_class_colour("dog", 0), own), "back to its own colour");
|
expect(same(segment_class_colour("dog", 0), own), "back to its own colour");
|
||||||
expect(!segment_ui.overlay_state.colours.count, "and nothing chosen is left");
|
// Only dog's choice is gone: the colours chosen in the app's own data folder are loaded too.
|
||||||
|
dog_chosen := false;
|
||||||
|
for segment_ui.overlay_state.colours if it.class == "dog" dog_chosen = true;
|
||||||
|
expect(!dog_chosen, "and its choice is gone");
|
||||||
|
|
||||||
hidden := segment_overlay_pixels(*overlay, .["person"], 0.5);
|
hidden := segment_overlay_pixels(*overlay, .["person"], 0.5);
|
||||||
expect(pixel(hidden, 0, 0)[3] == 0 && pixel(hidden, 2, 1)[3] == 0, "a hidden class is clear");
|
expect(pixel(hidden, 0, 0)[3] == 0 && pixel(hidden, 2, 1)[3] == 0, "a hidden class is clear");
|
||||||
@ -670,6 +673,85 @@ screen_capture_tests :: () {
|
|||||||
file_delete(again);
|
file_delete(again);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A screen recording written into the library (app/screen_record.jai): the MP4 as its original, a
|
||||||
|
// frame from it as its rendered image, and capture.json saying it is a video, as the catalog reads it.
|
||||||
|
// The MP4 is made here with modules/Screen_Record's encoder, from frames of a changing colour.
|
||||||
|
screen_record_tests :: () {
|
||||||
|
group("screen recording");
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
expect(record_size_allowed(240, 180), "240 x 180 may be recorded");
|
||||||
|
expect(!record_size_allowed(239, 180) && !record_size_allowed(240, 179), "smaller may not");
|
||||||
|
expect(record_size_allowed(3840, 2160), "a 4K screen may");
|
||||||
|
|
||||||
|
folder := tprint("%/mcap-tests", windows_expand_environment("%TEMP%"));
|
||||||
|
library := tprint("%/library", folder);
|
||||||
|
saved_library := app.library_folder;
|
||||||
|
app.library_folder = library;
|
||||||
|
defer app.library_folder = saved_library;
|
||||||
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||||
|
|
||||||
|
WIDTH :: 320;
|
||||||
|
HEIGHT :: 240;
|
||||||
|
FRAMES :: 30;
|
||||||
|
mp4 := tprint("%/recording-test.mp4", folder);
|
||||||
|
encoder: Encoder;
|
||||||
|
opened, open_error := encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
|
||||||
|
expect(opened, "an encoder opened: %", open_error);
|
||||||
|
if !opened return;
|
||||||
|
pixels := NewArray(WIDTH * HEIGHT * 4, u8,, temp);
|
||||||
|
for frame: 0..FRAMES - 1 {
|
||||||
|
for i: 0..WIDTH * HEIGHT - 1 {
|
||||||
|
pixels[i * 4 + 0] = cast,trunc(u8) (frame * 8);
|
||||||
|
pixels[i * 4 + 1] = cast,trunc(u8) (i % WIDTH);
|
||||||
|
pixels[i * 4 + 2] = 128;
|
||||||
|
pixels[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
encoder_write(*encoder, pixels.data, frame * 3000); // 30 a second, in 90 kHz ticks
|
||||||
|
}
|
||||||
|
encoder_name := copy_string(encoder.name,, temp);
|
||||||
|
closed, close_error := encoder_close(*encoder);
|
||||||
|
expect(closed, "the MP4 finished: %", close_error);
|
||||||
|
_, mp4_size, _ := file_modtime_and_size(mp4);
|
||||||
|
|
||||||
|
job: Screen_Capture_Job;
|
||||||
|
job.video = true;
|
||||||
|
job.recording = mp4;
|
||||||
|
job.date = "2026-10-10 15:20:00";
|
||||||
|
job.encoder = encoder_name;
|
||||||
|
job.source = "DXGI DuplicateOutput1 on a test";
|
||||||
|
job.shot.width, job.shot.height = WIDTH, HEIGHT;
|
||||||
|
job.shot.left, job.shot.top = 100, 200;
|
||||||
|
job.shot.window_title = "Recording test - KiCad";
|
||||||
|
job.shot.app_name = "KiCad";
|
||||||
|
path, error := screen_record_write(*job);
|
||||||
|
expect(!error, "written: %", error);
|
||||||
|
if error return;
|
||||||
|
expect(begins_with(path, tprint("%/%/2026-10-10_15-20-00_", library, SCREEN_CAPTURE_FOLDER)), "named by its date, in its folder: %", path);
|
||||||
|
expect(!file_exists(mp4), "the MP4 is deleted once it is in the library");
|
||||||
|
|
||||||
|
m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
|
||||||
|
expect(parsed, "it reads back");
|
||||||
|
if parsed {
|
||||||
|
expect(m.original.count == mp4_size && ends_with(m.original_name, ".mp4"), "the MP4 is its original: % of % bytes, %", m.original.count, mp4_size, m.original_name);
|
||||||
|
expect(m.rendered_kind == .OWN, "a frame of it is its rendered image");
|
||||||
|
poster, decoded := image_decode(m.rendered);
|
||||||
|
expect(decoded && poster.width == WIDTH && poster.height == HEIGHT, "at the recording's size");
|
||||||
|
if decoded image_free(*poster);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, entry_error := catalog_load(path);
|
||||||
|
expect(entry != null, "catalogued: %", entry_error);
|
||||||
|
if entry {
|
||||||
|
expect(entry.media == .VIDEO, "a video, from capture.json");
|
||||||
|
expect(entry.video_codec == "h264", "H.264: %", entry.video_codec);
|
||||||
|
expect(entry.duration > 0.9 && entry.duration < 1.1, "about a second: %", entry.duration);
|
||||||
|
expect(entry.title == "Recording test - KiCad" && entry.app == "KiCad", "the window it was recorded from");
|
||||||
|
catalog_entry_free(entry);
|
||||||
|
}
|
||||||
|
file_delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What mcap_chunks_size counts, here where it cannot be seen.
|
// What mcap_chunks_size counts, here where it cannot be seen.
|
||||||
mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 {
|
mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 {
|
||||||
size: s64 = 4;
|
size: s64 = 4;
|
||||||
@ -694,5 +776,6 @@ run_import_tests :: (builder: *String_Builder = null) -> bool {
|
|||||||
library_folders_tests();
|
library_folders_tests();
|
||||||
segment_overlay_tests();
|
segment_overlay_tests();
|
||||||
screen_capture_tests();
|
screen_capture_tests();
|
||||||
|
screen_record_tests();
|
||||||
return report();
|
return report();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -309,14 +309,17 @@ hotkeys_missing_lines :: (actions: [] Hotkey_Action, bindings: [] Hotkey_Binding
|
|||||||
// RegisterEventHotKey on macOS, none on Linux. They are never matched against ImGui's key state.
|
// RegisterEventHotKey on macOS, none on Linux. They are never matched against ImGui's key state.
|
||||||
bring_to_foreground_hotkey: Hotkey;
|
bring_to_foreground_hotkey: Hotkey;
|
||||||
capture_region_hotkey: Hotkey;
|
capture_region_hotkey: Hotkey;
|
||||||
|
record_region_hotkey: Hotkey;
|
||||||
|
|
||||||
GLOBAL_HOTKEY_BRING_TO_FOREGROUND :: 0; // indices into global_hotkey_actions
|
GLOBAL_HOTKEY_BRING_TO_FOREGROUND :: 0; // indices into global_hotkey_actions
|
||||||
GLOBAL_HOTKEY_CAPTURE_REGION :: 1;
|
GLOBAL_HOTKEY_CAPTURE_REGION :: 1;
|
||||||
GLOBAL_HOTKEY_ACTION_COUNT :: 2; // sizes the platforms' registration arrays
|
GLOBAL_HOTKEY_RECORD_REGION :: 2;
|
||||||
|
GLOBAL_HOTKEY_ACTION_COUNT :: 3; // sizes the platforms' registration arrays
|
||||||
|
|
||||||
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
|
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
|
||||||
array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false});
|
array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false});
|
||||||
array_add(out, .{"capture_region", *capture_region_hotkey, true}); // app/screen_capture.jai
|
array_add(out, .{"capture_region", *capture_region_hotkey, true}); // app/screen_capture.jai
|
||||||
|
array_add(out, .{"record_region", *record_region_hotkey, true}); // app/screen_record.jai
|
||||||
}
|
}
|
||||||
|
|
||||||
global_hotkey_actions: [..] Hotkey_Action;
|
global_hotkey_actions: [..] Hotkey_Action;
|
||||||
@ -408,6 +411,7 @@ global_hotkey_fired :: (id: s64) #c_call {
|
|||||||
action := (id - 1) / MAX_HOTKEY_BINDINGS;
|
action := (id - 1) / MAX_HOTKEY_BINDINGS;
|
||||||
if action == GLOBAL_HOTKEY_BRING_TO_FOREGROUND app.restore_window_this_frame = true;
|
if action == GLOBAL_HOTKEY_BRING_TO_FOREGROUND app.restore_window_this_frame = true;
|
||||||
if action == GLOBAL_HOTKEY_CAPTURE_REGION app.capture_region_this_frame = true;
|
if action == GLOBAL_HOTKEY_CAPTURE_REGION app.capture_region_this_frame = true;
|
||||||
|
if action == GLOBAL_HOTKEY_RECORD_REGION app.record_region_this_frame = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses the lines of section in data, or every line when data has no such header. Returns the
|
// Parses the lines of section in data, or every line when data has no such header. Returns the
|
||||||
|
|||||||
@ -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 and 2 built, mcap 0.1.4</strong></p>
|
<p>Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · <strong>phases 1 to 3 built, mcap 0.1.5</strong></p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
@ -82,6 +82,7 @@
|
|||||||
<li>It's built in five phases. Phase 1 is a command-line probe that answers the two-GPU question on your laptop before anything depends on it.</li>
|
<li>It's built in five phases. Phase 1 is a command-line probe that answers the two-GPU question on your laptop before anything depends on it.</li>
|
||||||
<li><span class="tag decided">Done</span> <strong>Phase 1</strong> (2026-10-10, mcap 0.1.3): <code>modules/Screen_Record</code> and <code>record-probe</code>. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see <a href="#phases">phase 1's result</a>).</li>
|
<li><span class="tag decided">Done</span> <strong>Phase 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 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>
|
||||||
@ -362,7 +363,7 @@ Captured_Frame :: struct {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="phase">
|
<div class="phase">
|
||||||
<h3>Phase 3: Recording in the app</h3>
|
<h3>Phase 3: Recording in the app <span class="tag decided">Done</span></h3>
|
||||||
<p>The pieces:</p>
|
<p>The pieces:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The <code>record_region</code> action, which starts a recording or, during one, stops it.</li>
|
<li>The <code>record_region</code> action, which starts a recording or, during one, stops it.</li>
|
||||||
@ -379,6 +380,35 @@ Captured_Frame :: struct {
|
|||||||
<li>A 239 × 300 drag doesn't start a recording.</li>
|
<li>A 239 × 300 drag doesn't start a recording.</li>
|
||||||
<li>Quitting mid-recording keeps it.</li>
|
<li>Quitting mid-recording keeps it.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="note"><strong>Result (2026-10-10).</strong>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Built:</strong>
|
||||||
|
<ul>
|
||||||
|
<li><code>src/app/screen_record.jai</code>: start, stop, and writing the recording's <code>.mcap</code>.</li>
|
||||||
|
<li>The overlay's video mode, in <code>screen_capture_win32.jai</code>.</li>
|
||||||
|
<li>The <code>record_region</code> hotkey, <code>Ctrl-PrintScreen</code> by default. Existing settings files get it appended, as yours did.</li>
|
||||||
|
<li>The catalog reading a video's keys from <code>capture.json</code> as well as <code>import.json</code>.</li>
|
||||||
|
<li>Saving reuses the stills' jobs: a thread copies the MP4 into the <code>.mcap</code> with <code>mcap_write_streamed</code>, then the grid, the Capture window and the OCR and label scan take it as they take a still.</li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong>A real bug, which the fallback hid:</strong> the first run in the app recorded with Windows.Graphics.Capture. Desktop Duplication had failed with <code>DXGI_ERROR_UNSUPPORTED</code>, which <code>record-probe</code> never saw. The cause is that ImGui's <code>EnableDpiAwareness</code> sets per-monitor DPI awareness for the main thread only, and Desktop Duplication refuses a thread that isn't aware. The capture thread now makes itself aware (<code>gpu_thread_dpi_aware</code>), and the app records with <code>DuplicateOutput1</code>. This also answers phase 1's question: the plain call isn't what fails for an unaware thread; both do.</li>
|
||||||
|
<li><strong>End to end</strong>, with <code>-record 5</code> on a scratch library: the internal screen (2560 × 1600), Desktop Duplication, NVENC, 5 s. Its <code>.mcap</code> lands in Screen Captures, and the temporary MP4 is deleted.</li>
|
||||||
|
<li><strong>Quitting while recording</strong> (<code>-record 4 -record-quit</code>): the shutdown stopped the recording, saved it and finished in 0.6 s.</li>
|
||||||
|
<li><strong>Tests:</strong> a "screen recording" group of 17 checks in the import suite:
|
||||||
|
<ul>
|
||||||
|
<li>The size rule: 240 × 180 allowed, 239 × 180 and 240 × 179 not.</li>
|
||||||
|
<li>An MP4 made with the module's encoder, written as a recording, reads back with the MP4 as its original and a frame of it, at its size, as its rendered image.</li>
|
||||||
|
<li>The catalog takes it as an H.264 video of about a second, with its window's title and app.</li>
|
||||||
|
</ul>
|
||||||
|
Every suite passes.</li>
|
||||||
|
<li><strong>Not checked by me: the overlay's video mode by hand.</strong> That means the red outline, the dashed outline under 240 × 180 and letting go of it, the drag held to one screen, and Ctrl+PrintScreen to start and stop. It needs someone at the keyboard and mouse.</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Changes from the plan:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>PrintScreen while recording</strong> takes the whole screen under the cursor at once, without the overlay: open question 1's answer, for now.</li>
|
||||||
|
<li><strong><code>-record SECONDS</code></strong> (and <code>-record-quit</code>) on the app's command line, for end-to-end checks like the existing <code>-scan</code>.</li>
|
||||||
|
<li><strong>No indicator yet:</strong> a toast says recording started and which key stops it. The border and toolbar are phase 4.</li>
|
||||||
|
<li><strong>A settings-dependent test fixed:</strong> the segment overlay test expected no segment colours chosen, but it loads the app's real <code>segment-colours.config</code>, where you had chosen one. It now checks only the colour it set.</li>
|
||||||
|
</ul></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="phase">
|
<div class="phase">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user