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

Screen video capture in mcap

-

Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 and 2 built, mcap 0.1.4

+

Planning document · 2026-10-10 · for mcap after 0.1.1 (still captures with PrintScreen) · phases 1 to 3 built, mcap 0.1.5

@@ -82,6 +82,7 @@
  • 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.
  • Done Phase 1 (2026-10-10, mcap 0.1.3): modules/Screen_Record and record-probe. 60 s of 4K at 30 fps with NVENC and no dropped frames. The two-GPU failure couldn't be reproduced, because this laptop's screens are currently driven by the NVIDIA GPU (see phase 1's result).
  • Done Phase 2 (2026-10-10, mcap 0.1.4): the Windows.Graphics.Capture fallback. Automatic mode falls back to it on a simulated DXGI_ERROR_UNSUPPORTED. It records 4K at 60 fps with nothing dropped, draws the cursor exactly as Desktop Duplication's code does, and the yellow border can be turned off on Windows 11.
  • +
  • Done Phase 3 (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 .mcap in Screen Captures. In the app, Desktop Duplication first failed with DXGI_ERROR_UNSUPPORTED: only the main thread was DPI aware. The capture thread now makes itself aware.
  • Later Audio is phase 5. Recording one window, and regions that span two screens, come after.
  • @@ -362,7 +363,7 @@ Captured_Frame :: struct {
    -

    Phase 3: Recording in the app

    +

    Phase 3: Recording in the app Done

    The pieces:

    +
    Result (2026-10-10). + +

    Changes from the plan:

    +