// Screen recording (video-capture-plan.html, macos-plan phase 7): Ctrl+PrintScreen on Windows,
// Opt+Cmd+Shift+2 on macOS (the record_region global hotkey, from any app, even with mcap hidden in the
// tray) freezes the screens as a still's key does, in the overlay's video mode
// (app/screen_capture_win32.jai, app/screen_capture_macos.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.
//
// While it records, a red border is drawn just outside the region and a toolbar beside it gives the
// time, Pause, Stop and Discard (app/screen_record_win32.jai, app/screen_record_macos.jai); both are
// kept out of the recording.
// Time paused is left out of the video. With sound: what the computer plays and the microphone, as
// [video] in the settings file says, as are the frame rate, the cursor and how the screen is taken.
// The toolbar's System sound and Mic buttons turn each on and off while recording, and are kept in
// [video] for the next: every recording has a sound track for them, silent while nothing is on.
//
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
// start (screen_record_leftovers_popup). PrintScreen while recording takes the screen under the cursor
// at once, without the overlay, whose frozen screen would be in the recording.
//
// Windows and macOS, as modules/Screen_Record is.
SCREEN_RECORD_PREFIX :: "recording-"; // the MP4s in the temporary folder: recording-2026-10-10_07-12-43.mp4
screen_record: struct {
recording: bool;
flag_started: bool; // -record has started its recording
flag_start: Apollo_Time;
leftovers: [..] string; // MP4s a crash left in the temporary folder, to offer; heap
leftovers_asked: bool;
shot: Screen_Shot; // the region and the window it is of; no pixels
date: string; // when it started, as CaptureDate; heap
path: string; // the MP4 being written; heap
#if OS == .WINDOWS || OS == .MACOS 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 || OS == .MACOS {
if !screen_record.recording return;
#if OS == .MACOS record_ui_tick();
failed, why := record_failed(*screen_record.recorder);
if failed {
toast_show(.Error, "The recording stopped: %", why);
screen_record_stop();
return;
}
for kind: Audio_Source_Kind.[.SYSTEM, .MICROPHONE] {
failed, why := record_audio_failed(*screen_record.recorder, kind);
if !failed continue;
toast_show(.Error, "% could not be turned on: %", screen_record_audio_name(kind), why);
screen_record_remember_audio(kind, false);
record_ui_refresh();
}
ask := record_ui_take_ask();
if ask == .SYSTEM_SOUND || ask == .MICROPHONE {
kind: Audio_Source_Kind = ifx ask == .SYSTEM_SOUND then .SYSTEM else .MICROPHONE;
on := !record_audio_on(*screen_record.recorder, kind);
if record_set_audio(*screen_record.recorder, kind, on) {
log("[record] % turned %.", screen_record_audio_name(kind), ifx on then "on" else "off");
toast_show(.Success, "% %", screen_record_audio_name(kind), ifx on then "on" else "off");
screen_record_remember_audio(kind, on);
}
record_ui_refresh();
}
if ask == {
case .PAUSE;
pause := !record_paused(*screen_record.recorder);
record_pause(*screen_record.recorder, pause);
log("[record] %.", ifx pause then "Paused" else "Resumed");
record_ui_refresh();
case .STOP; screen_record_stop();
case .DISCARD; screen_record_discard();
}
}
}
// -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
// -record-region X Y W H; 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. -record-ui-visible leaves the border and toolbar in screenshots, to
// look at them.
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(shutdown = true);
}
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
screen_record_startup :: () {
#if OS == .WINDOWS || OS == .MACOS {
screen_record.leftovers.allocator = context.default_allocator;
for file_list(screen_record_folder(),, temp) {
name := path_filename(it);
if !begins_with(name, SCREEN_RECORD_PREFIX) || !ends_with_nocase(name, ".mp4") continue;
array_add(*screen_record.leftovers, copy_string(it,, context.default_allocator));
}
if screen_record.leftovers.count log("[record] % recording% left in % by an earlier run.", screen_record.leftovers.count, ifx screen_record.leftovers.count == 1 then "" else "s", screen_record_folder());
}
}
// The offer of what screen_record_startup found: into the library, or deleted, or asked again next
// time. A modal popup, at the top level, each frame.
screen_record_leftovers_popup :: () { using ImGui;
POPUP :: "Unsaved recordings";
if !screen_record.leftovers.count || app.record_and_exit > 0 return;
if !screen_record.leftovers_asked && !IsPopupOpen(POPUP) { OpenPopup(POPUP); screen_record.leftovers_asked = true; }
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
count := screen_record.leftovers.count;
Text(ifx count == 1 then "A screen recording was not saved: mcap closed while it was recording." else tprint("% screen recordings were not saved: mcap closed while they were recording.", count));
for screen_record.leftovers {
_, size, _ := file_modtime_and_size(it);
TextColored(app_theme.palette.dim, "% (% MB)", screen_record_leftover_date(it), FF(size / (1024.0 * 1024.0), 1));
}
TextColored(app_theme.palette.dim, "Each plays up to its last two seconds or so.");
Spacing();
done := false;
if Button("Save to the library") {
for screen_record.leftovers {
job := New(Screen_Capture_Job,, context.default_allocator);
job.video = true;
job.recording = it; // the job's now
job.date = copy_string(screen_record_leftover_date(it),, context.default_allocator);
screen_capture_job_start(job);
}
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
SameLine();
if Button("Delete") {
for screen_record.leftovers { file_delete(it); free(it,, context.default_allocator); }
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
SameLine();
if Button("Later") || IsKeyPressed(.Escape) {
for screen_record.leftovers free(it,, context.default_allocator);
array_reset_keeping_memory(*screen_record.leftovers);
done = true;
}
if done CloseCurrentPopup();
EndPopup();
}
// "2026-10-10 07:12:43" from recording-2026-10-10_07-12-43.mp4 (or _2.mp4 ...): when it began. Temp.
screen_record_leftover_date :: (path: string) -> string {
name := path_strip_extension(path_filename(path));
stamp := advance(name, SCREEN_RECORD_PREFIX.count);
if stamp.count < 19 return name;
stamp.count = 19;
date := copy_string(stamp,, temp);
date[10] = #char " ";
date[13] = #char ":";
date[16] = #char ":";
return date;
}
screen_record_folder :: () -> string {
return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
}
#if OS == .WINDOWS || OS == .MACOS {
// 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 OS == .WINDOWS {
r := app.record_region;
if ok && immediate && r.right > r.left { // -record-region
shot.left, shot.top, shot.width, shot.height = r.left, r.top, r.right - r.left, r.bottom - r.top;
shot.whole_screen = false;
}
}
if !ok {
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
else log("[record] Cancelled.");
return;
}
folder := screen_record_folder();
make_directory_if_it_does_not_exist(folder, recursive = true);
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
number := 2;
while file_exists(path) { path = tprint("%/%%_%.mp4", folder, SCREEN_RECORD_PREFIX, stamp, number); number += 1; }
options: Record_Options;
options.path = path;
options.fps = cast(s32) app_user_settings.video_fps;
options.cursor = app_user_settings.video_cursor;
options.system_audio = app_user_settings.video_system_audio;
options.microphone = app_user_settings.video_microphone;
options.audio_track = true; // so the toolbar can turn a source on mid-recording
#if OS == .MACOS {
// The display's region, and the border and toolbar first: ScreenCaptureKit leaves them out of the
// recording by their window numbers.
options.display_id = shot.display_id;
options.x, options.y, options.w, options.h = shot.local_x, shot.local_y, shot.local_width, shot.local_height;
options.width, options.height = shot.width, shot.height;
options.exclude_windows = record_ui_open(shot, shot.width & cast(s32) ~1, shot.height & cast(s32) ~1);
} else {
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
if app_user_settings.video_capture == {
case "dxgi"; options.capture = .DXGI;
case "wgc"; options.capture = .WGC;
}
}
started, start_error := record_start(*screen_record.recorder, options);
if !started {
#if OS == .MACOS record_ui_close();
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);
if stats.audio log("[record] Sound: %.", stats.audio);
if stats.audio_errors {
log_error("[record] Sound not recorded: %.", stats.audio_errors);
toast_show(.Error, "Recording without some sound: %", stats.audio_errors);
} else {
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
}
#if OS == .WINDOWS record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
}
// A sound button's state, into [video] for the next recording.
screen_record_remember_audio :: (kind: Audio_Source_Kind, on: bool) {
setting := ifx kind == .SYSTEM then *app_user_settings.video_system_audio else *app_user_settings.video_microphone;
if setting.* == on return;
setting.* = on;
app_save_user_settings_file();
}
screen_record_audio_name :: (kind: Audio_Source_Kind) -> string {
return ifx kind == .SYSTEM then "System sound" else "The microphone";
}
// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
screen_record_discard :: () {
if !screen_record.recording return;
screen_record.recording = false;
record_ui_close();
record_stop(*screen_record.recorder);
file_delete(screen_record.path);
log("[record] Discarded % (% s).", screen_record.path, FF(screen_record.recorder.stats.seconds, 1));
toast_show(.Success, "Recording discarded");
screen_shot_free(*screen_record.shot);
free(screen_record.date,, context.default_allocator);
free(screen_record.path,, context.default_allocator);
screen_record.date, screen_record.path = "", "";
}
// Stops, and hands the MP4 to a job that puts it in the library. shutdown: on the shutdown thread, so
// no toasts, and the border and toolbar, which are the main thread's, are only hidden.
screen_record_stop :: (shutdown := false) {
if !screen_record.recording return;
screen_record.recording = false;
quiet := shutdown;
if shutdown record_ui_hide();
else record_ui_close();
stopped, stop_error := record_stop(*screen_record.recorder);
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;
job.paused_seconds = stats.paused_seconds;
job.audio = copy_string(stats.audio,, context.default_allocator);
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.
//
// 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 {
_, recorded_size, found := file_modtime_and_size(job.recording);
if !found || recorded_size <= 0 return "", tprint("% is missing", 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);
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 = mp4, offset = 0, size = size});
if !hashed return "", tprint("% cannot be read", mp4);
shot := job.shot;
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);
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");
if known {
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));
if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
if job.source import_add(doc, info, "Capture", import_text(job.source));
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
capture_object_add(doc, *info.root, "Audio", capture_boolean(video.has_audio));
if job.audio import_add(doc, info, "AudioSources", import_text(job.audio));
if job.paused_seconds > 0 import_add_number(doc, info, "Paused", import_milliseconds(job.paused_seconds));
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 = mp4, 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 on Windows and macOS only");
}
screen_record_stop :: (shutdown := false) {}
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
return "", "screen recording is on Windows and macOS only";
}
}