Compare commits

..

3 Commits

7 changed files with 261 additions and 20 deletions

View File

@ -272,7 +272,10 @@ What becomes of each file:
The date is the phone's (Apple's creation date), else one in its name, else the video's own
creation time, else the file's time; the phone and its location are kept too. The thumbnail shows a
play mark and how long it runs; **Play in the default player** in the Capture window writes the
video out to the temporary folder and opens it.
video out to the temporary folder and opens it. **Export...** (any capture) writes the file as it
was imported to a folder it asks for: one of the `folder:` lines under `[export]` in the settings
file (the first is the default; none: the Downloads folder), or **Custom...**, which can be added to
them. A file of that name already there, and different, is not overwritten.
- **Snagit `.snagx`:** the same `.mcap` as `snagit-converter mcap` makes, byte for byte, named as
the `.snagx` is.
- **Duplicates:** an image already in the library, in any of its folders, is not stored again; the

View File

@ -47,7 +47,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
VERSION :: "0.1.20";
VERSION :: "0.1.23";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.20";
VERSION :: "0.1.23";
// 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";

View File

@ -53,6 +53,7 @@ ocr_ui: struct {
show_boxes := true;
match_cursor: s64 = -1; // which match Next and Previous last went to
scroll_to_match: bool;
menu_text: string; // heap: the line right-clicked, for its menu's Copy
window_open := false;
toast_shown := false;
@ -823,6 +824,8 @@ ocr_ui_frame :: () { using ImGui;
if section && viewer.full_index >= 0 {
Separator();
Text("Text of % (% , % lines):", viewer.captures[viewer.full_index].name, section.model, section.lines.count);
SameLine();
if SmallButton("Copy all text") ocr_copy_all_text(section);
BeginChild("ocr text");
for section.lines TextUnformatted(it.text);
EndChild();
@ -849,6 +852,17 @@ text_with_links :: (text: string) { using ImGui;
// ---------------------------------------------------------------- the Capture window
// Puts the reading on the clipboard, a line of text to each of its lines.
ocr_copy_all_text :: (section: *Mcap_Ocr_Section) {
builder: String_Builder;
builder.allocator = temp;
for section.lines {
if it_index append(*builder, "\n");
append(*builder, it.text);
}
clipboard_set_text(builder_to_string(*builder,, temp));
}
// The reading shown over the capture in the Capture window: the overlay's choice, else the chosen
// model's, else the first there is.
ocr_overlay_section :: () -> *Mcap_Ocr_Section {
@ -910,6 +924,8 @@ ocr_ui_capture_controls :: (index: s32, path: string) { using ImGui;
SameLine();
}
Checkbox("Boxes", *ocr_ui.show_boxes);
SameLine();
if SmallButton("Copy all text") ocr_copy_all_text(section);
if viewer.search_terms.count {
matches := 0;
for section.lines if search_line_matches(it.text, viewer.search_terms) matches += 1;
@ -943,6 +959,7 @@ ocr_ui_capture_overlay :: (index: s32, image_min: ImGui.ImVec2, zoom: float) { u
plain := GetColorU32(.PlotHistogram, ifx searching then 0.35 else 1.0);
matching := GetColorU32(.PlotLinesHovered);
match_number := 0;
clicked: string; // the line right-clicked this frame; the last drawn, which is on top, where they overlap
for section.lines {
p0 := ImVec2.{image_min.x + it.x * scale_x, image_min.y + it.y * scale_y};
p1 := ImVec2.{p0.x + it.width * scale_x, p0.y + it.height * scale_y};
@ -963,6 +980,21 @@ ocr_ui_capture_overlay :: (index: s32, image_min: ImGui.ImVec2, zoom: float) { u
} else {
draw.AddQuad(draw, q[0], q[1], q[2], q[3], plain, 1.5);
}
if IsMouseHoveringRect(p0, p1) SetTooltip("% (%)", it.text, FF(it.confidence, 2));
if IsMouseHoveringRect(p0, p1) {
SetTooltip("% (%)", it.text, FF(it.confidence, 2));
if IsWindowHovered() && IsMouseClicked(.Right) clicked = it.text;
}
}
// Right-click a box for its menu. The text is kept, since the reading may change while the menu is up.
MENU :: "##ocr_line_menu";
if clicked {
free(ocr_ui.menu_text,, context.default_allocator);
ocr_ui.menu_text = copy_string(clicked,, context.default_allocator);
OpenPopup(MENU);
}
if BeginPopup(MENU) {
if MenuItem("Copy") clipboard_set_text(ocr_ui.menu_text);
EndPopup();
}
}

View File

@ -149,15 +149,24 @@ viewer: struct {
full_fit := Viewer_Fit.WINDOW;
full_zoom: float = 100; // percent of the size it was captured at; set by the fit, unless OFF
// A video or animation's original, written out to open in the default player (viewer_open_original).
// A capture's original, written out to open in the default player (viewer_open_original), or
// exported to a folder (viewer_export_original).
open_thread: Thread;
open_started: bool;
open_busy: bool; // set by the main thread, cleared by the open thread when it ends
open_source: string; // the .mcap; heap, main thread's until the thread starts
open_name: string;
open_folder: string; // where to export it; "" to write it to the temporary folder and open it
open_result: string; // the file written, or ""; heap, the open thread's until it ends
open_error: string;
// The export being asked about (viewer_export_popup).
export_index: s32 = -1; // the capture to export, while the popup is up
export_choice: s64; // which of viewer_export_choices, or -1 for Custom...
export_buffer: [1024] u8; // the folder typed in for Custom...
export_add: bool; // Custom...: add the folder to [export]
export_make_default: bool;
// The selected capture's image, decoded off the main thread for the clipboard (viewer_copy_image).
copy_thread: Thread;
copy_started: bool;
@ -1135,6 +1144,7 @@ viewer_ui :: () { using ImGui;
viewer_grid();
EndChild();
viewer_delete_popup();
viewer_export_popup();
viewer_full_window();
}
@ -1197,17 +1207,28 @@ viewer_duration_text :: (seconds: float64) -> string {
// Writes the capture's original (a video, an animated GIF) as it was imported to a file in the
// temporary folder, on a thread of its own, as it may be large; viewer_open_frame opens it.
viewer_open_original :: (index: s32) {
viewer_write_original(index, "");
}
// Writes the capture's original as it was imported into folder, on a thread of its own, as it may be
// large; viewer_open_frame says where it went.
viewer_export_original :: (index: s32, folder: string) {
viewer_write_original(index, folder);
}
viewer_write_original :: (index: s32, folder: string) {
if viewer.open_started return;
capture := *viewer.captures[index];
push_allocator(context.default_allocator);
free(viewer.open_source); free(viewer.open_name); free(viewer.open_result); free(viewer.open_error);
free(viewer.open_source); free(viewer.open_name); free(viewer.open_folder); free(viewer.open_result); free(viewer.open_error);
viewer.open_source = copy_string(capture.path);
viewer.open_name = copy_string(capture.name);
viewer.open_folder = copy_string(folder);
viewer.open_result = "";
viewer.open_error = "";
viewer.open_busy = true;
viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "open original");
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not open %", capture.name); return; }
viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "write original");
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not write % out", capture.name); return; }
thread_start(*viewer.open_thread);
}
@ -1217,14 +1238,28 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
m, original, ok, error := mcap_read_without_original(viewer.open_source,, temp);
if !ok { viewer.open_error = copy_string(error); return 0; }
exporting := viewer.open_folder.count > 0;
folder := viewer.open_folder;
if !exporting {
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%");
else base := "/tmp";
folder := tprint("%/%", base, APP_FOLDER_NAME);
folder = tprint("%/%", base, APP_FOLDER_NAME);
}
make_directory_if_it_does_not_exist(folder, recursive = true);
if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; }
extension := path_extension(m.original_name);
path := tprint("%/%.%", folder, viewer.open_name, ifx extension then extension else "bin");
if !extension extension = "bin";
path := tprint("%/%.%", folder, viewer.open_name, extension);
_, size, exists := file_modtime_and_size(path);
if !exists || size != original.size { // written out before, it is used again
// Written out before, it is used again. An export does not overwrite another file of that name.
if exporting && exists && size != original.size {
for 2..1000 {
path = tprint("%/% (%).%", folder, viewer.open_name, it, extension);
_, size, exists = file_modtime_and_size(path);
if !exists || size == original.size break;
}
}
if !exists || size != original.size {
ok, error = mcap_extract_original(original, path);
if !ok { viewer.open_error = copy_string(error); return 0; }
}
@ -1232,14 +1267,141 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
return 0;
}
// Each frame: opens what viewer_open_proc wrote, once it is done.
// Each frame: opens what viewer_open_proc wrote, or says where it was exported, once it is done.
viewer_open_frame :: () {
if !viewer.open_started || viewer.open_busy return;
thread_is_done(*viewer.open_thread, -1);
thread_deinit(*viewer.open_thread);
viewer.open_started = false;
if viewer.open_result platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
else toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
if !viewer.open_result toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
else if viewer.open_folder toast_show(.Success, "Exported % to %", viewer.open_name, viewer.open_result);
else platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
}
// The folders Export offers: the [export] folder lines in settings.config, else the Downloads folder.
viewer_export_choices :: () -> [] string {
if app_user_settings.export_folders return app_user_settings.export_folders;
#if OS == .WINDOWS downloads := tprint("%/Downloads", windows_expand_environment("%USERPROFILE%"));
else downloads := posix_expand_home("~/Downloads");
choices := NewArray(1, string,, temp);
choices[0] = downloads;
return choices;
}
// Asks where to export the capture's original, starting from the default folder.
viewer_export_ask :: (index: s32) {
viewer.export_index = index;
viewer.export_choice = 0;
viewer.export_add = false;
viewer.export_make_default = false;
}
viewer_export_set_buffer :: (folder: string) {
count := min(folder.count, viewer.export_buffer.count - 1);
memcpy(viewer.export_buffer.data, folder.data, count);
viewer.export_buffer[count] = 0;
}
// Puts folder in [export]: first when it is to be the default, else at the end if it is not there yet.
viewer_export_remember :: (folder: string, make_default: bool) {
folders := *app_user_settings.export_folders;
changed := false;
if !folders.count {
// The list was only the Downloads folder: it stays offered.
array_add(folders, copy_string(viewer_export_choices()[0],, context.default_allocator));
changed = true;
}
at := -1;
for folders.* if it == folder { at = it_index; break; }
if make_default && at != 0 {
copy := copy_string(folder,, context.default_allocator);
if at > 0 { free(folders.*[at],, context.default_allocator); array_ordered_remove_by_index(folders, at); }
array_insert_at(folders, copy, 0);
changed = true;
} else if at < 0 {
array_add(folders, copy_string(folder,, context.default_allocator));
changed = true;
}
if changed app_save_user_settings_file_and_toast("the export folders");
}
// The folder for viewer.export_index, as a modal popup.
viewer_export_popup :: () { using ImGui;
POPUP :: "Export capture";
if viewer.export_index >= 0 && !IsPopupOpen(POPUP) OpenPopup(POPUP);
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
index := viewer.export_index;
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed {
viewer.export_index = -1;
CloseCurrentPopup();
EndPopup();
return;
}
capture := *viewer.captures[index];
Text("Export %", capture.name);
TextColored(app_theme.palette.dim, "Writes the file as it was imported, unchanged, into the folder.");
Spacing();
choices := viewer_export_choices();
if viewer.export_choice >= choices.count viewer.export_choice = 0;
custom := viewer.export_choice < 0;
SetNextItemWidth(GetFontSize() * 30);
if BeginCombo("##folders", temp_c_string(ifx custom then "Custom..." else choices[viewer.export_choice])) {
for choices {
if Selectable(temp_c_string(it), it_index == viewer.export_choice) viewer.export_choice = it_index;
if it_index == 0 && IsItemHovered() SetTooltip("The default");
}
Separator();
if Selectable("Custom...", custom) && !custom {
viewer.export_choice = -1;
viewer_export_set_buffer(choices[0]); // somewhere to start from
}
EndCombo();
}
SameLine();
Text("Folder");
custom = viewer.export_choice < 0;
folder: string;
if custom {
SetNextItemWidth(GetFontSize() * 30);
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
#if OS == .WINDOWS {
SameLine();
if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to export to");
if chosen viewer_export_set_buffer(chosen);
}
}
folder = trim(to_string(viewer.export_buffer.data));
Checkbox("Add it to the list", *viewer.export_add);
if IsItemHovered() SetTooltip("Adds a folder line under [export] in %", APP_USER_SETTINGS_FILE_NAME);
} else {
folder = choices[viewer.export_choice];
}
if custom || viewer.export_choice > 0 {
Checkbox("Make it the default", *viewer.export_make_default);
if IsItemHovered() SetTooltip("Puts it first under [export] in %", APP_USER_SETTINGS_FILE_NAME);
}
Spacing();
// Enter and Escape are the open dropdown's while it is up.
keys := IsWindowFocused();
if !folder BeginDisabled();
if Button("Export") || (folder && keys && IsKeyPressed(.Enter)) {
viewer.export_index = -1;
CloseCurrentPopup();
if (custom || viewer.export_choice > 0) && viewer.export_make_default viewer_export_remember(folder, true);
else if custom && viewer.export_add viewer_export_remember(folder, false);
viewer_export_original(index, folder);
}
if !folder EndDisabled();
SameLine();
if Button("Cancel") || (keys && IsKeyPressed(.Escape)) {
viewer.export_index = -1;
CloseCurrentPopup();
}
EndPopup();
}
// ---------------------------------------------------------------- copying the selected capture
@ -1413,6 +1575,13 @@ viewer_full_window :: () { using ImGui;
TextColored(app_theme.palette.dim, "% x %", viewer.full_source_width, viewer.full_source_height);
}
if viewer.full_loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
SameLine();
exporting := viewer.open_started;
if exporting BeginDisabled();
if SmallButton("Export...") viewer_export_ask(viewer.full_index);
if exporting EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported, unchanged, to a folder");
if exporting && viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Exporting..."); }
TextColored(app_theme.palette.dim, "Left / Right: previous / next");
// Zoom is a percentage of the size the capture was taken at. A fit sets it from the window, larger
@ -1445,7 +1614,7 @@ viewer_full_window :: () { using ImGui;
if SmallButton(temp_c_string(ifx entry.media == .VIDEO then "Play in the default player" else "Open in the default viewer")) viewer_open_original(viewer.full_index);
if busy EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported to the temporary folder, and opens it");
if busy { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); }
if busy && !viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); }
}
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai

