[0.1.7] Recordings go into the library as regular MP4s, with a keyframe every 2 s

- A recording killed mid-way had wrong timestamps in players: its fragmented MP4's header gives a
  length of 0, and one cut short never gets the index a clean stop writes. modules/Screen_Record's
  record_remux now copies every recording's packets, as they are, into a regular MP4 (one header
  first with the length and every frame's index; faststart) before it goes into the library;
  should that fail, the fragmented MP4 goes in as it was.
- Keyframes were every 60 frames, which, with frames written only on change, could span minutes
  of time, and a crash could lose a fragment that long. One is now forced every 2 s of time
  (forced-idr for NVENC and libx264).
- A crash's leftover no longer claims a region at 0, 0 in its capture.json.
- record-probe -remux IN OUT. Tests: a recording cut at its last fragment, as a crash cuts it,
  goes in as a regular MP4 of the time before the cut.
- Checked on the recording that showed it: its MP4, cut mid-fragment, rewrites to 76 frames
  (5.2 s) with positive frame durations. A 12 s still recording now fragments every ~2 s, and
  the app killed 10 s into a recording leaves 8.1 s.
This commit is contained in:
Musa Mahmood 2026-10-10 07:54:48 +01:00
parent 59c5b8dd62
commit e0eaf5642b
11 changed files with 160 additions and 20 deletions

View File

