// 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"; } }