View File

@ -230,6 +230,19 @@ settings_tests :: () {
expect(ok && !seen.event_log && settings.event_log_capacity == EVENT_LOG_CAPACITY_DEFAULT,
"a file without the key gave seen % and capacity %", seen.event_log, settings.event_log_capacity);
{
group("[export] folder lines");
data := tprint("@version %\n[export]\nfolder: D:\\Exports\nfolder:\nfolder: \" E:\\Shared #2\"\n", APP_USER_SETTINGS_VERSION);
settings: App_User_Settings;
ok, seen := settings_parse("logger_tests", data, *settings);
defer settings_free(*settings);
expect(ok && seen.export_folder && settings.export_folders.count == 2, "gave seen % and % folders", seen.export_folder, settings.export_folders.count);
if settings.export_folders.count == 2 {
expect(settings.export_folders[0] == "D:\\Exports" && settings.export_folders[1] == " E:\\Shared #2",
"gave % and %", settings.export_folders[0], settings.export_folders[1]);
}
}
// A section that does not mention an action gets its default installed right away, since the save
// that writes it into the file does not reload it; a pre-upgrade file gets "none" instead.
{

View File

@ -293,6 +293,9 @@ app_user_settings: App_User_Settings;
App_User_Settings :: struct {
theme: string; // "" means the default theme; heap-allocated
ocr_model: string; // [ocr] model: the OCR model's name in models.config; "" means the default; heap-allocated
// [export] folder, one line each: the folders Export offers for a capture's original, the first
// the default. None means the Downloads folder. Heap-allocated.
export_folders: [..] string;
// Of the monitor the window first opens on.
window_width_fraction: float = 0.80;
@ -323,6 +326,8 @@ App_User_Settings :: struct {
settings_free :: (s: *App_User_Settings) {
free(s.theme,, context.default_allocator);
free(s.ocr_model,, context.default_allocator);
for s.export_folders free(it,, context.default_allocator);
array_free(s.export_folders);
free(s.video_capture,, context.default_allocator);
array_free(s.hotkey_bindings);
array_free(s.global_hotkey_bindings);
@ -382,11 +387,11 @@ app_load_user_settings_file :: () -> write_back: bool {
if hotkeys_missing.count || global_missing.count return true;
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone);
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.export_folder && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone);
}
Settings_Seen :: struct {
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool;
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model, export_folder: bool;
video_fps, video_cursor, video_capture, video_system_audio, video_microphone: bool;
}
@ -398,13 +403,14 @@ settings_file_hash: u64;
Settings_App_Values :: struct {
theme: string;
ocr_model: string;
export_folders: string; // the list, a line each
font_size_base: float;
font_scale_main: float;
}
// The app values as of the last load or save. A save keeps what the file says for a setting that
// has not changed in the app since, so an edit made by hand while the app runs is not overwritten.
settings_synced: Settings_App_Values; // theme heap-allocated
settings_synced: Settings_App_Values; // its strings heap-allocated
settings_app_values :: () -> Settings_App_Values {
values: Settings_App_Values;
@ -412,6 +418,7 @@ settings_app_values :: () -> Settings_App_Values {
values.theme = ifx entry then entry.name else app_user_settings.theme;
if !values.theme.count values.theme = APP_DEFAULT_THEME_NAME;
values.ocr_model = ifx app_user_settings.ocr_model.count then app_user_settings.ocr_model else OCR_DEFAULT_MODEL;
values.export_folders = join(..app_user_settings.export_folders, "\n",, temp);
values.font_size_base = app_user_settings.font_size_base;
values.font_scale_main = app_user_settings.font_scale_main;
@ -428,9 +435,11 @@ settings_app_values :: () -> Settings_App_Values {
settings_note_synced :: () {
free(settings_synced.theme,, context.default_allocator);
free(settings_synced.ocr_model,, context.default_allocator);
free(settings_synced.export_folders,, context.default_allocator);
settings_synced = settings_app_values();
settings_synced.theme = copy_string(settings_synced.theme,, context.default_allocator);
settings_synced.ocr_model = copy_string(settings_synced.ocr_model,, context.default_allocator);
settings_synced.export_folders = copy_string(settings_synced.export_folders,, context.default_allocator);
}
// Parses the file's contents into out, replacing its theme and hotkey bindings. ok is false when
@ -445,6 +454,9 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) ->
array_reset(*out.hotkey_bindings);
out.global_hotkey_bindings.allocator = context.default_allocator;
array_reset(*out.global_hotkey_bindings);
for out.export_folders free(it,, context.default_allocator);
out.export_folders.allocator = context.default_allocator;
array_reset(*out.export_folders);
seen_version := false;
version: s64;
@ -506,6 +518,10 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) ->
free(out.ocr_model,, context.default_allocator);
out.ocr_model = copy_string(settings_unquote(value),, context.default_allocator);
seen.ocr_model = true;
} else if section == "[export]" && key == "folder" {
folder := settings_unquote(value); // an empty line says the list is empty
if folder.count array_add(*out.export_folders, copy_string(folder,, context.default_allocator));
seen.export_folder = true;
} else if section == "[ui]" && key == "theme" {
free(out.theme,, context.default_allocator);
out.theme = copy_string(settings_unquote(value),, context.default_allocator);
@ -633,6 +649,7 @@ app_save_user_settings_file :: () -> bool {
values := settings_app_values();
theme := values.theme;
ocr_model := values.ocr_model;
export_folders: [] string = app_user_settings.export_folders;
font_size_base := values.font_size_base;
font_scale_main := values.font_scale_main;
@ -663,6 +680,7 @@ app_save_user_settings_file :: () -> bool {
if disk_ok {
if seen.theme && values.theme == settings_synced.theme && on_disk.theme.count theme = on_disk.theme;
if seen.ocr_model && values.ocr_model == settings_synced.ocr_model && on_disk.ocr_model.count ocr_model = on_disk.ocr_model;
if seen.export_folder && values.export_folders == settings_synced.export_folders export_folders = on_disk.export_folders;
if seen.font_size && values.font_size_base == settings_synced.font_size_base font_size_base = on_disk.font_size_base;
if seen.font_scale && values.font_scale_main == settings_synced.font_scale_main font_scale_main = on_disk.font_scale_main;
if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction;
@ -695,6 +713,12 @@ app_save_user_settings_file :: () -> bool {
append(*builder, "[ocr]\n");
append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n");
print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model));
append(*builder, "[export]\n");
append(*builder, "# The folders Export (in the Capture window) offers to write a capture to as it was imported, a\n");
append(*builder, "# folder: line each; the first is the default. None: the Downloads folder.\n");
for export_folders print_to_builder(*builder, "folder: %\n", settings_quote_if_needed(it));
if !export_folders append(*builder, "folder:\n");
append(*builder, "\n");
append(*builder, "[video]\n");
append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\n");
append(*builder, "# screen changes. cursor: true or false. capture: auto (Desktop Duplication, else\n");