@ -204,7 +204,8 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
- A recording that stops by itself keeps what it has, and so does quitting while recording. - A recording that stops by itself keeps what it has, and so does quitting while recording.
- When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the - When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the
recording carries on once it is back. recording carries on once it is back.
- A recording a crash cut short is offered at the next start: into the library, or deleted. - A recording a crash cut short is offered at the next start: into the library, or deleted. It
keeps all but its last two seconds or so.
- **Settings:** `[video]` in the settings file: `fps` (30), `cursor` (true), and `capture`: `auto` - **Settings:** `[video]` in the settings file: `fps` (30), `cursor` (true), and `capture`: `auto`
(Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`. (Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing - **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing

View File

@ -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.6"; VERSION :: "0.1.7";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -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.6"; VERSION :: "0.1.7";
// 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";

View File

@ -120,8 +120,10 @@ Format: "PNG"
## Screen recordings ## Screen recordings
A recording made with the mcap app's Ctrl+PrintScreen is a video: its original is the H.264 MP4 as A recording made with the mcap app's Ctrl+PrintScreen is a video: its original is the H.264 MP4, and
recorded, and its rendered image (kind 2) a frame from it as a PNG, as for an imported video. Its its rendered image (kind 2) a frame from it as a PNG, as for an imported video. It is recorded as a
fragmented MP4, so a crash still leaves one that plays, and stored as a regular one, its frames copied
as they are: one header first, with its length and the index of every frame. Its
metadata.json is a still's. Its capture.json has a still's keys, with Format "MP4", and these: metadata.json is a still's. Its capture.json has a still's keys, with Format "MP4", and these:
``` ```

View File

@ -4,10 +4,13 @@
// BGRA as it is and converts it on the GPU; the others are given NV12 or YUV 4:2:0, converted with // BGRA as it is and converts it on the GPU; the others are given NV12 or YUV 4:2:0, converted with
// swscale (BT.709, limited range, as tagged). // swscale (BT.709, limited range, as tagged).
// //
// The file is written fragmented (a fragment each keyframe, every 2 s), so one cut short by a crash // The file is written fragmented, a fragment at each keyframe, so one cut short by a crash still plays
// still plays up to its last fragment. // up to its last fragment. Frames are written only when the screen changes, so a count of frames can
// span any time: a keyframe is made every RECORD_KEYFRAME_SECONDS of time instead, which bounds what a
// crash loses. remux.jai makes the file a regular MP4 once it is done.
RECORD_ENCODERS :: string.["h264_nvenc", "h264_amf", "h264_qsv", "h264_mf", "libx264"]; RECORD_ENCODERS :: string.["h264_nvenc", "h264_amf", "h264_qsv", "h264_mf", "libx264"];
RECORD_KEYFRAME_SECONDS :: 2;
Encoder :: struct { Encoder :: struct {
format: *AVFormatContext; format: *AVFormatContext;
@ -20,6 +23,7 @@ Encoder :: struct {
input: AVPixelFormat; // what it is given: BGR0 as captured, or converted to NV12 or YUV420P input: AVPixelFormat; // what it is given: BGR0 as captured, or converted to NV12 or YUV420P
width, height: s32; width, height: s32;
last_pts: s64 = -1; last_pts: s64 = -1;
last_key_pts: s64 = -1; // the last keyframe asked for
header_written: bool; header_written: bool;
bytes: s64; // of packets written bytes: s64; // of packets written
} }
@ -95,6 +99,10 @@ encoder_write :: (e: *Encoder, pixels: *u8, pts: s64) -> ok: bool, error: string
sws_scale(e.sws, *source, *stride, 0, e.height, frame.data.data, frame.linesize.data); sws_scale(e.sws, *source, *stride, 0, e.height, frame.data.data, frame.linesize.data);
} }
frame.pts = pts_out; frame.pts = pts_out;
if e.last_key_pts < 0 || pts_out - e.last_key_pts >= RECORD_KEYFRAME_SECONDS * RECORD_TIME_BASE {
frame.pict_type = .I; // an IDR, with forced-idr: a new fragment
e.last_key_pts = pts_out;
}
result := avcodec_send_frame(e.codec, frame); result := avcodec_send_frame(e.codec, frame);
av_frame_unref(frame); av_frame_unref(frame);
@ -147,11 +155,11 @@ encoder_try :: (e: *Encoder, name: string, width: s32, height: s32, fps: s32) ->
options: *AVDictionary; options: *AVDictionary;
set :: (key: string, value: string) #expand { av_dict_set(*`options, temp_c_string(key), temp_c_string(value), 0); } set :: (key: string, value: string) #expand { av_dict_set(*`options, temp_c_string(key), temp_c_string(value), 0); }
if name == { if name == {
case "h264_nvenc"; set("preset", "p4"); set("rc", "vbr"); set("cq", "23"); c.bit_rate = 0; case "h264_nvenc"; set("preset", "p4"); set("rc", "vbr"); set("cq", "23"); set("forced-idr", "1"); c.bit_rate = 0;
case "h264_amf"; set("rc", "cqp"); set("qp_i", "22"); set("qp_p", "24"); set("quality", "balanced"); case "h264_amf"; set("rc", "cqp"); set("qp_i", "22"); set("qp_p", "24"); set("quality", "balanced");
case "h264_qsv"; set("global_quality", "23"); set("preset", "medium"); case "h264_qsv"; set("global_quality", "23"); set("preset", "medium");
case "h264_mf"; set("rate_control", "quality"); set("quality", "70"); set("scenario", "display_remoting"); case "h264_mf"; set("rate_control", "quality"); set("quality", "70"); set("scenario", "display_remoting");
case "libx264"; set("preset", "veryfast"); set("crf", "23"); case "libx264"; set("preset", "veryfast"); set("crf", "23"); set("forced-idr", "1");
} }
result := avcodec_open2(c, codec, *options); result := avcodec_open2(c, codec, *options);
av_dict_free(*options); av_dict_free(*options);
@ -205,6 +213,8 @@ encoder_free :: (e: *Encoder) {
e.sws, e.format, e.stream = null, null, null; e.sws, e.format, e.stream = null, null, null;
} }
#scope_module
// What FFmpeg says an error code is, after what failed (temp). // What FFmpeg says an error code is, after what failed (temp).
av_error :: (what: string, code: s32) -> string { av_error :: (what: string, code: s32) -> string {
buffer: [256] u8; buffer: [256] u8;

View File

@ -529,6 +529,7 @@ rect_inside :: (inner: RECT, outer: RECT) -> bool {
#load "dxgi_source.jai"; #load "dxgi_source.jai";
#load "wgc_source.jai"; #load "wgc_source.jai";
#load "encoder.jai"; #load "encoder.jai";
#load "remux.jai";
#load "cursor.jai"; #load "cursor.jai";
#import "Basic"; #import "Basic";

View File

@ -0,0 +1,65 @@
// A recording made into a regular MP4. While recording, the MP4 is written fragmented (encoder.jai), so
// a crash still leaves one that plays; but a fragmented MP4's header says nothing of its length or
// where its frames are, and one cut short by a crash never gets the index written at the end of a
// clean stop, so players show its time and seek in it badly. record_remux copies its packets, as they
// are, into a regular MP4: one header, with the length and the index of every frame, at the front
// (faststart), so it plays, seeks and streams as any MP4 does. Nothing is encoded again.
// Copies source's video and audio packets into a regular MP4 at destination. packets: how many.
record_remux :: (source: string, destination: string) -> ok: bool, error: string, packets: s64 {
av_log_set_level(AV_LOG_ERROR);
input: *AVFormatContext;
result := avformat_open_input(*input, temp_c_string(source), null, null);
if result < 0 return false, av_error("FFmpeg cannot open the recording", result), 0;
defer avformat_close_input(*input);
result = avformat_find_stream_info(input, null);
if result < 0 return false, av_error("FFmpeg cannot read the recording's streams", result), 0;
output: *AVFormatContext;
result = avformat_alloc_output_context2(*output, null, "mp4", temp_c_string(destination));
if result < 0 || !output return false, av_error("FFmpeg cannot make an MP4", result), 0;
defer {
if output.pb avio_closep(*output.pb);
avformat_free_context(output);
}
// Each input stream's output stream, or -1 for one left out (neither video nor audio).
map := NewArray(input.nb_streams, s32,, temp);
for 0..input.nb_streams - 1 {
in := input.streams[it];
map[it] = -1;
if in.codecpar.codec_type != .VIDEO && in.codecpar.codec_type != .AUDIO continue;
out := avformat_new_stream(output, null);
if !out return false, "FFmpeg cannot add a stream", 0;
avcodec_parameters_copy(out.codecpar, in.codecpar);
out.codecpar.codec_tag = 0; // the MP4 muxer picks its own
out.time_base = in.time_base;
map[it] = out.index;
}
result = avio_open(*output.pb, temp_c_string(destination), AVIO_FLAG_WRITE);
if result < 0 return false, av_error(tprint("FFmpeg cannot write %", destination), result), 0;
options: *AVDictionary;
av_dict_set(*options, "movflags", "+faststart", 0); // the header before the frames: a second pass at the end
result = avformat_write_header(output, *options);
av_dict_free(*options);
if result < 0 return false, av_error("FFmpeg cannot start the MP4", result), 0;
packet := av_packet_alloc();
defer av_packet_free(*packet);
packets: s64;
while av_read_frame(input, packet) >= 0 { // to the end, or to where a crash cut it
index := map[packet.stream_index];
if index < 0 { av_packet_unref(packet); continue; }
av_packet_rescale_ts(packet, input.streams[packet.stream_index].time_base, output.streams[index].time_base);
packet.stream_index = index;
packet.pos = -1;
result = av_interleaved_write_frame(output, packet); // takes the packet's data
if result < 0 return false, av_error("FFmpeg cannot write to the MP4", result), packets;
packets += 1;
}
if !packets return false, "the recording holds no frames", 0;
result = av_write_trailer(output);
if result < 0 return false, av_error("FFmpeg cannot finish the MP4", result), packets;
return true, "", packets;
}

View File

@ -285,21 +285,34 @@ screen_record_stop :: (shutdown := false) {
// The recording's .mcap in the library, from the job's MP4, which is deleted once it is in: its path, // 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. // or why it could not be (temp). On the job's thread.
//
// The MP4 was written fragmented, so a crash leaves one that plays; it is made a regular MP4 first
// (record_remux), with its length and index in its header, as players need to show its time and seek
// in it. Should that fail, the MP4 goes in as it was.
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string { screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
_, size, found := file_modtime_and_size(job.recording); _, recorded_size, found := file_modtime_and_size(job.recording);
if !found || size <= 0 return "", tprint("% is missing", job.recording); if !found || recorded_size <= 0 return "", tprint("% is missing", job.recording);
image, video, decoded, decode_error := video_poster(job.recording); mp4 := job.recording;
remuxed := tprint("%.regular.mp4", path_strip_extension(job.recording));
remux_ok, remux_error, packets := record_remux(job.recording, remuxed);
if remux_ok mp4 = remuxed;
else { log_error("[record] % stays fragmented: %", job.recording, remux_error); file_delete(remuxed); }
defer if remux_ok file_delete(remuxed);
_, size, _ := file_modtime_and_size(mp4);
image, video, decoded, decode_error := video_poster(mp4);
if !decoded return "", tprint("the recording cannot be read back: %", decode_error); if !decoded return "", tprint("the recording cannot be read back: %", decode_error);
defer image_free(*image); defer image_free(*image);
preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE); preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE);
if !preview_ok return "", preview_error; if !preview_ok return "", preview_error;
poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size
if !poster_ok return "", poster_error; if !poster_ok return "", poster_error;
hash, hashed := import_hash_file(.{path = job.recording, offset = 0, size = size}); hash, hashed := import_hash_file(.{path = mp4, offset = 0, size = size});
if !hashed return "", tprint("% cannot be read", job.recording); if !hashed return "", tprint("% cannot be read", mp4);
shot := job.shot; shot := job.shot;
if !shot.width { shot.width, shot.height = image.width, image.height; } // a crash's leftover: no more is known known := shot.width > 0; // a crash's leftover knows no region, window or encoder
if !known { shot.width, shot.height = image.width, image.height; }
doc := New(Capture_Document,, temp); doc := New(Capture_Document,, temp);
capture_document_init(doc, temp); capture_document_init(doc, temp);
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION); doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
@ -308,9 +321,11 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
import_add(doc, metadata, "CaptureDate", job.date); import_add(doc, metadata, "CaptureDate", job.date);
import_add(doc, metadata, "WindowName", import_text(shot.window_title)); import_add(doc, metadata, "WindowName", import_text(shot.window_title));
info := capture_add_file(doc, "capture.json"); info := capture_add_file(doc, "capture.json");
import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection"); if known {
import_add_number(doc, info, "Left", xx shot.left); import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection");
import_add_number(doc, info, "Top", xx shot.top); 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, "Width", xx shot.width);
import_add_number(doc, info, "Height", xx shot.height); import_add_number(doc, info, "Height", xx shot.height);
if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path)); if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path));
@ -337,7 +352,7 @@ screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string
m.rendered = poster; m.rendered = poster;
path, folder_error := screen_capture_out_path(name); path, folder_error := screen_capture_out_path(name);
if folder_error return "", folder_error; if folder_error return "", folder_error;
written, write_error := mcap_write_streamed(path, m, .{path = job.recording, offset = 0, size = size}); written, write_error := mcap_write_streamed(path, m, .{path = mp4, offset = 0, size = size});
if !written return "", write_error; if !written return "", write_error;
file_delete(job.recording); file_delete(job.recording);
return path, ""; return path, "";

