// The macOS recorder (module.jai has what is shared; windows.jai is the Windows one, which this follows). // A ScreenCaptureKit stream (modules/Screen_Record/macos/capture.m) of one display's region gives the // frames, already scaled to the region's pixels and with the cursor drawn in when asked, and the sound: // what the computer plays, and on macOS 15 and later the microphone, both from the same stream, so // there is no device to open here. mcap's own sound is left out. // // Three threads, as on Windows. The capture thread takes the newest frame, at most fps a second and // one a second while nothing changes, so players can seek; each carries the host time it was shown, // less the time paused. The encoder thread hands them to FFmpeg (encoder.jai, with VideoToolbox), the // oldest dropped when it falls RECORD_QUEUE behind. The audio thread puts each source's packets on the // same timeline into the mix (audio_mix.jai), and turns the sources on and off in the stream's // configuration as record_set_audio asks. // // Times are the host clock's (mach_absolute_time, CLOCK_UPTIME_RAW) in nanoseconds, which // ScreenCaptureKit's sample times are on too. AUDIO_RATE :: 48000; AUDIO_CHANNELS :: 2; Audio_Source_Kind :: enum u8 { SYSTEM; MICROPHONE; } // A source of sound in the stream: open while the stream is set to give it. Audio_Source :: struct { kind: Audio_Source_Kind; name: string; // "System sound", "Microphone" open: bool; lost_at: s64; // the mix's; never lost here } Audio_Packet :: struct { samples: [] float; // interleaved stereo, AUDIO_CHANNELS a frame frames: s64; ticks: s64; // the host time its first frame was heard, in nanoseconds silent: bool; } Record_Options :: struct { path: string; // the .mp4 written display_id: u32; // the display (CGDirectDisplayID) x, y, w, h: float64; // the region, in the display's points from its top left width, height: s32; // and in its pixels: rounded down to even here fps: s32 = 30; cursor := true; encoder: string; // "" tries RECORD_ENCODERS system_audio: bool; // what the computer plays microphone: bool; // the default input (macOS 15 and later); both at the start: record_set_audio turns them on and off audio_track: bool; // a sound track whatever is on at the start, so a source can be turned on later exclude_windows: [] u32; // window numbers kept out of the frames: the border and toolbar } Record_Stats :: struct { source: string; // "ScreenCaptureKit, display 1" fell_back_from: string; // never, here encoder: string; width, height: s32; captured: s64; // new frames from the stream pointer_moves: s64; emitted: s64; // frames sent to be encoded repeats: s64; // of them, the once-a-second repeat of an unchanged screen written: s64; // encoded dropped: s64; // the encoder was too far behind lost: s64; paused_seconds: float64; // left out of the video audio: string; // the sound recorded: "System sound; Microphone", or "" audio_frames: s64; // AAC frames encoded audio_errors: string; // sources that could not be turned on bytes: s64; // encoded seconds: float64; // from start to stop } Recorder :: struct { options: Record_Options; stream: *Mcap_Sck_Stream; encoder: Encoder; width, height: s32; frequency: s64 = 1_000_000_000; // ticks a second: nanoseconds start_ticks: s64; capture_thread, encoder_thread, audio_thread: Thread; capture_started, encoder_started, audio_started: bool; audio_sources: [2] Audio_Source; audio_open: bool; // the MP4 has sound audio_on: [2] bool; // each source is wanted (by Audio_Source_Kind): record_set_audio audio_failed: [2] bool; // it could not be turned on: record_audio_failed says once zero_ticks: s64; // the recording's time 0 paused_ticks: s64; // time paused so far; set by record_pause pause_began: s64; running: bool; stopping: bool; paused: bool; capture_ended: bool; mutex: Mutex; ready: Semaphore; // signalled for each frame queued, and once when capture ends latest: [] u8; // the stream's newest frame, BGRA: the capture thread's frames: [RECORD_BUFFERS] Record_Frame; free: [RECORD_BUFFERS] s32; free_count: s32; queue: [RECORD_BUFFERS] s32; // oldest first queue_count: s32; last_frame: [] u8; // the last frame queued, for record_last_frame stats: Record_Stats; error: string; // why it stopped by itself; heap } Record_Frame :: struct { pixels: [] u8; // BGRA, rows packed ticks: s64; } // Starts the stream, the encoder and the file, and starts recording. fall_back is never set: there is // one way of taking the screen here. record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: string, fall_back: bool { r.* = .{}; r.options = options; r.options.path = copy_string(options.path); r.options.encoder = copy_string(options.encoder); r.options.exclude_windows = array_copy(options.exclude_windows); if r.options.fps <= 0 r.options.fps = 30; if !record_size_allowed(options.width, options.height) return false, tprint("% x % is smaller than % x %", options.width, options.height, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false; // Even both ways: H.264's 4:2:0 colour needs it. The region in points shrinks to match. r.width = options.width & cast(s32) ~1; r.height = options.height & cast(s32) ~1; scale := options.w / options.width; w := r.width * scale; h := r.height * scale; r.stats.width, r.stats.height = r.width, r.height; init(*r.mutex, "record"); init(*r.ready); for * r.frames it.pixels = NewArray(cast(s64) r.width * r.height * 4, u8, initialized = false); for 0..RECORD_BUFFERS - 1 r.free[it] = cast(s32) it; r.free_count = RECORD_BUFFERS; r.latest = NewArray(cast(s64) r.width * r.height * 4, u8); r.last_frame = NewArray(cast(s64) r.width * r.height * 4, u8); // The microphone only where the stream can give it. microphone := options.microphone && mcap_sck_microphone_supported() != 0; if options.microphone && !microphone { r.stats.audio_errors = copy_string("microphone: it needs macOS 15 or later"); r.audio_failed[Audio_Source_Kind.MICROPHONE] = true; } error_text: [512] u8; excluded := r.options.exclude_windows; r.stream = mcap_sck_stream_start(options.display_id, options.x, options.y, w, h, r.width, r.height, r.options.fps, xx ifx options.cursor then 1 else 0, xx ifx options.system_audio then 1 else 0, xx ifx microphone then 1 else 0, excluded.data, xx excluded.count, error_text.data, error_text.count); if !r.stream { error := tprint("ScreenCaptureKit could not record: %", to_string(error_text.data)); record_free(r); return false, error, false; } r.stats.source = sprint("ScreenCaptureKit, display %", options.display_id); r.start_ticks = ticks_now(); r.zero_ticks = r.start_ticks; r.audio_sources[0] = .{kind = .SYSTEM, name = "System sound", open = options.system_audio}; r.audio_sources[1] = .{kind = .MICROPHONE, name = "Microphone", open = microphone}; r.audio_on = .[options.system_audio, microphone]; r.audio_open = options.system_audio || microphone || options.audio_track; record_note_audio(r); ok, error, tried := encoder_open(*r.encoder, r.options.path, r.width, r.height, r.options.fps, r.options.encoder, audio = r.audio_open); if tried log("[record] Encoders that did not open: %", tried); if !ok { error = copy_string(error,, temp); record_free(r); return false, error, false; } r.stats.encoder = copy_string(r.encoder.name); r.running = true; thread_init(*r.capture_thread, record_capture_proc, name = "record capture"); r.capture_started = true; thread_start(*r.capture_thread, r); thread_init(*r.encoder_thread, record_encoder_proc, name = "record encoder"); r.encoder_started = true; thread_start(*r.encoder_thread, r); if r.audio_open { thread_init(*r.audio_thread, record_audio_proc, name = "record audio"); r.audio_started = true; thread_start(*r.audio_thread, r); } return true, "", false; } // Stops, finishes the file and frees everything. ok is false when recording stopped by itself on an // error, or the file could not be finished; what was recorded up to then is in the file all the same. record_stop :: (r: *Recorder) -> ok: bool, error: string { if !r.running return false, "not recording"; atomic_swap(*r.stopping, true); thread_is_done(*r.capture_thread, -1); thread_is_done(*r.encoder_thread, -1); if r.audio_started thread_is_done(*r.audio_thread, -1); r.running = false; closed, close_error := encoder_close(*r.encoder); r.stats.seconds = cast(float64) (ticks_now() - r.start_ticks) / r.frequency; r.stats.bytes = r.encoder.bytes; error := ifx r.error then copy_string(r.error,, temp) else ""; record_free(r); if error return false, error; if !closed return false, close_error; return true, ""; } // Pauses or resumes; the time paused is left out of the video, sound and all. record_pause :: (r: *Recorder, pause: bool) { if !r.running || atomic_read(*r.paused) == pause return; now := ticks_now(); if pause { atomic_write(*r.pause_began, now); atomic_swap(*r.paused, true); } else { atomic_add(*r.paused_ticks, now - atomic_read(*r.pause_began)); atomic_swap(*r.paused, false); } } // Turns a source of sound on or off while recording; the stream is told on the audio thread. False // when the recording has no sound track to put it in. record_set_audio :: (r: *Recorder, kind: Audio_Source_Kind, on: bool) -> bool { if !r.running || !r.audio_open return false; atomic_swap(*r.audio_on[kind], on); return true; } record_audio_on :: (r: *Recorder, kind: Audio_Source_Kind) -> bool { return r.running && atomic_read(*r.audio_on[kind]); } // Whether a source could not be turned on (it is off again then), and why; once. record_audio_failed :: (r: *Recorder, kind: Audio_Source_Kind) -> failed: bool, error: string { if !r.running || !atomic_read(*r.audio_failed[kind]) return false, ""; atomic_swap(*r.audio_failed[kind], false); lock(*r.mutex); defer unlock(*r.mutex); return true, copy_string(r.stats.audio_errors,, temp); } // Ticks on the recording's timeline: time 0 at its start, less the time paused. record_timeline :: (r: *Recorder, ticks: s64) -> s64 { return ticks - r.zero_ticks - atomic_read(*r.paused_ticks); } record_paused :: (r: *Recorder) -> bool { return r.running && atomic_read(*r.paused); } record_stats :: (r: *Recorder) -> Record_Stats { if !r.running return r.stats; lock(*r.mutex); defer unlock(*r.mutex); return r.stats; } // The last frame queued, BGRA (temp), cursor and all. record_last_frame :: (r: *Recorder) -> pixels: [] u8, width: s32, height: s32 { if r.running lock(*r.mutex); pixels := array_copy(r.last_frame,, temp); if r.running unlock(*r.mutex); return pixels, r.width, r.height; } record_failed :: (r: *Recorder) -> bool, string { lock(*r.mutex); defer unlock(*r.mutex); return r.error.count > 0, r.error; } // The host clock, in nanoseconds: ScreenCaptureKit's sample times are on it. ticks_now :: () -> s64 { CLOCK_UPTIME_RAW :: 8; return cast(s64) clock_gettime_nsec_np(CLOCK_UPTIME_RAW); } #scope_file // The sources heard, for the stats: "System sound; Microphone". record_note_audio :: (r: *Recorder) { names: [..] string; names.allocator = temp; for r.audio_sources if it.open array_add(*names, it.name); lock(*r.mutex); free(r.stats.audio); r.stats.audio = join(..names, "; "); unlock(*r.mutex); } record_free :: (r: *Recorder) { atomic_swap(*r.stopping, true); if r.capture_started { thread_is_done(*r.capture_thread, -1); thread_deinit(*r.capture_thread); r.capture_started = false; } if r.encoder_started { thread_is_done(*r.encoder_thread, -1); thread_deinit(*r.encoder_thread); r.encoder_started = false; } if r.audio_started { thread_is_done(*r.audio_thread, -1); thread_deinit(*r.audio_thread); r.audio_started = false; } if r.stream { mcap_sck_stream_stop(r.stream); r.stream = null; } if r.encoder.format encoder_close(*r.encoder); for * r.frames array_free(it.pixels); array_free(r.latest); array_free(r.last_frame); r.latest, r.last_frame = .[], .[]; array_free(r.options.exclude_windows); r.options.exclude_windows = .[]; destroy(*r.ready); destroy(*r.mutex); free(r.error); r.error = ""; } // Takes the stream's frames as they come, and sends one when its turn is due: at most fps a second // (a tenth of a frame early counts as on time), and one a second while nothing changes. record_capture_proc :: (thread: *Thread) -> s64 { r := cast(*Recorder) thread.data; period := r.frequency / r.options.fps; early := period / 10; turn := ticks_now(); last_emit: s64 = -r.frequency; have_image, changed := false, false; frame_time: s64; was_paused := false; error_text: [512] u8; while !atomic_read(*r.stopping) { now := ticks_now(); due := ifx changed then turn - early else last_emit + r.frequency; wait := cast(s32) clamp((due - now) * 1000 / r.frequency, 1, 100); shown: s64; got := mcap_sck_next_frame(r.stream, r.latest.data, *shown, wait, error_text.data, error_text.count); if got < 0 { record_fail(r, tprint("ScreenCaptureKit stopped: %", to_string(error_text.data))); break; } if got > 0 { have_image, changed = true, true; frame_time = shown; lock(*r.mutex); r.stats.captured += 1; unlock(*r.mutex); } now = ticks_now(); if atomic_read(*r.paused) { was_paused = true; lock(*r.mutex); r.stats.paused_seconds = cast(float64) (atomic_read(*r.paused_ticks) + now - atomic_read(*r.pause_began)) / r.frequency; unlock(*r.mutex); continue; } if was_paused { was_paused = false; changed = true; turn = now; frame_time = now; // the screen as it is, at the moment of resuming lock(*r.mutex); r.stats.paused_seconds = cast(float64) atomic_read(*r.paused_ticks) / r.frequency; unlock(*r.mutex); } if !have_image continue; repeat := !changed && now - last_emit >= r.frequency; if !(changed && now >= turn - early) && !repeat continue; turn = max(turn + period, now - period); record_queue_frame(r, record_timeline(r, ifx repeat then now else frame_time)); last_emit = now; changed = false; lock(*r.mutex); r.stats.emitted += 1; if repeat r.stats.repeats += 1; unlock(*r.mutex); } atomic_swap(*r.capture_ended, true); signal(*r.ready); return 0; } // The newest frame into a free buffer, queued for the encoder; the oldest waiting dropped when every // buffer is taken. record_queue_frame :: (r: *Recorder, ticks: s64) { lock(*r.mutex); if !r.free_count { r.free[r.free_count] = r.queue[0]; r.free_count += 1; for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it]; r.queue_count -= 1; r.stats.dropped += 1; } r.free_count -= 1; index := r.free[r.free_count]; unlock(*r.mutex); frame := *r.frames[index]; memcpy(frame.pixels.data, r.latest.data, frame.pixels.count); frame.ticks = ticks; lock(*r.mutex); r.queue[r.queue_count] = index; r.queue_count += 1; memcpy(r.last_frame.data, frame.pixels.data, frame.pixels.count); unlock(*r.mutex); signal(*r.ready); } record_encoder_proc :: (thread: *Thread) -> s64 { r := cast(*Recorder) thread.data; while true { wait_for(*r.ready, 100); lock(*r.mutex); index: s32 = -1; if r.queue_count { index = r.queue[0]; for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it]; r.queue_count -= 1; } unlock(*r.mutex); if index < 0 { if atomic_read(*r.capture_ended) break; continue; } frame := *r.frames[index]; pts := max(frame.ticks, 0) * 90000 / r.frequency; // on the timeline already ok, error := encoder_write(*r.encoder, frame.pixels.data, pts); lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; if ok r.stats.written += 1; r.stats.bytes = r.encoder.bytes; unlock(*r.mutex); if !ok { record_fail(r, error); while !atomic_read(*r.capture_ended) { wait_for(*r.ready, 100); lock(*r.mutex); for 0..r.queue_count - 1 { r.free[r.free_count] = r.queue[it]; r.free_count += 1; } r.queue_count = 0; unlock(*r.mutex); } break; } } return 0; } // Every 10 ms: the sources turned on or off as asked, their packets into the mix, and what every // source has covered encoded. record_audio_proc :: (thread: *Thread) -> s64 { r := cast(*Recorder) thread.data; if !r.encoder.audio return 0; mix: Audio_Mix; mix.frame_size = r.encoder.audio_frame_size; defer array_free(mix.samples); MAX_FRAMES :: 4096; samples := NewArray(MAX_FRAMES * AUDIO_CHANNELS, float); defer array_free(samples); error_text: [512] u8; while true { stopping := atomic_read(*r.stopping); // The stream's sources as the toolbar wants them. want := r.audio_on; for * want it.* = atomic_read(it); if !stopping && (want[0] != r.audio_sources[0].open || want[1] != r.audio_sources[1].open) { if want[1] && !r.audio_sources[1].open && !mcap_sck_microphone_supported() { atomic_swap(*r.audio_on[Audio_Source_Kind.MICROPHONE], false); lock(*r.mutex); free(r.stats.audio_errors); r.stats.audio_errors = copy_string("microphone: it needs macOS 15 or later"); unlock(*r.mutex); atomic_swap(*r.audio_failed[Audio_Source_Kind.MICROPHONE], true); want[1] = false; } if mcap_sck_stream_set_audio(r.stream, xx ifx want[0] then 1 else 0, xx ifx want[1] then 1 else 0, error_text.data, error_text.count) { for 0..1 { if want[it] && !r.audio_sources[it].open mix.started[it] = false; r.audio_sources[it].open = want[it]; } record_note_audio(r); log("[record] Sound now: %.", ifx r.stats.audio then r.stats.audio else "none"); } else { why := to_string(error_text.data); log_error("[record] The sound could not be changed: %", why); for 0..1 if want[it] && !r.audio_sources[it].open { atomic_swap(*r.audio_on[it], false); lock(*r.mutex); free(r.stats.audio_errors); r.stats.audio_errors = sprint("% could not be turned on: %", r.audio_sources[it].name, why); unlock(*r.mutex); atomic_swap(*r.audio_failed[it], true); } } } for * source, index: r.audio_sources { if !source.open continue; while true { shown: s64; frames := mcap_sck_next_audio(r.stream, xx index, samples.data, MAX_FRAMES, *shown); if frames <= 0 break; if atomic_read(*r.paused) continue; packet := Audio_Packet.{samples = array_view(samples, 0, frames * AUDIO_CHANNELS), frames = frames, ticks = shown}; audio_mix_add(*mix, r, index, packet); } } ok, error := audio_mix_encode(*mix, r, final = stopping); if !ok { record_fail(r, error); break; } if stopping break; reset_temporary_storage(); sleep_milliseconds(10); } return 0; } record_fail :: (r: *Recorder, error: string) { log_error("[record] %", error); lock(*r.mutex); if !r.error r.error = copy_string(error); unlock(*r.mutex); atomic_swap(*r.stopping, true); } #scope_module // The shim (macos/capture.h). Mcap_Sck_Stream :: struct { _: u8; } mcap_capture :: #library,no_dll "macos/capture"; mcap_sck_stream_start :: (display_id: u32, x: float64, y: float64, w: float64, h: float64, width: s32, height: s32, fps: s32, show_cursor: s32, system_audio: s32, microphone: s32, excluded: *u32, excluded_count: s32, error: *u8, error_size: s32) -> *Mcap_Sck_Stream #foreign mcap_capture; mcap_sck_next_frame :: (s: *Mcap_Sck_Stream, pixels: *u8, host_time_ns: *s64, timeout_ms: s32, error: *u8, error_size: s32) -> s32 #foreign mcap_capture; mcap_sck_next_audio :: (s: *Mcap_Sck_Stream, source: s32, samples: *float, max_frames: s32, host_time_ns: *s64) -> s32 #foreign mcap_capture; mcap_sck_stream_set_audio :: (s: *Mcap_Sck_Stream, system_audio: s32, microphone: s32, error: *u8, error_size: s32) -> s32 #foreign mcap_capture; mcap_sck_microphone_supported :: () -> s32 #foreign mcap_capture; mcap_sck_stream_stop :: (s: *Mcap_Sck_Stream) #foreign mcap_capture; ScreenCaptureKit_Framework :: #library,system,link_always "ScreenCaptureKit"; CoreMedia_Framework :: #library,system,link_always "CoreMedia"; CoreVideo_Framework :: #library,system,link_always "CoreVideo"; clock_gettime_nsec_np :: (clock: s32) -> u64 #foreign libc_for_clock; libc_for_clock :: #library,system "libc";