View File

@ -732,7 +732,10 @@ screen_record_tests :: () {
m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp)); m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp));
expect(parsed, "it reads back"); expect(parsed, "it reads back");
if parsed { 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); // Recorded fragmented, it goes in a regular MP4: the same frames, its header first.
original := cast(string) m.original;
expect(ends_with(m.original_name, ".mp4") && slice(original, 4, 4) == "ftyp" && find_index_from_left(original, "moof") < 0 && find_index_from_left(original, "moov") < find_index_from_left(original, "mdat"),
"the MP4, made regular, is its original: % (% bytes, % recorded)", m.original_name, m.original.count, mp4_size);
expect(m.rendered_kind == .OWN, "a frame of it is its rendered image"); expect(m.rendered_kind == .OWN, "a frame of it is its rendered image");
poster, decoded := image_decode(m.rendered); poster, decoded := image_decode(m.rendered);
expect(decoded && poster.width == WIDTH && poster.height == HEIGHT, "at the recording's size"); expect(decoded && poster.width == WIDTH && poster.height == HEIGHT, "at the recording's size");
@ -779,6 +782,36 @@ screen_record_tests :: () {
} }
file_delete(leftover_path); file_delete(leftover_path);
} }
// One a crash cut short: a frame every 100 ms for 5 s makes fragments at 0, 2 and 4 s (a
// keyframe each 2 s, however few the frames); cut at the last, as a crash would, it goes into the
// library as a regular MP4 (header first, no fragments) of the 4 s before the cut.
encoder = .{};
encoder_open(*encoder, mp4, WIDTH, HEIGHT, 30, "");
for frame: 0..49 encoder_write(*encoder, pixels.data, frame * 9000);
encoder_close(*encoder);
whole := read_entire_file(mp4,, temp);
last_fragment := -1;
for 4..whole.count - 4 if whole[it] == #char "m" && slice(whole, it, 4) == "moof" last_fragment = it - 4;
expect(last_fragment > 0, "the recording is fragmented");
write_entire_file(mp4, slice(whole, 0, last_fragment));
crashed: Screen_Capture_Job;
crashed.video = true;
crashed.recording = mp4;
crashed.date = "2026-10-10 07:48:52";
crashed_path, crashed_error := screen_record_write(*crashed);
expect(!crashed_error, "a cut recording is written: %", crashed_error);
if !crashed_error {
m3, parsed3 := mcap_parse(cast([] u8) read_entire_file(crashed_path,, temp));
original := cast(string) m3.original;
moov := find_index_from_left(original, "moov");
mdat := find_index_from_left(original, "mdat");
expect(parsed3 && moov > 0 && moov < mdat && find_index_from_left(original, "moof") < 0, "a regular MP4, its header first");
crashed_entry := catalog_load(crashed_path);
expect(crashed_entry && crashed_entry.duration > 3.5 && crashed_entry.duration < 4.5, "the 4 s before the cut: %", ifx crashed_entry then crashed_entry.duration else -1);
if crashed_entry catalog_entry_free(crashed_entry);
file_delete(crashed_path);
}
} }
} }

View File

@ -20,6 +20,7 @@
// WDA_EXCLUDEFROMCAPTURE: is it out of the recording, and the other in? // WDA_EXCLUDEFROMCAPTURE: is it out of the recording, and the other in?
// -animate moves a square across the region the whole time, so the screen changes every // -animate moves a square across the region the whole time, so the screen changes every
// frame: for measuring throughput. // frame: for measuring throughput.
// -remux IN OUT a recording's fragmented MP4 made a regular one, as the app does, and nothing else.
// -check FILE reads FILE back with FFmpeg and says what it holds, and nothing else: for a // -check FILE reads FILE back with FFmpeg and says what it holds, and nothing else: for a
// recording cut short by a crash. // recording cut short by a crash.
// -snapshot DIR the last frame captured (captured.png) and a frame decoded back from the file // -snapshot DIR the last frame captured (captured.png) and a frame decoded back from the file
@ -78,6 +79,12 @@ main :: () {
else { print("-capture takes auto, dxgi or wgc\n"); exit(2); } else { print("-capture takes auto, dxgi or wgc\n"); exit(2); }
i += 2; continue; i += 2; continue;
} }
if a == "-remux" && more >= 2 {
ok, error, packets := record_remux(args[i + 1], args[i + 2]);
if !ok { print("Cannot remux %: %\n", args[i + 1], error); exit(1); }
print("% packets into %\n", packets, args[i + 2]);
exit(0);
}
if a == "-check" && more >= 1 { if a == "-check" && more >= 1 {
image, info, ok, error := video_poster(args[i + 1]); image, info, ok, error := video_poster(args[i + 1]);
if !ok { print("%: cannot be read: %\n", args[i + 1], error); exit(1); } if !ok { print("%: cannot be read: %\n", args[i + 1], error); exit(1); }

View File

@ -434,6 +434,12 @@ Captured_Frame :: struct {
<li>Leftover recordings offered at startup, in a popup in the main window.</li> <li>Leftover recordings offered at startup, in a popup in the main window.</li>
<li>The Win32 GDI bindings moved to <code>src/base/platform/win32_gdi_bindings.jai</code>, shared by the overlay and the toolbar.</li> <li>The Win32 GDI bindings moved to <code>src/base/platform/win32_gdi_bindings.jai</code>, shared by the overlay and the toolbar.</li>
</ul></li> </ul></li>
<li><strong>Found by hand: a killed recording's timestamps were wrong</strong> (mcap 0.1.7). There were two causes:
<ul>
<li><strong>No length or index.</strong> A fragmented MP4's header gives its length as 0, and one cut short never gets the index a clean stop writes at its end, so players showed its time and seeked badly. Every recording, cut short or not, is now rewritten as a regular MP4 on its way into the library (<code>record_remux</code>): its packets copied as they are, with one header first holding its length and an index of every frame. Nothing is re-encoded.</li>
<li><strong>Keyframes counted in frames.</strong> They came every 60 frames, and with frames written only on change, 60 frames can span minutes. So a fragment, and what a crash loses, could span minutes too, not the 2 s section 5 promised. A keyframe is now forced every 2 s of time.</li>
</ul>
Checked: your recording's MP4, cut mid-fragment, rewrites to a regular MP4 of the 76 whole frames (5.2 s) with positive frame durations. A 12 s mostly still recording has fragments at 0, 2.0, 4.1, 6.1, 8.1, 10.2 and 12.1 s. The app killed about 10 s into a recording leaves 8.1 s. A test cuts a recording at its last fragment, as a crash would, and checks it goes in as a regular MP4 of the time before the cut.</li>
<li><strong>Kept out of the recording:</strong> a whole-screen recording with the app (<code>-record 6</code>), where the toolbar sits inside the recorded screen. The frame stored in its <code>.mcap</code> has no toolbar. With <code>-record-ui-visible</code> and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.</li> <li><strong>Kept out of the recording:</strong> a whole-screen recording with the app (<code>-record 6</code>), where the toolbar sits inside the recorded screen. The frame stored in its <code>.mcap</code> has no toolbar. With <code>-record-ui-visible</code> and a region, a screenshot shows both drawn as designed: the frame just outside the region, and the toolbar below it at its right.</li>
<li><strong>Pause:</strong> 16 s with a 10 s pause from 2 s in gives a 6.2 s video at a steady 30 fps, with Desktop Duplication and with Windows.Graphics.Capture alike. The pause is cut out, with no gap.</li> <li><strong>Pause:</strong> 16 s with a 10 s pause from 2 s in gives a 6.2 s video at a steady 30 fps, with Desktop Duplication and with Windows.Graphics.Capture alike. The pause is cut out, with no gap.</li>
<li><strong>Losing the screen</strong> (<code>-lose</code>, which loses Desktop Duplication every 3 s through the real recovery path): 3 losses in 10 s, 304 frames, 30 fps throughout. A real UAC prompt or resolution change is still to try by hand.</li> <li><strong>Losing the screen</strong> (<code>-lose</code>, which loses Desktop Duplication every 3 s through the real recovery path): 3 losses in 10 s, 304 frames, 30 fps throughout. A real UAC prompt or resolution change is still to try by hand.</li>