diff --git a/README.md b/README.md
index 82c5837..2b2d9dd 100644
--- a/README.md
+++ b/README.md
@@ -220,7 +220,7 @@ On macOS (Cmd+Shift+3, 4 and 5 are the system's own screenshot keys):
### Recording the screen
-**Ctrl+PrintScreen** records a region of one screen as a video:
+**Ctrl+PrintScreen** (**Opt+Cmd+Shift+2** on macOS) records a region of one screen as a video:
1. **The screens freeze**, as for a still, with the outline and magnifier in red.
2. **Drag a region**, or click (or press Enter) for the whole screen under the cursor:
@@ -238,14 +238,15 @@ On macOS (Cmd+Shift+3, 4 and 5 are the system's own screenshot keys):
and **Mic** (each on or off), **Pause** (the time paused is left out of the video), **Stop**, and **Discard**, which asks
first.
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
-4. **Ctrl+PrintScreen again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
+4. **The same key again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
OCR and labels read a frame from it.
- **The screen** is taken with DXGI Desktop Duplication, or with Windows.Graphics.Capture where that
- cannot run (a laptop with two GPUs, a rotated screen).
+ cannot run (a laptop with two GPUs, a rotated screen). On macOS, with a ScreenCaptureKit stream
+ (`modules/Screen_Record/macos/capture.m`), which also brings the sound.
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
- Foundation, or libx264.
+ Foundation, or libx264. On macOS, VideoToolbox.
- **Losing nothing:**
- 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
@@ -254,11 +255,13 @@ On macOS (Cmd+Shift+3, 4 and 5 are the system's own screenshot keys):
keeps all but its last two seconds or so.
- **Settings:** `[video]` in the settings file:
- `fps` (30) and `cursor` (true).
- - `capture`: `auto` (Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
+ - `capture` (Windows): `auto` (Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
- `system_audio` (true): what the computer plays, from the default output device. The toolbar's
System sound button sets it.
- `microphone` (false): the default input device. The toolbar's Mic button sets it.
- Sound is AAC at 160 kb/s. A device unplugged mid-recording is picked up again from the new default.
+ - On macOS the microphone needs macOS 15 or later, and its permission (asked for the first time the
+ Mic button turns it on).
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
the screens, which would show in the recording.
- **The key** is `record_region` in `[global_hotkeys]`.
diff --git a/assets/data/default_global_hotkeys_macos.config b/assets/data/default_global_hotkeys_macos.config
index f1dffab..bd0ad07 100644
--- a/assets/data/default_global_hotkeys_macos.config
+++ b/assets/data/default_global_hotkeys_macos.config
@@ -6,4 +6,4 @@
none bring_to_foreground
Cmd-Shift-2 capture_region
-none record_region
+Opt-Cmd-Shift-2 record_region
diff --git a/check_targets.jai b/check_targets.jai
index 79e0357..5a3c131 100644
--- a/check_targets.jai
+++ b/check_targets.jai
@@ -52,7 +52,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
-VERSION :: "0.1.33";
+VERSION :: "0.1.34";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;
diff --git a/first.jai b/first.jai
index 684dfd4..73e6408 100644
--- a/first.jai
+++ b/first.jai
@@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
-VERSION :: "0.1.33";
+VERSION :: "0.1.34";
// 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";
@@ -373,6 +373,7 @@ finish_main_build :: (build: Pending_Build) {
CFBundleVersion %
LSMinimumSystemVersion %
NSHighResolutionCapable
+ NSMicrophoneUsageDescription mcap records the microphone into a screen recording when its Mic button is on.
PLIST
diff --git a/modules/Screen_Record/audio_mix.jai b/modules/Screen_Record/audio_mix.jai
new file mode 100644
index 0000000..88af054
--- /dev/null
+++ b/modules/Screen_Record/audio_mix.jai
@@ -0,0 +1,77 @@
+// The sound of a recording, mixed: each source's packets put on the recording's timeline and added
+// together, and an AAC frame encoded once every source has covered it. Shared by the Windows and
+// macOS recorders (windows.jai, macos.jai), whose Recorder each has the fields used here: moved here
+// from module.jai unchanged.
+
+#scope_module
+
+// The sound so far, from its base: one AAC frame's worth is encoded at a time.
+Audio_Mix :: struct {
+ samples: [..] float; // interleaved stereo, from base on
+ base: s64; // the frame (in samples, on the timeline) samples[0] is
+ next: [2] s64; // each source's next frame, so packets that follow on are not moved by jitter
+ started: [2] bool;
+ frame_size: s32;
+}
+
+AUDIO_SLACK :: AUDIO_RATE / 5; // 200 ms: how late a source's packets may come and still be in the mix
+AUDIO_FOLLOW_ON :: AUDIO_RATE / 33; // 30 ms: a packet that close to where its source left off follows on
+
+// A packet into the mix at its place on the timeline, added to what the other source put there.
+audio_mix_add :: (mix: *Audio_Mix, r: *Recorder, source: s64, packet: Audio_Packet) {
+ at := record_timeline(r, packet.ticks) * AUDIO_RATE / r.frequency;
+ if mix.started[source] && abs(at - mix.next[source]) <= AUDIO_FOLLOW_ON at = mix.next[source];
+ mix.started[source] = true;
+ mix.next[source] = at + packet.frames;
+ end := (at + packet.frames - mix.base) * AUDIO_CHANNELS;
+ if end > mix.samples.count {
+ old := mix.samples.count;
+ array_resize(*mix.samples, end, initialize = false);
+ for old..end - 1 mix.samples[it] = 0;
+ }
+ if packet.silent || !packet.samples.count return;
+ for frame: max(at, mix.base)..at + packet.frames - 1 {
+ into := (frame - mix.base) * AUDIO_CHANNELS;
+ from := (frame - at) * AUDIO_CHANNELS;
+ mix.samples[into] += packet.samples[from];
+ mix.samples[into + 1] += packet.samples[from + 1];
+ }
+}
+
+// Encodes the frames every open source has covered: up to where each has sent, or, for one that has
+// sent nothing lately (silence), to AUDIO_SLACK ago. final: everything sent.
+audio_mix_encode :: (mix: *Audio_Mix, r: *Recorder, final: bool) -> ok: bool, error: string {
+ // Paused, the timeline stands still where the pause began.
+ now := ifx atomic_read(*r.paused) then atomic_read(*r.pause_began) else ticks_now();
+ now_frame := record_timeline(r, now) * AUDIO_RATE / r.frequency;
+ ready := now_frame - AUDIO_SLACK;
+ for source, index: r.audio_sources {
+ if !source.open continue;
+ covered := max(mix.next[index], now_frame - AUDIO_SLACK);
+ ready = min(ready, covered);
+ }
+ if final { ready = mix.base; for mix.next ready = max(ready, it); }
+
+ size := mix.frame_size;
+ frame := NewArray(size * AUDIO_CHANNELS, float, initialized = false,, temp);
+ done: s64;
+ while mix.base + size <= ready {
+ for i: 0..frame.count - 1 {
+ at := done * AUDIO_CHANNELS + i;
+ value := ifx at < mix.samples.count then mix.samples[at] else 0.0;
+ frame[i] = clamp(value, -1.0, 1.0);
+ }
+ ok, error := encoder_write_audio(*r.encoder, frame.data, mix.base);
+ if !ok return false, error;
+ mix.base += size;
+ done += size;
+ lock(*r.mutex); r.stats.audio_frames += 1; unlock(*r.mutex);
+ }
+ // What was encoded leaves the front.
+ gone := min(done * AUDIO_CHANNELS, mix.samples.count);
+ if gone {
+ memcpy(mix.samples.data, mix.samples.data + gone, (mix.samples.count - gone) * size_of(float));
+ mix.samples.count -= gone;
+ }
+ return true, "";
+}
diff --git a/modules/Screen_Record/encoder.jai b/modules/Screen_Record/encoder.jai
index f96c8aa..018cc9b 100644
--- a/modules/Screen_Record/encoder.jai
+++ b/modules/Screen_Record/encoder.jai
@@ -1,6 +1,7 @@
// The encoder thread's half: frames of BGRA pixels to H.264 in a fragmented MP4, with FFmpeg
// (modules/ffmpeg). The first encoder in RECORD_ENCODERS that opens at the size wanted is used:
-// NVENC, AMD's AMF, Intel's QSV, Windows' Media Foundation, then libx264 on the CPU. NVENC takes the
+// NVENC, AMD's AMF, Intel's QSV, Windows' Media Foundation, then libx264 on the CPU; on macOS,
+// VideoToolbox (Apple silicon's media engine), at a constant quality. NVENC takes the
// 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).
//
@@ -12,7 +13,8 @@
// 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"];
+#if OS == .MACOS RECORD_ENCODERS :: string.["h264_videotoolbox"]; // Apple's, in every Mac; the macOS FFmpeg has no libx264
+else RECORD_ENCODERS :: string.["h264_nvenc", "h264_amf", "h264_qsv", "h264_mf", "libx264"];
RECORD_KEYFRAME_SECONDS :: 2;
RECORD_AUDIO_BIT_RATE :: 160_000;
@@ -170,6 +172,7 @@ encoder_close :: (e: *Encoder) -> ok: bool, error: string {
#scope_file
RECORD_TIME_BASE :: 90000; // pts ticks a second: MPEG's
+VIDEOTOOLBOX_QUALITY :: 65; // 0-100: sharp text at a few MB a minute for a screen
encoder_try :: (e: *Encoder, name: string, width: s32, height: s32, fps: s32) -> ok: bool, error: string {
codec := avcodec_find_encoder_by_name(temp_c_string(name));
@@ -203,6 +206,13 @@ encoder_try :: (e: *Encoder, name: string, width: s32, height: s32, fps: s32) ->
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 "libx264"; set("preset", "veryfast"); set("crf", "23"); set("forced-idr", "1");
+ case "h264_videotoolbox";
+ // A constant quality (VideoToolbox's 0-1, as 0-100), which Apple silicon takes: FFmpeg's default
+ // bit rate, 200 kb/s, would blur a screen. Real time, as frames come as they are recorded.
+ set("realtime", "1");
+ set("allow_sw", "0");
+ c.flags |= AV_CODEC_FLAG_QSCALE;
+ c.global_quality = FF_QP2LAMBDA * VIDEOTOOLBOX_QUALITY;
}
result := avcodec_open2(c, codec, *options);
av_dict_free(*options);
@@ -231,7 +241,8 @@ encoder_input_format :: (codec: *AVCodec) -> AVPixelFormat {
// The packets an encoder (the video's, unless another is given) has ready, into the file.
encoder_drain :: (e: *Encoder, codec: *AVCodecContext = null, stream: *AVStream = null, packet: *AVPacket = null) -> ok: bool, error: string {
- AVERROR_EAGAIN :: -11;
+ #if OS == .MACOS AVERROR_EAGAIN :: -35; // AVERROR(EAGAIN): -EAGAIN, whose errno differs
+ else AVERROR_EAGAIN :: -11;
if !codec codec, stream, packet = e.codec, e.stream, e.packet;
while true {
result := avcodec_receive_packet(codec, packet);
diff --git a/modules/Screen_Record/macos.jai b/modules/Screen_Record/macos.jai
new file mode 100644
index 0000000..deec34c
--- /dev/null
+++ b/modules/Screen_Record/macos.jai
@@ -0,0 +1,526 @@
+// 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";
diff --git a/modules/Screen_Record/macos/capture.h b/modules/Screen_Record/macos/capture.h
index 506a008..1da62cb 100644
--- a/modules/Screen_Record/macos/capture.h
+++ b/modules/Screen_Record/macos/capture.h
@@ -15,3 +15,39 @@ typedef struct {
int mcap_sck_screenshots(Mcap_Sck_Shot *shots, int count, char *error, int error_size);
void mcap_sck_free(void *pointer);
+
+// ---------------------------------------------------------------- recording (macos-plan, phase 7)
+
+// A stream of one display's region, with the sound wanted. Frames are kept one at a time, the
+// newest; sound is queued, per source, until taken.
+typedef struct Mcap_Sck_Stream Mcap_Sck_Stream;
+
+enum { MCAP_SCK_SYSTEM = 0, MCAP_SCK_MICROPHONE = 1 };
+
+// The region (x, y, w, h) in the display's points from its top left; width and height the frames'
+// size in pixels. excluded: window numbers kept out of the frames (mcap's border and toolbar).
+// The microphone comes from the stream itself, which macOS 15 has; on 14 it cannot be turned on.
+// NULL, with why in error, when it could not start.
+Mcap_Sck_Stream *mcap_sck_stream_start(uint32_t display_id, double x, double y, double w, double h,
+ int32_t width, int32_t height, int32_t fps, int show_cursor,
+ int system_audio, int microphone,
+ const uint32_t *excluded, int excluded_count,
+ char *error, int error_size);
+
+// The newest frame since the last taken, as packed BGRA into pixels (width * height * 4), with the
+// host time it was shown at (mach_absolute_time, in nanoseconds). 1 for a frame; 0 when none came in
+// timeout_ms; -1 when the stream stopped by itself (error says why).
+int mcap_sck_next_frame(Mcap_Sck_Stream *s, uint8_t *pixels, int64_t *host_time_ns, int timeout_ms, char *error, int error_size);
+
+// The oldest sound packet of a source not yet taken: up to max_frames frames of 48 kHz stereo float,
+// interleaved, into samples, and the host time of its first frame. Its frame count; 0 for none.
+int mcap_sck_next_audio(Mcap_Sck_Stream *s, int source, float *samples, int max_frames, int64_t *host_time_ns);
+
+// Turns the sources on and off while it runs (the stream's configuration changed). 1 when done.
+int mcap_sck_stream_set_audio(Mcap_Sck_Stream *s, int system_audio, int microphone, char *error, int error_size);
+
+// Whether this macOS can take the microphone from the stream.
+int mcap_sck_microphone_supported(void);
+
+// Stops it, waits for it, and frees it.
+void mcap_sck_stream_stop(Mcap_Sck_Stream *s);
diff --git a/modules/Screen_Record/macos/capture.m b/modules/Screen_Record/macos/capture.m
index 81f258c..a1077d8 100644
--- a/modules/Screen_Record/macos/capture.m
+++ b/modules/Screen_Record/macos/capture.m
@@ -1,10 +1,13 @@
// ScreenCaptureKit for mcap on macOS, behind a plain C interface (capture.h): ScreenCaptureKit is
// asynchronous, with completion blocks and its own queues, which is simpler to wait on here than from
-// Jai. first.jai compiles this into capture.a beside it. Recording (macos-plan, phase 7) comes here too.
+// Jai. first.jai compiles this into capture.a beside it. Stills for the screen capture overlay, and
+// a stream for recording (modules/Screen_Record/macos.jai): its frames and sound, on host time.
#import "capture.h"
#import
#import
+#import
+#import
#include
#include
@@ -105,3 +108,285 @@ int mcap_sck_screenshots(Mcap_Sck_Shot *shots, int count, char *error, int error
void mcap_sck_free(void *pointer) {
free(pointer);
}
+
+// ---------------------------------------------------------------- recording
+
+#define AUDIO_RATE 48000
+
+// Whether this is macOS 15 or later, for the microphone in the stream. Asked of NSProcessInfo rather
+// than with @available, whose check comes from clang's runtime library, which Jai's link leaves out;
+// the warnings for the calls it guards are off below.
+static BOOL macos_15(void) {
+ static int known = -1;
+ if (known < 0) known = [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion) {15, 0, 0}] ? 1 : 0;
+ return known == 1;
+}
+#pragma clang diagnostic ignored "-Wunguarded-availability-new"
+
+// A source's packets, waiting to be taken: interleaved stereo float at AUDIO_RATE.
+@interface McapAudioPacket : NSObject
+@property (nonatomic) NSData *samples;
+@property (nonatomic) int64_t hostTime;
+@end
+@implementation McapAudioPacket
+@end
+
+@interface McapStream : NSObject
+@property (nonatomic) SCStream *stream;
+@property (nonatomic) SCStreamConfiguration *configuration;
+@property (nonatomic) dispatch_queue_t queue;
+@property (nonatomic) NSCondition *condition; // over everything below
+@property (nonatomic) NSMutableData *frame; // the newest frame, packed BGRA
+@property (nonatomic) int64_t frameTime;
+@property (nonatomic) BOOL fresh; // a frame not yet taken
+@property (nonatomic) int32_t width, height;
+@property (nonatomic) NSString *failure; // the stream stopped by itself
+@property (nonatomic) NSMutableArray *system, *microphone;
+@end
+
+static int64_t host_nanoseconds(CMTime time) {
+ return (int64_t) (CMTimeGetSeconds(time) * 1e9);
+}
+
+@implementation McapStream
+
+- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)buffer ofType:(SCStreamOutputType)type {
+ if (!CMSampleBufferIsValid(buffer)) return;
+ if (type == SCStreamOutputTypeScreen) [self takeFrame:buffer];
+ else if (type == SCStreamOutputTypeAudio) [self takeAudio:buffer into:self.system];
+ else if (macos_15()) { if (type == SCStreamOutputTypeMicrophone) [self takeAudio:buffer into:self.microphone]; }
+}
+
+- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error {
+ [self.condition lock];
+ self.failure = error.localizedDescription ?: @"the screen stopped being recorded";
+ [self.condition broadcast];
+ [self.condition unlock];
+}
+
+// A complete frame (not an idle one, which repeats the last), packed into self.frame.
+- (void)takeFrame:(CMSampleBufferRef)buffer {
+ CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(buffer, false);
+ if (attachments && CFArrayGetCount(attachments) > 0) {
+ NSDictionary *info = (__bridge NSDictionary *) CFArrayGetValueAtIndex(attachments, 0);
+ NSNumber *status = info[SCStreamFrameInfoStatus];
+ if (status && status.integerValue != SCFrameStatusComplete) return;
+ }
+ CVPixelBufferRef image = CMSampleBufferGetImageBuffer(buffer);
+ if (!image) return;
+ CVPixelBufferLockBaseAddress(image, kCVPixelBufferLock_ReadOnly);
+ const uint8_t *base = CVPixelBufferGetBaseAddress(image);
+ size_t stride = CVPixelBufferGetBytesPerRow(image);
+ size_t w = MIN((size_t) self.width, CVPixelBufferGetWidth(image));
+ size_t h = MIN((size_t) self.height, CVPixelBufferGetHeight(image));
+ [self.condition lock];
+ uint8_t *out = self.frame.mutableBytes;
+ size_t row = (size_t) self.width * 4;
+ if (base) for (size_t y = 0; y < h; y++) memcpy(out + y * row, base + y * stride, w * 4);
+ self.frameTime = host_nanoseconds(CMSampleBufferGetPresentationTimeStamp(buffer));
+ self.fresh = YES;
+ [self.condition signal];
+ [self.condition unlock];
+ CVPixelBufferUnlockBaseAddress(image, kCVPixelBufferLock_ReadOnly);
+}
+
+// A sound buffer as interleaved stereo float at AUDIO_RATE, onto the source's queue.
+- (void)takeAudio:(CMSampleBufferRef)buffer into:(NSMutableArray *)queue {
+ CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(buffer);
+ const AudioStreamBasicDescription *asbd = format ? CMAudioFormatDescriptionGetStreamBasicDescription(format) : NULL;
+ if (!asbd || !(asbd->mFormatFlags & kAudioFormatFlagIsFloat) || asbd->mBitsPerChannel != 32) return;
+ CMItemCount frames = CMSampleBufferGetNumSamples(buffer);
+ if (frames <= 0) return;
+ size_t needed = 0;
+ CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(buffer, &needed, NULL, 0, NULL, NULL, 0, NULL);
+ AudioBufferList *list = malloc(needed);
+ CMBlockBufferRef block = NULL;
+ OSStatus status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(buffer, NULL, list, needed, NULL, NULL,
+ kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, &block);
+ if (status != noErr) { free(list); return; }
+ int channels = (int) asbd->mChannelsPerFrame;
+ BOOL planar = (asbd->mFormatFlags & kAudioFormatFlagIsNonInterleaved) != 0;
+ // Each frame's left and right: a mono source on both.
+ float (^sample)(CMItemCount, int) = ^float(CMItemCount i, int c) {
+ int channel = channels >= 2 ? c : 0;
+ if (planar) return ((float *) list->mBuffers[channel < (int) list->mNumberBuffers ? channel : 0].mData)[i];
+ return ((float *) list->mBuffers[0].mData)[i * channels + channel];
+ };
+ double ratio = asbd->mSampleRate > 0 ? AUDIO_RATE / asbd->mSampleRate : 1;
+ CMItemCount out_frames = (CMItemCount) (frames * ratio + 0.5);
+ NSMutableData *samples = [NSMutableData dataWithLength:(size_t) out_frames * 2 * sizeof(float)];
+ float *out = samples.mutableBytes;
+ for (CMItemCount i = 0; i < out_frames; i++) {
+ // Linearly between the source's frames, when its rate is not AUDIO_RATE.
+ double at = ratio == 1 ? (double) i : i / ratio;
+ CMItemCount a = (CMItemCount) at;
+ CMItemCount b = MIN(a + 1, frames - 1);
+ float t = (float) (at - (double) a);
+ for (int c = 0; c < 2; c++) out[i * 2 + c] = sample(MIN(a, frames - 1), c) * (1 - t) + sample(b, c) * t;
+ }
+ CFRelease(block);
+ free(list);
+ McapAudioPacket *packet = [McapAudioPacket new];
+ packet.samples = samples;
+ packet.hostTime = host_nanoseconds(CMSampleBufferGetPresentationTimeStamp(buffer));
+ [self.condition lock];
+ [queue addObject:packet];
+ [self.condition unlock];
+}
+
+@end
+
+static void configure_audio(SCStreamConfiguration *configuration, int system_audio, int microphone) {
+ configuration.capturesAudio = system_audio ? YES : NO;
+ configuration.sampleRate = AUDIO_RATE;
+ configuration.channelCount = 2;
+ configuration.excludesCurrentProcessAudio = YES;
+ if (macos_15()) configuration.captureMicrophone = microphone ? YES : NO;
+}
+
+int mcap_sck_microphone_supported(void) {
+ if (macos_15()) return 1;
+ return 0;
+}
+
+Mcap_Sck_Stream *mcap_sck_stream_start(uint32_t display_id, double x, double y, double w, double h,
+ int32_t width, int32_t height, int32_t fps, int show_cursor,
+ int system_audio, int microphone,
+ const uint32_t *excluded, int excluded_count,
+ char *error, int error_size) {
+ @autoreleasepool {
+ __block SCShareableContent *content = nil;
+ __block NSError *content_error = nil;
+ dispatch_semaphore_t got = dispatch_semaphore_create(0);
+ [SCShareableContent getShareableContentExcludingDesktopWindows:NO onScreenWindowsOnly:NO
+ completionHandler:^(SCShareableContent *c, NSError *e) {
+ content = c;
+ content_error = e;
+ dispatch_semaphore_signal(got);
+ }];
+ if (dispatch_semaphore_wait(got, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)) != 0 || !content) {
+ set_error(error, error_size, content_error ? content_error.localizedDescription : @"ScreenCaptureKit did not list the screens");
+ return NULL;
+ }
+ SCDisplay *display = nil;
+ for (SCDisplay *d in content.displays) if (d.displayID == display_id) display = d;
+ if (!display) { set_error(error, error_size, [NSString stringWithFormat:@"ScreenCaptureKit has no display %u", display_id]); return NULL; }
+ NSMutableArray *leave_out = [NSMutableArray array];
+ for (SCWindow *window in content.windows)
+ for (int i = 0; i < excluded_count; i++) if (window.windowID == excluded[i]) [leave_out addObject:window];
+ SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:display excludingWindows:leave_out];
+
+ SCStreamConfiguration *configuration = [SCStreamConfiguration new];
+ configuration.sourceRect = CGRectMake(x, y, w, h);
+ configuration.width = (size_t) width;
+ configuration.height = (size_t) height;
+ configuration.minimumFrameInterval = CMTimeMake(1, fps > 0 ? fps : 30);
+ configuration.pixelFormat = kCVPixelFormatType_32BGRA;
+ configuration.colorSpaceName = kCGColorSpaceSRGB;
+ configuration.showsCursor = show_cursor ? YES : NO;
+ configuration.queueDepth = 6;
+ configure_audio(configuration, system_audio, microphone);
+
+ McapStream *holder = [McapStream new];
+ holder.configuration = configuration;
+ holder.condition = [NSCondition new];
+ holder.width = width;
+ holder.height = height;
+ holder.frame = [NSMutableData dataWithLength:(size_t) width * (size_t) height * 4];
+ holder.system = [NSMutableArray array];
+ holder.microphone = [NSMutableArray array];
+ holder.queue = dispatch_queue_create("mcap.record", DISPATCH_QUEUE_SERIAL);
+ holder.stream = [[SCStream alloc] initWithFilter:filter configuration:configuration delegate:holder];
+ NSError *add_error = nil;
+ BOOL added = [holder.stream addStreamOutput:holder type:SCStreamOutputTypeScreen sampleHandlerQueue:holder.queue error:&add_error];
+ if (added) added = [holder.stream addStreamOutput:holder type:SCStreamOutputTypeAudio sampleHandlerQueue:holder.queue error:&add_error];
+ if (added) if (macos_15()) added = [holder.stream addStreamOutput:holder type:SCStreamOutputTypeMicrophone sampleHandlerQueue:holder.queue error:&add_error];
+ if (!added) { set_error(error, error_size, add_error.localizedDescription ?: @"ScreenCaptureKit would not take the stream's outputs"); return NULL; }
+
+ __block NSError *start_error = nil;
+ dispatch_semaphore_t started = dispatch_semaphore_create(0);
+ [holder.stream startCaptureWithCompletionHandler:^(NSError *e) { start_error = e; dispatch_semaphore_signal(started); }];
+ if (dispatch_semaphore_wait(started, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) != 0) {
+ set_error(error, error_size, @"ScreenCaptureKit did not start recording in time");
+ return NULL;
+ }
+ if (start_error) { set_error(error, error_size, start_error.localizedDescription); return NULL; }
+ return (__bridge_retained Mcap_Sck_Stream *) holder;
+ }
+}
+
+int mcap_sck_next_frame(Mcap_Sck_Stream *s, uint8_t *pixels, int64_t *host_time_ns, int timeout_ms, char *error, int error_size) {
+ McapStream *holder = (__bridge McapStream *) s;
+ [holder.condition lock];
+ NSDate *until = [NSDate dateWithTimeIntervalSinceNow:timeout_ms / 1000.0];
+ while (!holder.fresh && !holder.failure) if (![holder.condition waitUntilDate:until]) break;
+ int result = 0;
+ if (holder.failure) {
+ set_error(error, error_size, holder.failure);
+ result = -1;
+ } else if (holder.fresh) {
+ memcpy(pixels, holder.frame.bytes, holder.frame.length);
+ *host_time_ns = holder.frameTime;
+ holder.fresh = NO;
+ result = 1;
+ }
+ [holder.condition unlock];
+ return result;
+}
+
+int mcap_sck_next_audio(Mcap_Sck_Stream *s, int source, float *samples, int max_frames, int64_t *host_time_ns) {
+ McapStream *holder = (__bridge McapStream *) s;
+ NSMutableArray *queue = source == MCAP_SCK_MICROPHONE ? holder.microphone : holder.system;
+ [holder.condition lock];
+ int frames = 0;
+ if (queue.count) {
+ McapAudioPacket *packet = queue[0];
+ int available = (int) (packet.samples.length / (2 * sizeof(float)));
+ frames = MIN(available, max_frames);
+ memcpy(samples, packet.samples.bytes, (size_t) frames * 2 * sizeof(float));
+ *host_time_ns = packet.hostTime;
+ if (frames == available) [queue removeObjectAtIndex:0];
+ else { // the rest stays, its time moved on
+ packet.samples = [packet.samples subdataWithRange:NSMakeRange((size_t) frames * 2 * sizeof(float), packet.samples.length - (size_t) frames * 2 * sizeof(float))];
+ packet.hostTime += (int64_t) frames * 1000000000LL / AUDIO_RATE;
+ }
+ }
+ [holder.condition unlock];
+ return frames;
+}
+
+int mcap_sck_stream_set_audio(Mcap_Sck_Stream *s, int system_audio, int microphone, char *error, int error_size) {
+ @autoreleasepool {
+ McapStream *holder = (__bridge McapStream *) s;
+ if (microphone && !mcap_sck_microphone_supported()) { set_error(error, error_size, @"the microphone needs macOS 15 or later"); return 0; }
+ configure_audio(holder.configuration, system_audio, microphone);
+ __block NSError *update_error = nil;
+ dispatch_semaphore_t done = dispatch_semaphore_create(0);
+ [holder.stream updateConfiguration:holder.configuration completionHandler:^(NSError *e) { update_error = e; dispatch_semaphore_signal(done); }];
+ if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)) != 0) {
+ set_error(error, error_size, @"ScreenCaptureKit did not change the sound in time");
+ return 0;
+ }
+ if (update_error) { set_error(error, error_size, update_error.localizedDescription); return 0; }
+ // A source turned off keeps nothing queued.
+ [holder.condition lock];
+ if (!system_audio) [holder.system removeAllObjects];
+ if (!microphone) [holder.microphone removeAllObjects];
+ [holder.condition unlock];
+ return 1;
+ }
+}
+
+void mcap_sck_stream_stop(Mcap_Sck_Stream *s) {
+ if (!s) return;
+ McapStream *holder = (__bridge_transfer McapStream *) s;
+ @autoreleasepool {
+ dispatch_semaphore_t stopped = dispatch_semaphore_create(0);
+ [holder.stream stopCaptureWithCompletionHandler:^(NSError *e) { dispatch_semaphore_signal(stopped); }];
+ dispatch_semaphore_wait(stopped, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
+ [holder.stream removeStreamOutput:holder type:SCStreamOutputTypeScreen error:nil];
+ [holder.stream removeStreamOutput:holder type:SCStreamOutputTypeAudio error:nil];
+ if (macos_15()) [holder.stream removeStreamOutput:holder type:SCStreamOutputTypeMicrophone error:nil];
+ dispatch_sync(holder.queue, ^{}); // the last sample handler is done
+ }
+}
diff --git a/modules/Screen_Record/module.jai b/modules/Screen_Record/module.jai
index f2c00b7..b3b83ad 100644
--- a/modules/Screen_Record/module.jai
+++ b/modules/Screen_Record/module.jai
@@ -1,33 +1,14 @@
// Screen_Record: a region of one screen recorded to H.264 in an MP4, for the mcap app's screen
-// recording and tools/record_probe.jai (video-capture-plan.html). Windows only.
-//
-// Two threads. The capture thread takes the screen with DXGI Desktop Duplication (dxgi_source.jai),
-// or with Windows.Graphics.Capture (wgc_source.jai) where duplication cannot run, each on a D3D11
-// device of its own; copies the region on the GPU; reads it back through a ring of staging textures,
-// so Map never waits on the GPU (capture_gpu.jai); and, for duplication, draws the cursor in
-// (cursor.jai). The encoder thread hands the frames to FFmpeg (encoder.jai). Between them is a pool of
-// RECORD_BUFFERS frames; when the encoder falls RECORD_QUEUE frames behind, the oldest waiting is
-// dropped and counted.
-//
-// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
-// one a second while nothing does, so players can seek. Each carries the time it was taken, less the
-// time spent paused (record_pause), so a pause leaves no gap in the video.
-//
-// Sound (system_audio, microphone) has a thread of its own (audio_source.jai's WASAPI): each packet is
-// put on the same timeline as the frames, by when it was heard, less the same pauses, into one mix,
-// and an AAC frame of it is encoded once every source has had its say up to there. A source that sends
-// nothing (the computer playing nothing) is silence. Either source can be turned on and off while
-// recording (record_set_audio); with audio_track, the MP4 has a sound track even when no source is on
-// at the start, so one can be.
+// recording and tools/record_probe.jai (video-capture-plan.html, macos-plan phase 7). On Windows with
+// Desktop Duplication or Windows.Graphics.Capture and WASAPI (windows.jai); on macOS with
+// ScreenCaptureKit, its frames and sound alike, through modules/Screen_Record/macos/capture.m
+// (macos.jai). Either way to FFmpeg (encoder.jai), the sound mixed onto the video's timeline
+// (audio_mix.jai), and made a regular MP4 at the end (remux.jai).
//
// recorder: Recorder;
-// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
+// ok, error := record_start(*recorder, .{path = "out.mp4", ...});
// ...
// ok, error = record_stop(*recorder);
-//
-// With capture .AUTO (the default), Desktop Duplication is tried first and Windows.Graphics.Capture
-// takes over when it cannot run: on a laptop with two GPUs, on a rotated screen, or with too many
-// duplications of the screen running. fall_back is only returned for .DXGI, which does not fall back.
RECORD_MIN_WIDTH :: 240; // a smaller region is refused
RECORD_MIN_HEIGHT :: 180;
@@ -40,750 +21,15 @@ record_size_allowed :: (width: s64, height: s64) -> bool {
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
}
-Record_Screen :: struct {
- adapter_index, output_index: u32;
- adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
- name: string; // "\\.\DISPLAY1"
- desktop: RECT; // on the virtual desktop
- rotation: DXGI_MODE_ROTATION;
- monitor: HMONITOR;
-}
-
-Record_Capture :: enum u8 {
- AUTO; // Desktop Duplication, else Windows.Graphics.Capture
- DXGI; // Desktop Duplication only
- WGC; // Windows.Graphics.Capture only
-}
-
-// For record-probe: paths a machine may not take by itself.
-Record_Testing :: enum_flags u8 {
- DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED)
- WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler
- KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on
- LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
-}
-
-Record_Options :: struct {
- path: string; // the .mp4 written
- screen: s32 = -1; // into record_list_screens(); -1: the one holding region
- region: RECT; // on the virtual desktop; empty: the whole screen
- fps: s32 = 30;
- cursor := true;
- encoder: string; // "" tries RECORD_ENCODERS in turn
- capture: Record_Capture;
- legacy_duplicate: bool; // the plain DuplicateOutput, not DuplicateOutput1: for record-probe
- testing: Record_Testing;
- system_audio: bool; // what the computer plays (the default output device)
- microphone: bool; // the default input device; 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
-}
-
-Record_Stats :: struct {
- source: string; // "DXGI DuplicateOutput1 on ", "Windows.Graphics.Capture on , border off"
- fell_back_from: string; // why Desktop Duplication could not run, when it fell back
- encoder: string;
- width, height: s32;
- captured: s64; // new images of the screen
- pointer_moves: s64; // cursor changes with no new image (Desktop Duplication)
- 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; // times the duplication was lost and made again
- paused_seconds: float64; // left out of the video
- audio: string; // the sound recorded: "Speakers (Realtek(R) Audio); Microphone (USB Audio)", or ""
- audio_frames: s64; // AAC frames encoded
- audio_errors: string; // sources that could not be opened, or were lost
- copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
- bytes: s64; // encoded
- seconds: float64; // from start to stop
-}
-
-Recorder :: struct {
- options: Record_Options;
- screen: Record_Screen;
- local: RECT; // the region, in the screen's pixels
- dxgi: Dxgi_Source; // the capture thread's: one of these is open
- wgc: Wgc_Source;
- using_wgc: bool;
- encoder: Encoder;
- width, height: s32;
- frequency: s64; // QueryPerformanceCounter's
- start_ticks: s64;
-
- capture_thread, encoder_thread: Thread;
- capture_started, encoder_started, audio_started: bool;
- audio_thread: Thread;
- audio_opened: Semaphore; // the audio thread has opened its sources
- audio_go: Semaphore; // the encoder is open: the audio thread may write
- audio_sources: [2] Audio_Source;
- audio_open: bool; // any source opened, or audio_track: the MP4 has sound
- audio_on: [2] bool; // each source is wanted (by Audio_Source_Kind): record_set_audio; cleared when it cannot be opened
- audio_failed: [2] bool; // it could not be opened after being turned on: record_audio_failed says once
- zero_ticks: s64; // the recording's time 0, for frames and sound alike
- paused_ticks: s64; // time paused so far, for the capture and audio threads; set by record_pause
- pause_began: s64;
- running: bool;
- stopping: bool; // set by record_stop; the capture thread ends, then the encoder
- paused: bool; // set by record_pause; the capture thread takes nothing while it is
- capture_ended: bool;
-
- opened: Semaphore; // the capture thread has opened its source, or failed to
- open_ok, open_fall_back: bool;
- open_error: string;
-
- mutex: Mutex;
- ready: Semaphore; // signalled for each frame queued, and once when capture ends
- 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, BGRA, 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;
-}
-
-// Opens the screen, the encoder and the file, and starts recording.
-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);
- if r.options.fps <= 0 r.options.fps = 30;
-
- screens := record_list_screens();
- if !screens.count return false, "no screen to record", false;
- screen_index := options.screen;
- region := options.region;
- has_region := region.right > region.left && region.bottom > region.top;
- if screen_index < 0 {
- if !has_region screen_index = 0;
- else for screens if rect_inside(region, it.desktop) { screen_index = xx it_index; break; }
- if screen_index < 0 return false, "the region is not on one screen", false;
- }
- if screen_index >= screens.count return false, tprint("there is no screen %", screen_index), false;
- r.screen = screens[screen_index];
- r.screen.adapter = copy_string(r.screen.adapter);
- r.screen.name = copy_string(r.screen.name);
- if !has_region region = r.screen.desktop;
- if !rect_inside(region, r.screen.desktop) return false, "the region is not on that screen", false;
-
- // The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
- // RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
- #assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
- if !record_size_allowed(region.right - region.left, region.bottom - region.top)
- return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
- d := r.screen.desktop;
- r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
- r.local.right = r.local.left + ((r.local.right - r.local.left) & cast(s32) ~1);
- r.local.bottom = r.local.top + ((r.local.bottom - r.local.top) & cast(s32) ~1);
- r.width = r.local.right - r.local.left;
- r.height = r.local.bottom - r.local.top;
- r.stats.width, r.stats.height = r.width, r.height;
-
- init(*r.mutex, "record");
- init(*r.ready);
- init(*r.opened);
- 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.last_frame = NewArray(cast(s64) r.width * r.height * 4, u8);
- frequency: LARGE_INTEGER;
- QueryPerformanceFrequency(*frequency);
- r.frequency = frequency.QuadPart;
- r.start_ticks = ticks_now();
- r.zero_ticks = r.start_ticks;
-
- // The source is opened on the capture thread, which uses it: WinRT's objects belong to the
- // apartment they are made in.
- thread_init(*r.capture_thread, record_capture_proc, name = "record capture");
- r.capture_started = true;
- thread_start(*r.capture_thread, r);
- wait_for(*r.opened);
- if !r.open_ok {
- error, fall_back := copy_string(r.open_error,, temp), r.open_fall_back;
- record_free(r);
- return false, error, fall_back;
- }
-
- // The sound's sources are opened on their own thread (COM, for WASAPI), before the encoder, which
- // has a sound stream only when one opened.
- r.audio_on = .[r.options.system_audio, r.options.microphone];
- if r.options.system_audio || r.options.microphone || r.options.audio_track {
- init(*r.audio_opened);
- init(*r.audio_go);
- thread_init(*r.audio_thread, record_audio_proc, name = "record audio");
- r.audio_started = true;
- thread_start(*r.audio_thread, r);
- wait_for(*r.audio_opened);
- }
-
- 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);
- if r.audio_started signal(*r.audio_go);
-
- thread_init(*r.encoder_thread, record_encoder_proc, name = "record encoder");
- r.encoder_started = true;
- r.running = true;
- thread_start(*r.encoder_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. While paused nothing is taken, and the time is left out of the video, sound and
-// all: the time paused is kept here, once, for the capture and audio threads both.
-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)); // before paused clears, so no thread sees it unpaused with the old total
- atomic_swap(*r.paused, false);
- }
-}
-
-// Turns a source of sound on or off while recording. It is opened only while on, so Windows shows the
-// microphone in use only then. 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 opened when 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);
-}
-
-// As they are now; strings are the recorder's.
-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;
-}
-
-// Whether recording stopped by itself (the screen went away, the encoder failed); why, then.
-record_failed :: (r: *Recorder) -> bool, string {
- lock(*r.mutex);
- defer unlock(*r.mutex);
- return r.error.count > 0, r.error;
-}
-
-#scope_file
-
-Staged :: struct { pending: bool; ticks: s64; pointer: POINT; pointer_visible: bool; }
-
-// Stops the capture thread if it runs, and frees what record_start made. The stats' strings are kept.
-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 {
- signal(*r.audio_go); // in case it waits for an encoder that did not open
- thread_is_done(*r.audio_thread, -1);
- thread_deinit(*r.audio_thread);
- destroy(*r.audio_opened);
- destroy(*r.audio_go);
- r.audio_started = false;
- }
- if r.encoder.format encoder_close(*r.encoder);
- for * r.frames array_free(it.pixels);
- array_free(r.last_frame);
- r.last_frame = .[];
- destroy(*r.opened);
- destroy(*r.ready);
- destroy(*r.mutex);
- free(r.open_error);
- free(r.error);
- r.open_error, r.error = "", "";
-}
-
-// Desktop Duplication, unless asked for Windows.Graphics.Capture, which takes over when duplication
-// cannot run here and the options allow.
-record_open_source :: (r: *Recorder) -> bool {
- if r.options.capture != .WGC {
- ok, error, fall_back := dxgi_open(*r.dxgi, r.screen, r.local, r.options.legacy_duplicate);
- if ok && r.options.testing & .DXGI_UNSUPPORTED {
- ok, error, fall_back = false, "Desktop Duplication (testing): 0x887A0004 (DXGI_ERROR_UNSUPPORTED)", true;
- }
- if ok {
- r.stats.source = sprint("DXGI % on %", r.dxgi.method, r.screen.adapter);
- return true;
- }
- dxgi_close(*r.dxgi);
- if r.options.capture == .DXGI || !fall_back {
- r.open_error, r.open_fall_back = copy_string(error), fall_back;
- return false;
- }
- log("[record] Desktop Duplication cannot run here (%); Windows.Graphics.Capture instead.", error);
- r.stats.fell_back_from = copy_string(error);
- }
- ok, error := wgc_open(*r.wgc, r.screen, r.local, r.options.cursor, r.options.testing);
- if !ok {
- wgc_close(*r.wgc);
- r.open_error = copy_string(error);
- return false;
- }
- r.using_wgc = true;
- r.stats.source = sprint("Windows.Graphics.Capture on %, %%", r.screen.adapter, r.wgc.border, ifx r.wgc.polling then ", polled" else "");
- return true;
-}
-
-record_capture_proc :: (thread: *Thread) -> s64 {
- r := cast(*Recorder) thread.data;
- // Aware of each monitor's DPI, whatever the process is: Desktop Duplication refuses a thread that is
- // not (DXGI_ERROR_UNSUPPORTED), and the mcap app's ImGui makes only its main thread aware.
- gpu_thread_dpi_aware();
- wgc_thread_start();
- defer wgc_thread_end();
- r.open_ok = record_open_source(r);
- signal(*r.opened);
- if !r.open_ok return 0;
- gpu: *Capture_Gpu = ifx r.using_wgc then *r.wgc.gpu else *r.dxgi.gpu;
- defer if r.using_wgc wgc_close(*r.wgc); else dxgi_close(*r.dxgi);
-
- // A change is sent as soon as it comes once its turn is due: turns are a frame's time apart on a
- // schedule that moves on a frame at a time, so the rate never passes fps, and a tenth of a frame
- // early counts as on time, so a 60 Hz screen's frames, which jitter, all make a 60 fps cap.
- period := r.frequency / r.options.fps;
- early := period / 10;
- turn := ticks_now();
- last_emit: s64 = -r.frequency;
- have_image, changed := false, false;
- staged: [RECORD_STAGING] Staged;
- emits: s64;
- was_paused := false;
- last_lost := ticks_now(); // for Record_Testing.LOSE_DUPLICATION
-
- while !atomic_read(*r.stopping) {
- now := ticks_now();
- due := ifx changed then turn - early else last_emit + r.frequency; // the change, or the repeat
- wait := cast(u32) clamp((due - now) * 1000 / r.frequency, 0, 100);
- next: Capture_Next;
- error: string;
- if r.using_wgc next, error = wgc_next(*r.wgc, wait);
- else next, error = dxgi_next(*r.dxgi, wait);
- if r.options.testing & .LOSE_DUPLICATION && !r.using_wgc && ticks_now() - last_lost >= 3 * r.frequency {
- next, last_lost = Capture_Next.LOST, ticks_now();
- }
- if next == {
- case .FAILED;
- record_fail(r, error);
- break;
- case .LOST;
- // A mode change, the secure desktop, a game going fullscreen: duplicated again, and on.
- lock(*r.mutex); r.stats.lost += 1; unlock(*r.mutex);
- while !atomic_read(*r.stopping) {
- hr := dxgi_duplicate(*r.dxgi);
- if SUCCEEDED(hr) break;
- if !hresult_is(hr, DXGI_ERROR_ACCESS_LOST) && !hresult_is(hr, 0x80070005) && !hresult_is(hr, DXGI_ERROR_NOT_CURRENTLY_AVAILABLE) {
- record_fail(r, hresult_text("the screen could not be duplicated again", hr));
- break;
- }
- sleep_milliseconds(250);
- }
- if atomic_read(*r.stopping) || r.error break;
- continue;
- case .IMAGE; #through;
- case .IMAGE_AND_POINTER;
- have_image, changed = true, true;
- lock(*r.mutex);
- r.stats.captured += 1;
- r.stats.copy_ticks = gpu.copy_ticks;
- unlock(*r.mutex);
- case .POINTER;
- if r.options.cursor && have_image {
- changed = true;
- lock(*r.mutex); r.stats.pointer_moves += 1; unlock(*r.mutex);
- }
- }
-
- // Paused: the source is still drained, so it never stalls, but nothing is sent. On resuming, the
- // screen as it is now is sent at once.
- 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;
- 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); // after a quiet spell, no burst to catch up
-
- // Copied to staging now; read back two frames on, when the GPU is done with it.
- slot := emits % RECORD_STAGING;
- if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
- ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
- staged[slot] = .{pending = true, ticks = record_timeline(r, now)};
- if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
- emits += 1;
- last_emit = now;
- changed = false;
- lock(*r.mutex);
- r.stats.emitted += 1;
- if repeat r.stats.repeats += 1;
- unlock(*r.mutex);
- }
-
- // What is still staged, oldest first.
- for 0..RECORD_STAGING - 1 {
- slot := (emits + it) % RECORD_STAGING;
- if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
- }
- atomic_swap(*r.capture_ended, true);
- signal(*r.ready);
- return 0;
-}
-
-// Copies a staging texture into a free frame, draws the cursor (Desktop Duplication leaves it out)
-// and queues it.
-record_read_back :: (r: *Recorder, gpu: *Capture_Gpu, slot: s64, staged: Staged) {
- t := ticks_now();
- lock(*r.mutex);
- if !r.free_count { // every frame is waiting: drop the oldest
- 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];
-
- mapped: D3D11_MAPPED_SUBRESOURCE;
- hr := ID3D11DeviceContext_Map(gpu.device_context, gpu.staging[slot], 0, .READ, 0, *mapped);
- if FAILED(hr) {
- lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; unlock(*r.mutex);
- record_fail(r, hresult_text("reading the frame back", hr));
- return;
- }
- row := cast(s64) r.width * 4;
- for y: 0..r.height - 1 memcpy(frame.pixels.data + y * row, cast(*u8) mapped.pData + cast(s64) y * mapped.RowPitch, row);
- ID3D11DeviceContext_Unmap(gpu.device_context, gpu.staging[slot], 0);
- if r.options.cursor && staged.pointer_visible {
- cursor_draw(frame.pixels.data, r.width, r.height, *r.dxgi.shape,
- staged.pointer.x - cast(s32) gpu.box.left, staged.pointer.y - cast(s32) gpu.box.top);
- }
- frame.ticks = staged.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);
- r.stats.readback_ticks += ticks_now() - t;
- 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; // frame.ticks is on the timeline already
- t := ticks_now();
- ok, error := encoder_write(*r.encoder, frame.pixels.data, pts);
-
- lock(*r.mutex);
- r.free[r.free_count] = index;
- r.free_count += 1;
- r.stats.encode_ticks += ticks_now() - t;
- if ok r.stats.written += 1;
- r.stats.bytes = r.encoder.bytes;
- unlock(*r.mutex);
- if !ok {
- record_fail(r, error);
- // Frames still come until the capture thread sees it; they are let go.
- 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;
-}
-
-// The audio thread: opens the sources asked for, waits for the encoder, then every 10 ms takes their
-// packets into the mix and encodes what every source has covered.
-record_audio_proc :: (thread: *Thread) -> s64 {
- r := cast(*Recorder) thread.data;
- audio_thread_start();
- defer audio_thread_end();
-
- kinds: [2] Audio_Source_Kind = .[.SYSTEM, .MICROPHONE];
- wanted: [2] bool = .[r.options.system_audio, r.options.microphone];
- names, errors: [..] string; // every source heard, and every failure, for the stats
- names.allocator, errors.allocator = context.default_allocator, context.default_allocator;
- defer { for names free(it); array_free(names); for errors free(it); array_free(errors); }
- note :: (list: *[..] string, text: string, field: *string) #expand {
- for list.* if it == text return;
- array_add(list, copy_string(text));
- lock(*`r.mutex);
- free(field.*);
- field.* = join(..list.*, "; ");
- unlock(*`r.mutex);
- }
- for 0..1 {
- if !wanted[it] continue;
- s := *r.audio_sources[it];
- ok, error := audio_open(s, kinds[it]);
- what := ifx kinds[it] == .SYSTEM then "system sound" else "microphone";
- if ok { r.audio_open = true; note(*names, s.name, *r.stats.audio); }
- else { audio_close(s); note(*errors, tprint("%: %", what, error), *r.stats.audio_errors); log_error("[record] No %: %", what, error); }
- }
- for r.audio_sources if !it.open atomic_swap(*r.audio_on[it_index], false);
- if r.options.audio_track r.audio_open = true;
- signal(*r.audio_opened);
- wait_for(*r.audio_go);
- if !r.audio_open || !r.encoder.audio || atomic_read(*r.stopping) {
- for * r.audio_sources audio_free(it);
- return 0;
- }
-
- mix: Audio_Mix;
- mix.frame_size = r.encoder.audio_frame_size;
- defer array_free(mix.samples);
- while true {
- stopping := atomic_read(*r.stopping);
- // Each source, turned on or off from the toolbar: opened only while on.
- for * source, index: r.audio_sources {
- on := atomic_read(*r.audio_on[index]) && !stopping;
- what := ifx kinds[index] == .SYSTEM then "System sound" else "Microphone";
- if on && !source.open && !wanted[index] {
- ok, error := audio_open(source, kinds[index]);
- if ok {
- log("[record] % on: %.", what, source.name);
- note(*names, source.name, *r.stats.audio);
- mix.started[index] = false;
- } else {
- audio_close(source);
- log_error("[record] % could not be opened: %", what, error);
- note(*errors, tprint("%: %", to_lower_copy(what,, temp), error), *r.stats.audio_errors);
- atomic_swap(*r.audio_on[index], false);
- atomic_swap(*r.audio_failed[index], true);
- }
- } else if !on && source.open {
- log("[record] % off.", what);
- audio_close(source);
- source.lost_at = 0;
- }
- wanted[index] = source.open || (wanted[index] && on);
- }
-
- for * source, index: r.audio_sources {
- if !wanted[index] continue;
- if !source.open { // lost: opened again a second on, from the default device then
- if source.lost_at && ticks_now() - source.lost_at >= r.frequency && !stopping {
- if audio_open(source, kinds[index]) log("[record] % is back: %.", ifx kinds[index] == .SYSTEM then "System sound" else "The microphone", source.name);
- else { audio_close(source); source.lost_at = ticks_now(); }
- }
- continue;
- }
- packets, lost := audio_take(source, r.frequency);
- for packets if !atomic_read(*r.paused) audio_mix_add(*mix, r, index, it);
- if lost {
- log_error("[record] % was lost (unplugged, or the default device changed); trying again.", source.name);
- audio_close(source);
- source.lost_at = ticks_now();
- }
- }
- 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);
- }
- for * r.audio_sources audio_free(it);
- return 0;
-}
-
-// The sound so far, from its base: one AAC frame's worth is encoded at a time.
-Audio_Mix :: struct {
- samples: [..] float; // interleaved stereo, from base on
- base: s64; // the frame (in samples, on the timeline) samples[0] is
- next: [2] s64; // each source's next frame, so packets that follow on are not moved by jitter
- started: [2] bool;
- frame_size: s32;
-}
-
-AUDIO_SLACK :: AUDIO_RATE / 5; // 200 ms: how late a source's packets may come and still be in the mix
-AUDIO_FOLLOW_ON :: AUDIO_RATE / 33; // 30 ms: a packet that close to where its source left off follows on
-
-// A packet into the mix at its place on the timeline, added to what the other source put there.
-audio_mix_add :: (mix: *Audio_Mix, r: *Recorder, source: s64, packet: Audio_Packet) {
- at := record_timeline(r, packet.ticks) * AUDIO_RATE / r.frequency;
- if mix.started[source] && abs(at - mix.next[source]) <= AUDIO_FOLLOW_ON at = mix.next[source];
- mix.started[source] = true;
- mix.next[source] = at + packet.frames;
- end := (at + packet.frames - mix.base) * AUDIO_CHANNELS;
- if end > mix.samples.count {
- old := mix.samples.count;
- array_resize(*mix.samples, end, initialize = false);
- for old..end - 1 mix.samples[it] = 0;
- }
- if packet.silent || !packet.samples.count return;
- for frame: max(at, mix.base)..at + packet.frames - 1 {
- into := (frame - mix.base) * AUDIO_CHANNELS;
- from := (frame - at) * AUDIO_CHANNELS;
- mix.samples[into] += packet.samples[from];
- mix.samples[into + 1] += packet.samples[from + 1];
- }
-}
-
-// Encodes the frames every open source has covered: up to where each has sent, or, for one that has
-// sent nothing lately (silence), to AUDIO_SLACK ago. final: everything sent.
-audio_mix_encode :: (mix: *Audio_Mix, r: *Recorder, final: bool) -> ok: bool, error: string {
- // Paused, the timeline stands still where the pause began.
- now := ifx atomic_read(*r.paused) then atomic_read(*r.pause_began) else ticks_now();
- now_frame := record_timeline(r, now) * AUDIO_RATE / r.frequency;
- ready := now_frame - AUDIO_SLACK;
- for source, index: r.audio_sources {
- if !source.open continue;
- covered := max(mix.next[index], now_frame - AUDIO_SLACK);
- ready = min(ready, covered);
- }
- if final { ready = mix.base; for mix.next ready = max(ready, it); }
-
- size := mix.frame_size;
- frame := NewArray(size * AUDIO_CHANNELS, float, initialized = false,, temp);
- done: s64;
- while mix.base + size <= ready {
- for i: 0..frame.count - 1 {
- at := done * AUDIO_CHANNELS + i;
- value := ifx at < mix.samples.count then mix.samples[at] else 0.0;
- frame[i] = clamp(value, -1.0, 1.0);
- }
- ok, error := encoder_write_audio(*r.encoder, frame.data, mix.base);
- if !ok return false, error;
- mix.base += size;
- done += size;
- lock(*r.mutex); r.stats.audio_frames += 1; unlock(*r.mutex);
- }
- // What was encoded leaves the front.
- gone := min(done * AUDIO_CHANNELS, mix.samples.count);
- if gone {
- memcpy(mix.samples.data, mix.samples.data + gone, (mix.samples.count - gone) * size_of(float));
- mix.samples.count -= gone;
- }
- return true, "";
-}
-
-// Ends the recording from one of its threads; the first reason is kept.
-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);
-}
-
-rect_inside :: (inner: RECT, outer: RECT) -> bool {
- return inner.left >= outer.left && inner.top >= outer.top && inner.right <= outer.right && inner.bottom <= outer.bottom;
-}
-
-#scope_export
-
-#load "capture_gpu.jai";
-#load "dxgi_source.jai";
-#load "wgc_source.jai";
+#if OS == .WINDOWS #load "windows.jai";
+#if OS == .MACOS #load "macos.jai";
+#load "audio_mix.jai";
#load "encoder.jai";
#load "remux.jai";
-#load "audio_source.jai";
-#load "cursor.jai";
#import "Basic";
#import "String";
#import "Atomics";
#import "Math";
#import "Thread";
-#import "Windows";
-#import "Windows_Utf8";
-#import "dxgi";
-#import "d3d11";
#import "ffmpeg";
diff --git a/modules/Screen_Record/windows.jai b/modules/Screen_Record/windows.jai
new file mode 100644
index 0000000..4e24f6e
--- /dev/null
+++ b/modules/Screen_Record/windows.jai
@@ -0,0 +1,701 @@
+// The Windows recorder (module.jai has what is shared): moved here from module.jai unchanged.
+//
+// A region of one screen recorded to H.264 in an MP4, for the mcap app's screen
+// recording and tools/record_probe.jai (video-capture-plan.html). Windows only.
+//
+// Two threads. The capture thread takes the screen with DXGI Desktop Duplication (dxgi_source.jai),
+// or with Windows.Graphics.Capture (wgc_source.jai) where duplication cannot run, each on a D3D11
+// device of its own; copies the region on the GPU; reads it back through a ring of staging textures,
+// so Map never waits on the GPU (capture_gpu.jai); and, for duplication, draws the cursor in
+// (cursor.jai). The encoder thread hands the frames to FFmpeg (encoder.jai). Between them is a pool of
+// RECORD_BUFFERS frames; when the encoder falls RECORD_QUEUE frames behind, the oldest waiting is
+// dropped and counted.
+//
+// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
+// one a second while nothing does, so players can seek. Each carries the time it was taken, less the
+// time spent paused (record_pause), so a pause leaves no gap in the video.
+//
+// Sound (system_audio, microphone) has a thread of its own (audio_source.jai's WASAPI): each packet is
+// put on the same timeline as the frames, by when it was heard, less the same pauses, into one mix,
+// and an AAC frame of it is encoded once every source has had its say up to there. A source that sends
+// nothing (the computer playing nothing) is silence. Either source can be turned on and off while
+// recording (record_set_audio); with audio_track, the MP4 has a sound track even when no source is on
+// at the start, so one can be.
+//
+// recorder: Recorder;
+// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
+// ...
+// ok, error = record_stop(*recorder);
+//
+// With capture .AUTO (the default), Desktop Duplication is tried first and Windows.Graphics.Capture
+// takes over when it cannot run: on a laptop with two GPUs, on a rotated screen, or with too many
+// duplications of the screen running. fall_back is only returned for .DXGI, which does not fall back.
+
+Record_Screen :: struct {
+ adapter_index, output_index: u32;
+ adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
+ name: string; // "\\.\DISPLAY1"
+ desktop: RECT; // on the virtual desktop
+ rotation: DXGI_MODE_ROTATION;
+ monitor: HMONITOR;
+}
+
+Record_Capture :: enum u8 {
+ AUTO; // Desktop Duplication, else Windows.Graphics.Capture
+ DXGI; // Desktop Duplication only
+ WGC; // Windows.Graphics.Capture only
+}
+
+// For record-probe: paths a machine may not take by itself.
+Record_Testing :: enum_flags u8 {
+ DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED)
+ WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler
+ KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on
+ LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
+}
+
+Record_Options :: struct {
+ path: string; // the .mp4 written
+ screen: s32 = -1; // into record_list_screens(); -1: the one holding region
+ region: RECT; // on the virtual desktop; empty: the whole screen
+ fps: s32 = 30;
+ cursor := true;
+ encoder: string; // "" tries RECORD_ENCODERS in turn
+ capture: Record_Capture;
+ legacy_duplicate: bool; // the plain DuplicateOutput, not DuplicateOutput1: for record-probe
+ testing: Record_Testing;
+ system_audio: bool; // what the computer plays (the default output device)
+ microphone: bool; // the default input device; 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
+}
+
+Record_Stats :: struct {
+ source: string; // "DXGI DuplicateOutput1 on ", "Windows.Graphics.Capture on , border off"
+ fell_back_from: string; // why Desktop Duplication could not run, when it fell back
+ encoder: string;
+ width, height: s32;
+ captured: s64; // new images of the screen
+ pointer_moves: s64; // cursor changes with no new image (Desktop Duplication)
+ 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; // times the duplication was lost and made again
+ paused_seconds: float64; // left out of the video
+ audio: string; // the sound recorded: "Speakers (Realtek(R) Audio); Microphone (USB Audio)", or ""
+ audio_frames: s64; // AAC frames encoded
+ audio_errors: string; // sources that could not be opened, or were lost
+ copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
+ bytes: s64; // encoded
+ seconds: float64; // from start to stop
+}
+
+Recorder :: struct {
+ options: Record_Options;
+ screen: Record_Screen;
+ local: RECT; // the region, in the screen's pixels
+ dxgi: Dxgi_Source; // the capture thread's: one of these is open
+ wgc: Wgc_Source;
+ using_wgc: bool;
+ encoder: Encoder;
+ width, height: s32;
+ frequency: s64; // QueryPerformanceCounter's
+ start_ticks: s64;
+
+ capture_thread, encoder_thread: Thread;
+ capture_started, encoder_started, audio_started: bool;
+ audio_thread: Thread;
+ audio_opened: Semaphore; // the audio thread has opened its sources
+ audio_go: Semaphore; // the encoder is open: the audio thread may write
+ audio_sources: [2] Audio_Source;
+ audio_open: bool; // any source opened, or audio_track: the MP4 has sound
+ audio_on: [2] bool; // each source is wanted (by Audio_Source_Kind): record_set_audio; cleared when it cannot be opened
+ audio_failed: [2] bool; // it could not be opened after being turned on: record_audio_failed says once
+ zero_ticks: s64; // the recording's time 0, for frames and sound alike
+ paused_ticks: s64; // time paused so far, for the capture and audio threads; set by record_pause
+ pause_began: s64;
+ running: bool;
+ stopping: bool; // set by record_stop; the capture thread ends, then the encoder
+ paused: bool; // set by record_pause; the capture thread takes nothing while it is
+ capture_ended: bool;
+
+ opened: Semaphore; // the capture thread has opened its source, or failed to
+ open_ok, open_fall_back: bool;
+ open_error: string;
+
+ mutex: Mutex;
+ ready: Semaphore; // signalled for each frame queued, and once when capture ends
+ 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, BGRA, 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;
+}
+
+// Opens the screen, the encoder and the file, and starts recording.
+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);
+ if r.options.fps <= 0 r.options.fps = 30;
+
+ screens := record_list_screens();
+ if !screens.count return false, "no screen to record", false;
+ screen_index := options.screen;
+ region := options.region;
+ has_region := region.right > region.left && region.bottom > region.top;
+ if screen_index < 0 {
+ if !has_region screen_index = 0;
+ else for screens if rect_inside(region, it.desktop) { screen_index = xx it_index; break; }
+ if screen_index < 0 return false, "the region is not on one screen", false;
+ }
+ if screen_index >= screens.count return false, tprint("there is no screen %", screen_index), false;
+ r.screen = screens[screen_index];
+ r.screen.adapter = copy_string(r.screen.adapter);
+ r.screen.name = copy_string(r.screen.name);
+ if !has_region region = r.screen.desktop;
+ if !rect_inside(region, r.screen.desktop) return false, "the region is not on that screen", false;
+
+ // The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
+ // RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
+ #assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
+ if !record_size_allowed(region.right - region.left, region.bottom - region.top)
+ return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
+ d := r.screen.desktop;
+ r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
+ r.local.right = r.local.left + ((r.local.right - r.local.left) & cast(s32) ~1);
+ r.local.bottom = r.local.top + ((r.local.bottom - r.local.top) & cast(s32) ~1);
+ r.width = r.local.right - r.local.left;
+ r.height = r.local.bottom - r.local.top;
+ r.stats.width, r.stats.height = r.width, r.height;
+
+ init(*r.mutex, "record");
+ init(*r.ready);
+ init(*r.opened);
+ 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.last_frame = NewArray(cast(s64) r.width * r.height * 4, u8);
+ frequency: LARGE_INTEGER;
+ QueryPerformanceFrequency(*frequency);
+ r.frequency = frequency.QuadPart;
+ r.start_ticks = ticks_now();
+ r.zero_ticks = r.start_ticks;
+
+ // The source is opened on the capture thread, which uses it: WinRT's objects belong to the
+ // apartment they are made in.
+ thread_init(*r.capture_thread, record_capture_proc, name = "record capture");
+ r.capture_started = true;
+ thread_start(*r.capture_thread, r);
+ wait_for(*r.opened);
+ if !r.open_ok {
+ error, fall_back := copy_string(r.open_error,, temp), r.open_fall_back;
+ record_free(r);
+ return false, error, fall_back;
+ }
+
+ // The sound's sources are opened on their own thread (COM, for WASAPI), before the encoder, which
+ // has a sound stream only when one opened.
+ r.audio_on = .[r.options.system_audio, r.options.microphone];
+ if r.options.system_audio || r.options.microphone || r.options.audio_track {
+ init(*r.audio_opened);
+ init(*r.audio_go);
+ thread_init(*r.audio_thread, record_audio_proc, name = "record audio");
+ r.audio_started = true;
+ thread_start(*r.audio_thread, r);
+ wait_for(*r.audio_opened);
+ }
+
+ 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);
+ if r.audio_started signal(*r.audio_go);
+
+ thread_init(*r.encoder_thread, record_encoder_proc, name = "record encoder");
+ r.encoder_started = true;
+ r.running = true;
+ thread_start(*r.encoder_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. While paused nothing is taken, and the time is left out of the video, sound and
+// all: the time paused is kept here, once, for the capture and audio threads both.
+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)); // before paused clears, so no thread sees it unpaused with the old total
+ atomic_swap(*r.paused, false);
+ }
+}
+
+// Turns a source of sound on or off while recording. It is opened only while on, so Windows shows the
+// microphone in use only then. 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 opened when 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);
+}
+
+// As they are now; strings are the recorder's.
+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;
+}
+
+// Whether recording stopped by itself (the screen went away, the encoder failed); why, then.
+record_failed :: (r: *Recorder) -> bool, string {
+ lock(*r.mutex);
+ defer unlock(*r.mutex);
+ return r.error.count > 0, r.error;
+}
+
+#scope_file
+
+Staged :: struct { pending: bool; ticks: s64; pointer: POINT; pointer_visible: bool; }
+
+// Stops the capture thread if it runs, and frees what record_start made. The stats' strings are kept.
+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 {
+ signal(*r.audio_go); // in case it waits for an encoder that did not open
+ thread_is_done(*r.audio_thread, -1);
+ thread_deinit(*r.audio_thread);
+ destroy(*r.audio_opened);
+ destroy(*r.audio_go);
+ r.audio_started = false;
+ }
+ if r.encoder.format encoder_close(*r.encoder);
+ for * r.frames array_free(it.pixels);
+ array_free(r.last_frame);
+ r.last_frame = .[];
+ destroy(*r.opened);
+ destroy(*r.ready);
+ destroy(*r.mutex);
+ free(r.open_error);
+ free(r.error);
+ r.open_error, r.error = "", "";
+}
+
+// Desktop Duplication, unless asked for Windows.Graphics.Capture, which takes over when duplication
+// cannot run here and the options allow.
+record_open_source :: (r: *Recorder) -> bool {
+ if r.options.capture != .WGC {
+ ok, error, fall_back := dxgi_open(*r.dxgi, r.screen, r.local, r.options.legacy_duplicate);
+ if ok && r.options.testing & .DXGI_UNSUPPORTED {
+ ok, error, fall_back = false, "Desktop Duplication (testing): 0x887A0004 (DXGI_ERROR_UNSUPPORTED)", true;
+ }
+ if ok {
+ r.stats.source = sprint("DXGI % on %", r.dxgi.method, r.screen.adapter);
+ return true;
+ }
+ dxgi_close(*r.dxgi);
+ if r.options.capture == .DXGI || !fall_back {
+ r.open_error, r.open_fall_back = copy_string(error), fall_back;
+ return false;
+ }
+ log("[record] Desktop Duplication cannot run here (%); Windows.Graphics.Capture instead.", error);
+ r.stats.fell_back_from = copy_string(error);
+ }
+ ok, error := wgc_open(*r.wgc, r.screen, r.local, r.options.cursor, r.options.testing);
+ if !ok {
+ wgc_close(*r.wgc);
+ r.open_error = copy_string(error);
+ return false;
+ }
+ r.using_wgc = true;
+ r.stats.source = sprint("Windows.Graphics.Capture on %, %%", r.screen.adapter, r.wgc.border, ifx r.wgc.polling then ", polled" else "");
+ return true;
+}
+
+record_capture_proc :: (thread: *Thread) -> s64 {
+ r := cast(*Recorder) thread.data;
+ // Aware of each monitor's DPI, whatever the process is: Desktop Duplication refuses a thread that is
+ // not (DXGI_ERROR_UNSUPPORTED), and the mcap app's ImGui makes only its main thread aware.
+ gpu_thread_dpi_aware();
+ wgc_thread_start();
+ defer wgc_thread_end();
+ r.open_ok = record_open_source(r);
+ signal(*r.opened);
+ if !r.open_ok return 0;
+ gpu: *Capture_Gpu = ifx r.using_wgc then *r.wgc.gpu else *r.dxgi.gpu;
+ defer if r.using_wgc wgc_close(*r.wgc); else dxgi_close(*r.dxgi);
+
+ // A change is sent as soon as it comes once its turn is due: turns are a frame's time apart on a
+ // schedule that moves on a frame at a time, so the rate never passes fps, and a tenth of a frame
+ // early counts as on time, so a 60 Hz screen's frames, which jitter, all make a 60 fps cap.
+ period := r.frequency / r.options.fps;
+ early := period / 10;
+ turn := ticks_now();
+ last_emit: s64 = -r.frequency;
+ have_image, changed := false, false;
+ staged: [RECORD_STAGING] Staged;
+ emits: s64;
+ was_paused := false;
+ last_lost := ticks_now(); // for Record_Testing.LOSE_DUPLICATION
+
+ while !atomic_read(*r.stopping) {
+ now := ticks_now();
+ due := ifx changed then turn - early else last_emit + r.frequency; // the change, or the repeat
+ wait := cast(u32) clamp((due - now) * 1000 / r.frequency, 0, 100);
+ next: Capture_Next;
+ error: string;
+ if r.using_wgc next, error = wgc_next(*r.wgc, wait);
+ else next, error = dxgi_next(*r.dxgi, wait);
+ if r.options.testing & .LOSE_DUPLICATION && !r.using_wgc && ticks_now() - last_lost >= 3 * r.frequency {
+ next, last_lost = Capture_Next.LOST, ticks_now();
+ }
+ if next == {
+ case .FAILED;
+ record_fail(r, error);
+ break;
+ case .LOST;
+ // A mode change, the secure desktop, a game going fullscreen: duplicated again, and on.
+ lock(*r.mutex); r.stats.lost += 1; unlock(*r.mutex);
+ while !atomic_read(*r.stopping) {
+ hr := dxgi_duplicate(*r.dxgi);
+ if SUCCEEDED(hr) break;
+ if !hresult_is(hr, DXGI_ERROR_ACCESS_LOST) && !hresult_is(hr, 0x80070005) && !hresult_is(hr, DXGI_ERROR_NOT_CURRENTLY_AVAILABLE) {
+ record_fail(r, hresult_text("the screen could not be duplicated again", hr));
+ break;
+ }
+ sleep_milliseconds(250);
+ }
+ if atomic_read(*r.stopping) || r.error break;
+ continue;
+ case .IMAGE; #through;
+ case .IMAGE_AND_POINTER;
+ have_image, changed = true, true;
+ lock(*r.mutex);
+ r.stats.captured += 1;
+ r.stats.copy_ticks = gpu.copy_ticks;
+ unlock(*r.mutex);
+ case .POINTER;
+ if r.options.cursor && have_image {
+ changed = true;
+ lock(*r.mutex); r.stats.pointer_moves += 1; unlock(*r.mutex);
+ }
+ }
+
+ // Paused: the source is still drained, so it never stalls, but nothing is sent. On resuming, the
+ // screen as it is now is sent at once.
+ 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;
+ 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); // after a quiet spell, no burst to catch up
+
+ // Copied to staging now; read back two frames on, when the GPU is done with it.
+ slot := emits % RECORD_STAGING;
+ if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
+ ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
+ staged[slot] = .{pending = true, ticks = record_timeline(r, now)};
+ if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
+ emits += 1;
+ last_emit = now;
+ changed = false;
+ lock(*r.mutex);
+ r.stats.emitted += 1;
+ if repeat r.stats.repeats += 1;
+ unlock(*r.mutex);
+ }
+
+ // What is still staged, oldest first.
+ for 0..RECORD_STAGING - 1 {
+ slot := (emits + it) % RECORD_STAGING;
+ if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
+ }
+ atomic_swap(*r.capture_ended, true);
+ signal(*r.ready);
+ return 0;
+}
+
+// Copies a staging texture into a free frame, draws the cursor (Desktop Duplication leaves it out)
+// and queues it.
+record_read_back :: (r: *Recorder, gpu: *Capture_Gpu, slot: s64, staged: Staged) {
+ t := ticks_now();
+ lock(*r.mutex);
+ if !r.free_count { // every frame is waiting: drop the oldest
+ 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];
+
+ mapped: D3D11_MAPPED_SUBRESOURCE;
+ hr := ID3D11DeviceContext_Map(gpu.device_context, gpu.staging[slot], 0, .READ, 0, *mapped);
+ if FAILED(hr) {
+ lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; unlock(*r.mutex);
+ record_fail(r, hresult_text("reading the frame back", hr));
+ return;
+ }
+ row := cast(s64) r.width * 4;
+ for y: 0..r.height - 1 memcpy(frame.pixels.data + y * row, cast(*u8) mapped.pData + cast(s64) y * mapped.RowPitch, row);
+ ID3D11DeviceContext_Unmap(gpu.device_context, gpu.staging[slot], 0);
+ if r.options.cursor && staged.pointer_visible {
+ cursor_draw(frame.pixels.data, r.width, r.height, *r.dxgi.shape,
+ staged.pointer.x - cast(s32) gpu.box.left, staged.pointer.y - cast(s32) gpu.box.top);
+ }
+ frame.ticks = staged.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);
+ r.stats.readback_ticks += ticks_now() - t;
+ 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; // frame.ticks is on the timeline already
+ t := ticks_now();
+ ok, error := encoder_write(*r.encoder, frame.pixels.data, pts);
+
+ lock(*r.mutex);
+ r.free[r.free_count] = index;
+ r.free_count += 1;
+ r.stats.encode_ticks += ticks_now() - t;
+ if ok r.stats.written += 1;
+ r.stats.bytes = r.encoder.bytes;
+ unlock(*r.mutex);
+ if !ok {
+ record_fail(r, error);
+ // Frames still come until the capture thread sees it; they are let go.
+ 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;
+}
+
+// The audio thread: opens the sources asked for, waits for the encoder, then every 10 ms takes their
+// packets into the mix and encodes what every source has covered.
+record_audio_proc :: (thread: *Thread) -> s64 {
+ r := cast(*Recorder) thread.data;
+ audio_thread_start();
+ defer audio_thread_end();
+
+ kinds: [2] Audio_Source_Kind = .[.SYSTEM, .MICROPHONE];
+ wanted: [2] bool = .[r.options.system_audio, r.options.microphone];
+ names, errors: [..] string; // every source heard, and every failure, for the stats
+ names.allocator, errors.allocator = context.default_allocator, context.default_allocator;
+ defer { for names free(it); array_free(names); for errors free(it); array_free(errors); }
+ note :: (list: *[..] string, text: string, field: *string) #expand {
+ for list.* if it == text return;
+ array_add(list, copy_string(text));
+ lock(*`r.mutex);
+ free(field.*);
+ field.* = join(..list.*, "; ");
+ unlock(*`r.mutex);
+ }
+ for 0..1 {
+ if !wanted[it] continue;
+ s := *r.audio_sources[it];
+ ok, error := audio_open(s, kinds[it]);
+ what := ifx kinds[it] == .SYSTEM then "system sound" else "microphone";
+ if ok { r.audio_open = true; note(*names, s.name, *r.stats.audio); }
+ else { audio_close(s); note(*errors, tprint("%: %", what, error), *r.stats.audio_errors); log_error("[record] No %: %", what, error); }
+ }
+ for r.audio_sources if !it.open atomic_swap(*r.audio_on[it_index], false);
+ if r.options.audio_track r.audio_open = true;
+ signal(*r.audio_opened);
+ wait_for(*r.audio_go);
+ if !r.audio_open || !r.encoder.audio || atomic_read(*r.stopping) {
+ for * r.audio_sources audio_free(it);
+ return 0;
+ }
+
+ mix: Audio_Mix;
+ mix.frame_size = r.encoder.audio_frame_size;
+ defer array_free(mix.samples);
+ while true {
+ stopping := atomic_read(*r.stopping);
+ // Each source, turned on or off from the toolbar: opened only while on.
+ for * source, index: r.audio_sources {
+ on := atomic_read(*r.audio_on[index]) && !stopping;
+ what := ifx kinds[index] == .SYSTEM then "System sound" else "Microphone";
+ if on && !source.open && !wanted[index] {
+ ok, error := audio_open(source, kinds[index]);
+ if ok {
+ log("[record] % on: %.", what, source.name);
+ note(*names, source.name, *r.stats.audio);
+ mix.started[index] = false;
+ } else {
+ audio_close(source);
+ log_error("[record] % could not be opened: %", what, error);
+ note(*errors, tprint("%: %", to_lower_copy(what,, temp), error), *r.stats.audio_errors);
+ atomic_swap(*r.audio_on[index], false);
+ atomic_swap(*r.audio_failed[index], true);
+ }
+ } else if !on && source.open {
+ log("[record] % off.", what);
+ audio_close(source);
+ source.lost_at = 0;
+ }
+ wanted[index] = source.open || (wanted[index] && on);
+ }
+
+ for * source, index: r.audio_sources {
+ if !wanted[index] continue;
+ if !source.open { // lost: opened again a second on, from the default device then
+ if source.lost_at && ticks_now() - source.lost_at >= r.frequency && !stopping {
+ if audio_open(source, kinds[index]) log("[record] % is back: %.", ifx kinds[index] == .SYSTEM then "System sound" else "The microphone", source.name);
+ else { audio_close(source); source.lost_at = ticks_now(); }
+ }
+ continue;
+ }
+ packets, lost := audio_take(source, r.frequency);
+ for packets if !atomic_read(*r.paused) audio_mix_add(*mix, r, index, it);
+ if lost {
+ log_error("[record] % was lost (unplugged, or the default device changed); trying again.", source.name);
+ audio_close(source);
+ source.lost_at = ticks_now();
+ }
+ }
+ 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);
+ }
+ for * r.audio_sources audio_free(it);
+ return 0;
+}
+
+// Ends the recording from one of its threads; the first reason is kept.
+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);
+}
+
+rect_inside :: (inner: RECT, outer: RECT) -> bool {
+ return inner.left >= outer.left && inner.top >= outer.top && inner.right <= outer.right && inner.bottom <= outer.bottom;
+}
+
+#scope_export
+
+#load "capture_gpu.jai";
+#load "dxgi_source.jai";
+#load "wgc_source.jai";
+#load "audio_source.jai";
+#load "cursor.jai";
+
+#import "Windows";
+#import "Windows_Utf8";
+#import "dxgi";
+#import "d3d11";
diff --git a/src/App.jai b/src/App.jai
index c9acea8..01476a2 100644
--- a/src/App.jai
+++ b/src/App.jai
@@ -33,6 +33,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
#if OS == .MACOS #load "app/screen_capture_macos.jai";
#if OS == .WINDOWS #load "app/screen_record_win32.jai";
+#if OS == .MACOS #load "app/screen_record_macos.jai";
#load "base/circle_buffer.jai";
#load "base/debug.jai";
@@ -79,7 +80,7 @@ Shared_Channel :: #import "Shared_Memory_Channel";
#import "Capture_Text";
#import "Base64";
#import "onnxruntime";
-#if OS == .WINDOWS #import "Screen_Record"; // screen recording: Desktop Duplication or Windows.Graphics.Capture, to NVENC
+#if OS == .WINDOWS || OS == .MACOS #import "Screen_Record"; // screen recording: Desktop Duplication or Windows.Graphics.Capture, to NVENC
// Basic's arenas import Secure_Memory at its defaults; this import adds its test suite.
using Secure_Memory :: #import "Secure_Memory"()(LOAD_TESTS=COMPILE_TESTS);
diff --git a/src/app/screen_capture_macos.jai b/src/app/screen_capture_macos.jai
index 9f4248e..dd6255d 100644
--- a/src/app/screen_capture_macos.jai
+++ b/src/app/screen_capture_macos.jai
@@ -7,6 +7,11 @@
// selection's size, so a region can be chosen to the pixel; the arrow keys move the cursor a pixel at
// a time (ten with Shift). Escape or a right click cancels; Return takes what is dragged.
//
+// For a recording (video), the outline and magnifier are red, and a region smaller than
+// RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT is outlined dashed, with the minimum in the label, and letting
+// go of it takes nothing: the overlay stays to drag again. No pixels are copied out for a recording,
+// which app/screen_record.jai has ScreenCaptureKit stream from the display and region the shot names.
+//
// A drag stays on the screen it began on: its pixels are that screen's, two to a point on a Retina
// screen. Everything is worked out in each screen's own pixels, which the cursor's position in points
// is turned into.
@@ -30,6 +35,13 @@ Screen_Shot :: struct {
window_title: string; // the window under the region's middle; heap, as are the two below
app_name: string; // its app ("Safari")
app_path: string; // its app's bundle, or executable
+
+ // The display it is on, and the region in that display's points from its top left: what a
+ // recording streams (modules/Screen_Record).
+ display_id: u32;
+ scale: float64; // its pixels a point
+ local_x, local_y, local_width, local_height: float64;
+ screen_left, screen_top, screen_width, screen_height: float64; // the display, in points from the main screen's top left
}
screen_shot_free :: (shot: *Screen_Shot) {
@@ -41,12 +53,11 @@ screen_shot_free :: (shot: *Screen_Shot) {
shot.* = .{};
}
-// Freezes the screens and lets a region be chosen. With immediate, no overlay: the whole screen under
-// the cursor is taken at once. ok is false when it was cancelled (error "") or could not start (error
-// says why). Recording (video) is not on macOS yet.
+// Freezes the screens and lets a region be chosen: for a still, or with video for a recording. With
+// immediate, no overlay: the whole screen under the cursor is taken at once. ok is false when it was
+// cancelled (error "") or could not start (error says why).
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
shot: Screen_Shot;
- if video return shot, false, "screen recording is not on macOS yet";
init_objective_c();
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
@@ -64,6 +75,7 @@ platform_screen_capture_select :: (video := false, immediate := false) -> Screen
ok, error := capture_freeze();
defer capture_release();
if !ok return shot, false, error;
+ capture.video = video;
if immediate {
x, y := capture_cursor_points();
@@ -85,11 +97,18 @@ platform_screen_capture_select :: (video := false, immediate := false) -> Screen
shot.left = cast(s32) (screen.left + r.left / screen.scale);
shot.top = cast(s32) (screen.top + r.top / screen.scale);
shot.whole_screen = capture.whole_screen;
- shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
- row_bytes := cast(s64) shot.width * 4;
- for y: 0..shot.height - 1 {
- from := screen.pixels + (cast(s64) (r.top + y) * screen.pixel_width + r.left) * 4;
- memcpy(shot.pixels.data + y * row_bytes, from, row_bytes);
+ shot.display_id = screen.display_id;
+ shot.scale = screen.scale;
+ shot.screen_left, shot.screen_top, shot.screen_width, shot.screen_height = screen.left, screen.top, screen.width, screen.height;
+ shot.local_x, shot.local_y = r.left / screen.scale, r.top / screen.scale;
+ shot.local_width, shot.local_height = (r.right - r.left) / screen.scale, (r.bottom - r.top) / screen.scale;
+ if !video {
+ shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
+ row_bytes := cast(s64) shot.width * 4;
+ for y: 0..shot.height - 1 {
+ from := screen.pixels + (cast(s64) (r.top + y) * screen.pixel_width + r.left) * 4;
+ memcpy(shot.pixels.data + y * row_bytes, from, row_bytes);
+ }
}
// The window under the region's middle, now that the overlay is gone; else the app that was in front.
@@ -121,6 +140,7 @@ Pixel_Rect :: struct { left, top, right, bottom: s32; }
capture: struct {
screens: [..] Capture_Screen;
+ video: bool; // choosing a region to record
cursor_screen: s64; // the screen the cursor is on, and its pixel there
cursor: Pixel;
dragging: bool;
@@ -141,6 +161,9 @@ CLICK_SLOP :: 3; // a drag smaller than this either way, in pixels, is
DIM :: 0.5; // how dark the frozen screens are
ACCENT :: Vector4.{1, 0.647, 0, 1}; // orange, as on Windows
+RECORD :: Vector4.{0.91, 0.157, 0.157, 1}; // red: choosing a region to record
+DASH : float64 : 6; // a too-small region's dashes and gaps, in points
+GAP : float64 : 4;
LABEL :: Vector4.{0.125, 0.125, 0.125, 0.92};
TEXT :: Vector4.{1, 1, 1, 1};
@@ -358,6 +381,11 @@ capture_take :: () {
if capture.dragging {
selection := capture_selection();
if selection.right - selection.left >= CLICK_SLOP || selection.bottom - selection.top >= CLICK_SLOP {
+ if capture.video && !record_size_allowed(selection.right - selection.left, selection.bottom - selection.top) {
+ capture.dragging = false; // too small to record: nothing taken, the overlay stays
+ capture_update();
+ return;
+ }
capture.result_screen = capture.anchor_screen;
capture.result = selection;
capture.dragging = false;
@@ -432,14 +460,24 @@ magnifier_layout :: (screen: *Capture_Screen) -> magnifier: CGRect, label: CGRec
label_text :: (screen: *Capture_Screen) -> string {
if capture.dragging {
s := capture_selection();
- return tprint("% × %", s.right - s.left, s.bottom - s.top);
+ text := tprint("% × %", s.right - s.left, s.bottom - s.top);
+ if capture_too_small(s) text = tprint("% · at least % × %", text, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT);
+ return text;
}
return tprint("%, %", capture.cursor.x, capture.cursor.y);
}
+// To record, smaller than the minimum (and not a click, which takes the whole screen).
+capture_too_small :: (s: Pixel_Rect) -> bool {
+ w, h := s.right - s.left, s.bottom - s.top;
+ if !capture.video || (w < CLICK_SLOP && h < CLICK_SLOP) return false;
+ return !record_size_allowed(w, h);
+}
+
// drawRect: for screen index's view, in its points (flipped: y goes down).
capture_draw :: (index: s64, cg: CGContextRef) {
screen := *capture.screens[index];
+ accent := ifx capture.video then RECORD else ACCENT;
whole := CGRect.{.{0, 0}, .{screen.width, screen.height}};
draw_image(cg, screen.image, whole);
CGContextSetRGBFillColor(cg, 0, 0, 0, DIM);
@@ -455,18 +493,35 @@ capture_draw :: (index: s64, cg: CGContextRef) {
CGContextClipToRect(cg, s);
draw_image(cg, screen.image, whole);
CGContextRestoreGState(cg);
- // The outline just outside it, so every pixel taken shows as it is.
- fill(cg, .{.{s.origin.x - BORDER, s.origin.y - BORDER}, .{s.size.width + 2 * BORDER, BORDER}}, ACCENT);
- fill(cg, .{.{s.origin.x - BORDER, s.origin.y + s.size.height}, .{s.size.width + 2 * BORDER, BORDER}}, ACCENT);
- fill(cg, .{.{s.origin.x - BORDER, s.origin.y}, .{BORDER, s.size.height}}, ACCENT);
- fill(cg, .{.{s.origin.x + s.size.width, s.origin.y}, .{BORDER, s.size.height}}, ACCENT);
+ // The outline just outside it, so every pixel taken shows as it is; dashed when too small to record.
+ dashed := capture_too_small(capture_selection());
+ outline := CGRect.[
+ .{.{s.origin.x - BORDER, s.origin.y - BORDER}, .{s.size.width + 2 * BORDER, BORDER}},
+ .{.{s.origin.x - BORDER, s.origin.y + s.size.height}, .{s.size.width + 2 * BORDER, BORDER}},
+ .{.{s.origin.x - BORDER, s.origin.y}, .{BORDER, s.size.height}},
+ .{.{s.origin.x + s.size.width, s.origin.y}, .{BORDER, s.size.height}},
+ ];
+ for outline {
+ if !dashed { fill(cg, it, accent); continue; }
+ across := it.size.width > it.size.height;
+ start := ifx across then it.origin.x else it.origin.y;
+ end := start + (ifx across then it.size.width else it.size.height);
+ position := start;
+ while position < end {
+ piece := it;
+ if across { piece.origin.x = position; piece.size.width = min(DASH, end - position); }
+ else { piece.origin.y = position; piece.size.height = min(DASH, end - position); }
+ fill(cg, piece, accent);
+ position += DASH + GAP;
+ }
+ }
}
if index != capture.cursor_screen return;
pixel := 1 / screen.scale;
cx, cy := capture.cursor.x * pixel, capture.cursor.y * pixel;
if !capture.dragging {
- fill(cg, .{.{0, cy}, .{screen.width, pixel}}, ACCENT);
- fill(cg, .{.{cx, 0}, .{pixel, screen.height}}, ACCENT);
+ fill(cg, .{.{0, cy}, .{screen.width, pixel}}, accent);
+ fill(cg, .{.{cx, 0}, .{pixel, screen.height}}, accent);
}
// The magnifier: the pixels around the cursor, as copied, each MAGNIFIER_ZOOM points wide; past
@@ -491,10 +546,10 @@ capture_draw :: (index: s64, cg: CGContextRef) {
}
// The pixel under the cursor, outlined.
c := CGRect.{.{inner.origin.x + half * MAGNIFIER_ZOOM, inner.origin.y + half * MAGNIFIER_ZOOM}, .{MAGNIFIER_ZOOM, MAGNIFIER_ZOOM}};
- fill(cg, .{.{c.origin.x - 1, c.origin.y - 1}, .{c.size.width + 2, 1}}, ACCENT);
- fill(cg, .{.{c.origin.x - 1, c.origin.y + c.size.height}, .{c.size.width + 2, 1}}, ACCENT);
- fill(cg, .{.{c.origin.x - 1, c.origin.y}, .{1, c.size.height}}, ACCENT);
- fill(cg, .{.{c.origin.x + c.size.width, c.origin.y}, .{1, c.size.height}}, ACCENT);
+ fill(cg, .{.{c.origin.x - 1, c.origin.y - 1}, .{c.size.width + 2, 1}}, accent);
+ fill(cg, .{.{c.origin.x - 1, c.origin.y + c.size.height}, .{c.size.width + 2, 1}}, accent);
+ fill(cg, .{.{c.origin.x - 1, c.origin.y}, .{1, c.size.height}}, accent);
+ fill(cg, .{.{c.origin.x + c.size.width, c.origin.y}, .{1, c.size.height}}, accent);
// Under it, the position, or the selection's size.
fill(cg, label, LABEL);
diff --git a/src/app/screen_record.jai b/src/app/screen_record.jai
index 0e6525d..5524998 100644
--- a/src/app/screen_record.jai
+++ b/src/app/screen_record.jai
@@ -1,6 +1,7 @@
-// Screen recording (video-capture-plan.html): Ctrl+PrintScreen (the record_region global hotkey, from
-// any app, even with mcap hidden in the tray) freezes the screens as PrintScreen does, in the
-// overlay's video mode (app/screen_capture_win32.jai): a region of one screen, at least 240 x 180, or
+// 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
@@ -8,7 +9,8 @@
// 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); both are kept out of the recording.
+// 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
@@ -19,7 +21,7 @@
// 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 only, as modules/Screen_Record is.
+// 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
@@ -32,7 +34,7 @@ screen_record: struct {
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 recorder: Recorder;
+ #if OS == .WINDOWS || OS == .MACOS recorder: Recorder;
}
// From app_frame when the hotkey fired, outside the ImGui frame: starts recording, or stops.
@@ -44,8 +46,9 @@ screen_record_toggle :: () {
// 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 {
+ #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);
@@ -122,7 +125,7 @@ screen_record_shutdown :: () {
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
screen_record_startup :: () {
- #if OS == .WINDOWS {
+ #if OS == .WINDOWS || OS == .MACOS {
screen_record.leftovers.allocator = context.default_allocator;
for file_list(screen_record_folder(),, temp) {
name := path_filename(it);
@@ -193,17 +196,19 @@ screen_record_folder :: () -> string {
return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
}
-#if OS == .WINDOWS {
+#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);
- 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 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); }
@@ -220,18 +225,28 @@ screen_record_start :: (immediate := false) {
options: Record_Options;
options.path = path;
- options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
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 app_user_settings.video_capture == {
- case "dxgi"; options.capture = .DXGI;
- case "wgc"; options.capture = .WGC;
+ #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);
@@ -253,7 +268,7 @@ screen_record_start :: (immediate := false) {
} else {
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
}
- record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
+ #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.
@@ -411,13 +426,13 @@ screen_record_hotkey_text :: () -> string {
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 Windows only");
+ 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 Windows only";
+ return "", "screen recording is on Windows and macOS only";
}
}
diff --git a/src/app/screen_record_macos.jai b/src/app/screen_record_macos.jai
new file mode 100644
index 0000000..7880f3f
--- /dev/null
+++ b/src/app/screen_record_macos.jai
@@ -0,0 +1,365 @@
+// While recording (app/screen_record.jai), on macOS: a thin red border just outside the region, and a
+// toolbar beside it with the time, the size, frames dropped, and System sound, Mic, Pause, Stop and
+// Discard, as app/screen_record_win32.jai has on Windows. Both are non-activating panels above other
+// windows, on every Space and over full-screen apps, so they work while mcap is hidden in the menu
+// bar and a click on the toolbar leaves the window being recorded in front. Both are kept out of the
+// recording: their window numbers go to ScreenCaptureKit's filter (record_ui_open returns them), so
+// they are made before the recording starts. The border is outside the region anyway.
+//
+// The border lets clicks through. A toolbar button only asks (record_ui.asked); screen_record_frame
+// does it. Discard asks first, in the toolbar itself. Hover and the time are kept up from
+// screen_record_frame (record_ui_tick), as the panels get no events while the mouse only passes.
+
+Record_UI_Ask :: enum u8 { NONE; SYSTEM_SOUND; MICROPHONE; PAUSE; STOP; DISCARD; }
+
+record_ui: struct {
+ border, toolbar: *NSWindow;
+ toolbar_view: *NSView;
+ toolbar_frame: CGRect; // in points from the main screen's top left, for the hover
+ asked: Record_UI_Ask; // a button pressed, for screen_record_frame
+ confirming: bool; // Discard pressed once: "Discard it? Yes No"
+ hovered: s32 = -1; // the button under the mouse
+ buttons: [5] Record_UI_Button;
+ button_count: s32;
+ started: Apollo_Time;
+ drawn: Apollo_Time; // the toolbar last drawn
+ size_text: string; // "1920 × 1080"; heap
+}
+
+Record_UI_Button :: struct { rect: CGRect; label: string; ask: Record_UI_Ask; confirm: bool; }
+
+// Shows the border around the shot's region and the toolbar beside it; their window numbers (temp),
+// to keep them out of the recording. width and height: what is recorded, in pixels.
+record_ui_open :: (shot: Screen_Shot, width: s32, height: s32) -> [] u32 {
+ record_ui_close();
+ init_objective_c();
+ init_core_graphics_runtime_constants();
+ record_ui.started = current_time_monotonic();
+ record_ui.drawn = record_ui.started;
+ record_ui.asked = .NONE;
+ record_ui.confirming = false;
+ record_ui.hovered = -1;
+ free(record_ui.size_text,, context.default_allocator);
+ record_ui.size_text = sprint("% × %", width, height,, context.default_allocator);
+
+ region := CGRect.{.{shot.screen_left + shot.local_x, shot.screen_top + shot.local_y}, .{shot.local_width, shot.local_height}};
+ screen := CGRect.{.{shot.screen_left, shot.screen_top}, .{shot.screen_width, shot.screen_height}};
+
+ // Below the region, at its right; else above; else inside its top right (kept out of the recording).
+ toolbar_width, toolbar_height := record_toolbar_size();
+ x := region.origin.x + region.size.width - toolbar_width;
+ y := region.origin.y + region.size.height + BORDER + GAP;
+ if y + toolbar_height > screen.origin.y + screen.size.height y = region.origin.y - BORDER - GAP - toolbar_height;
+ if y < screen.origin.y || shot.whole_screen {
+ y = region.origin.y + GAP;
+ x = region.origin.x + region.size.width - toolbar_width - GAP;
+ }
+ x = clamp(x, screen.origin.x, screen.origin.x + screen.size.width - toolbar_width);
+ record_ui.toolbar_frame = .{.{x, y}, .{toolbar_width, toolbar_height}};
+ record_ui.toolbar, record_ui.toolbar_view = record_ui_panel(record_ui.toolbar_frame, TOOLBAR, clicks = true);
+
+ // The border: BORDER points wide just outside the region. Not for a whole screen, where it would be
+ // on the screen beside it.
+ if !shot.whole_screen {
+ outer := CGRect.{.{region.origin.x - BORDER, region.origin.y - BORDER}, .{region.size.width + 2 * BORDER, region.size.height + 2 * BORDER}};
+ record_ui.border = record_ui_panel(outer, BORDER_VIEW, clicks = false);
+ }
+
+ numbers: [..] u32;
+ numbers.allocator = temp;
+ if array_find(app.args, "-record-ui-visible") return numbers; // to look at them in a recording
+ for window: *NSWindow.[record_ui.toolbar, record_ui.border] if window {
+ func :: #type (*void, Selector) -> s64 #c_call;
+ array_add(*numbers, cast(u32) (cast(func) objc_msgSend)(window, selector("windowNumber")));
+ }
+ return numbers;
+}
+
+record_ui_close :: () {
+ for window: *NSWindow.[record_ui.toolbar, record_ui.border] if window {
+ NSWindow.orderOut(window, null);
+ NSWindow.close(window);
+ release(window);
+ }
+ record_ui.toolbar, record_ui.border, record_ui.toolbar_view = null, null, null;
+}
+
+// From another thread (the shutdown's): hidden on the main thread, the only one AppKit takes.
+record_ui_hide :: () {
+ hide :: (data: *void) #c_call {
+ new_context: #Context;
+ push_context new_context {
+ for window: *NSWindow.[record_ui.toolbar, record_ui.border] if window NSWindow.orderOut(window, null);
+ }
+ }
+ dispatch_async_f(*_dispatch_main_q, null, hide);
+}
+
+// The button pressed since the last call, once.
+record_ui_take_ask :: () -> Record_UI_Ask {
+ ask := record_ui.asked;
+ record_ui.asked = .NONE;
+ return ask;
+}
+
+record_ui_refresh :: () {
+ set_needs_display :: #type (*void, Selector, BOOL) -> void #c_call;
+ send := cast(set_needs_display) objc_msgSend;
+ if record_ui.toolbar_view send(record_ui.toolbar_view, selector("setNeedsDisplay:"), YES);
+}
+
+// Each frame while recording: the button under the mouse lit, and the time moved on four times a second.
+record_ui_tick :: () {
+ if !record_ui.toolbar_view return;
+ location := NSEvent.mouseLocation();
+ main_height := main_screen_height();
+ f := record_ui.toolbar_frame;
+ point := Vector2.{xx (location.x - f.origin.x), xx (main_height - location.y - f.origin.y)};
+ hovered := record_toolbar_hit(point);
+ now := current_time_monotonic();
+ if hovered != record_ui.hovered || to_float64_seconds(now - record_ui.drawn) >= 0.25 {
+ record_ui.hovered = hovered;
+ record_ui.drawn = now;
+ record_ui_refresh();
+ }
+}
+
+#scope_file
+
+BORDER : float64 : 3.0; // points
+GAP : float64 : 6.0;
+TOOLBAR :: 1; // the view's kind, in its extra bytes
+BORDER_VIEW :: 2;
+
+RED :: Vector4.{0.91, 0.157, 0.157, 1}; // as the overlay's video mode
+DIM_RED:: Vector4.{0.38, 0.125, 0.125, 1};
+GREY :: Vector4.{0.565, 0.565, 0.565, 1};
+AMBER :: Vector4.{1, 0.706, 0, 1};
+BACK :: Vector4.{0.125, 0.125, 0.125, 0.96};
+BUTTON :: Vector4.{0.22, 0.22, 0.22, 1};
+HOVER :: Vector4.{0.314, 0.314, 0.314, 1};
+TEXT :: Vector4.{0.94, 0.94, 0.94, 1};
+
+PADDING : float64 : 10.0;
+SPACE : float64 : 8.0;
+HEIGHT : float64 : 34.0;
+DOT : float64 : 12.0;
+
+main_screen_height :: () -> float64 {
+ screens := objc_msgSend_typed(objc_getClass("NSScreen"), selector("screens"));
+ func_at :: #type (*void, Selector, u64) -> *void #c_call;
+ first := (cast(func_at) objc_msgSend)(screens, selector("objectAtIndex:"), 0);
+ func_frame :: #type (*void, Selector) -> NSRect #c_call;
+ return (cast(func_frame) objc_msgSend)(first, selector("frame")).size.height;
+}
+
+// A borderless, non-activating panel at frame (points from the main screen's top left) above other
+// windows, its content a McapRecordView of kind; letting clicks through unless clicks.
+record_ui_panel :: (frame: CGRect, kind: s64, clicks: bool) -> *NSWindow, *NSView {
+ view_class := record_view_class();
+ if !view_class return null, null;
+ main_height := main_screen_height();
+ NSWindowStyleMaskNonactivatingPanel :: 1 << 7;
+ window := NSWindow.initWithContentRect(cast(*NSWindow) objc_alloc(objc_getClass("NSPanel")),
+ NSMakeRect(frame.origin.x, main_height - frame.origin.y - frame.size.height, frame.size.width, frame.size.height),
+ NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel, NSBackingStoreBuffered, NO);
+ if !window return null, null;
+ set_bool :: (object: *void, name: string, value: bool) {
+ func :: #type (*void, Selector, BOOL) -> void #c_call;
+ (cast(func) objc_msgSend)(object, selector(name), ifx value then YES else NO);
+ }
+ NSWindow.setReleasedWhenClosed(window, NO);
+ set_bool(window, "setOpaque:", false);
+ set_bool(window, "setHasShadow:", kind == TOOLBAR);
+ set_bool(window, "setHidesOnDeactivate:", false);
+ set_bool(window, "setIgnoresMouseEvents:", !clicks);
+ set_bool(window, "setBecomesKeyOnlyIfNeeded:", true);
+ NSWindow.setBackgroundColor(window, objc_msgSend_typed(objc_getClass("NSColor"), selector("clearColor")));
+ NSStatusWindowLevel :: 25;
+ NSWindow.setLevel(window, NSStatusWindowLevel);
+ NSWindow.setCollectionBehavior(window, NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorStationary |
+ NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorIgnoresCycle);
+ func_init :: #type (*void, Selector, NSRect) -> *NSView #c_call;
+ view := (cast(func_init) objc_msgSend)(objc_alloc(view_class), selector("initWithFrame:"), NSMakeRect(0, 0, frame.size.width, frame.size.height));
+ (cast(*s64) object_getIndexedIvars(view)).* = kind;
+ NSWindow.setContentView(window, view);
+ release(view);
+ objc_msgSend_typed(window, selector("orderFrontRegardless"));
+ return window, view;
+}
+
+// McapRecordView: draws the border or the toolbar (its kind in its extra bytes), y going down, and
+// takes the toolbar's clicks without making mcap active.
+record_view_class :: () -> Class {
+ if view_class return view_class;
+ view_class = objc_getClass("McapRecordView");
+ if view_class return view_class;
+ view_class = objc_allocateClassPair(objc_getClass("NSView"), "McapRecordView", size_of(s64));
+ if !view_class return null;
+ draw_rect :: (self: id, _sel: Selector, dirty: NSRect) #c_call {
+ push_context record_context {
+ kind := (cast(*s64) object_getIndexedIvars(self)).*;
+ context_object := objc_msgSend_typed(objc_getClass("NSGraphicsContext"), selector("currentContext"));
+ cg := ifx context_object then cast(*void) objc_msgSend_typed(context_object, selector("CGContext")) else null;
+ if !cg return;
+ func_bounds :: #type (*void, Selector) -> NSRect #c_call;
+ bounds := (cast(func_bounds) objc_msgSend)(self, selector("bounds"));
+ if kind == BORDER_VIEW draw_border(cg, bounds.size.width, bounds.size.height);
+ else draw_toolbar(cg, bounds.size.width, bounds.size.height);
+ }
+ }
+ mouse_down :: (self: id, _sel: Selector, event: *NSEvent) #c_call {
+ push_context record_context {
+ func_location :: #type (*void, Selector) -> NSPoint #c_call;
+ location := (cast(func_location) objc_msgSend)(event, selector("locationInWindow"));
+ height := record_ui.toolbar_frame.size.height;
+ hit := record_toolbar_hit(.{xx location.x, xx (height - location.y)});
+ if hit >= 0 {
+ button := record_ui.buttons[hit];
+ if button.confirm record_ui.confirming = !record_ui.confirming;
+ else if button.ask != .NONE { record_ui.asked = button.ask; record_ui.confirming = false; }
+ record_ui_refresh();
+ }
+ }
+ }
+ yes :: (self: id, _sel: Selector) -> BOOL #c_call { return YES; }
+ yes_for_event :: (self: id, _sel: Selector, event: *NSEvent) -> BOOL #c_call { return YES; }
+ class_addMethod(view_class, selector("drawRect:"), xx draw_rect, "v@:{CGRect={CGPoint=dd}{CGSize=dd}}");
+ class_addMethod(view_class, selector("mouseDown:"), xx mouse_down, "v@:@");
+ class_addMethod(view_class, selector("isFlipped"), xx yes, "c@:");
+ class_addMethod(view_class, selector("acceptsFirstMouse:"), xx yes_for_event, "c@:@");
+ objc_registerClassPair(view_class);
+ record_context = context;
+ return view_class;
+}
+view_class: Class;
+record_context: #Context; // AppKit calls the view on the main thread, during SDL's event pump
+
+fill :: (cg: *void, r: CGRect, color: Vector4) {
+ CGContextSetRGBFillColor(cg, color.x, color.y, color.z, color.w);
+ CGContextFillRect(cg, r);
+}
+
+draw_border :: (cg: *void, width: float64, height: float64) {
+ fill(cg, .{.{0, 0}, .{width, BORDER}}, RED);
+ fill(cg, .{.{0, height - BORDER}, .{width, BORDER}}, RED);
+ fill(cg, .{.{0, BORDER}, .{BORDER, height - 2 * BORDER}}, RED);
+ fill(cg, .{.{width - BORDER, BORDER}, .{BORDER, height - 2 * BORDER}}, RED);
+}
+
+draw_toolbar :: (cg: *void, width: float64, height: float64) {
+ fill(cg, .{.{0, 0}, .{width, height}}, BACK);
+ paused := record_paused(*screen_record.recorder);
+
+ // The dot: red while recording, blinking each half second; grey while paused.
+ blink := (cast(s64) (to_float64_seconds(current_time_monotonic() - record_ui.started) * 2)) % 2 == 0;
+ dot := ifx paused then GREY else ifx blink then RED else DIM_RED;
+ CGContextSetRGBFillColor(cg, dot.x, dot.y, dot.z, dot.w);
+ CGContextFillEllipseInRect(cg, .{.{PADDING, (height - DOT) / 2}, .{DOT, DOT}});
+
+ status, dropped := record_toolbar_texts();
+ x := PADDING + DOT + SPACE;
+ draw_text(status, x, height, TEXT);
+ x += text_size(status).width + SPACE;
+ if dropped draw_text(dropped, x, height, AMBER);
+
+ // The buttons, from the right.
+ buttons := record_toolbar_buttons();
+ button_height := height - 10;
+ right := width - PADDING;
+ for < buttons {
+ label_width := text_size(it.label).width;
+ r := CGRect.{.{right - label_width - 2 * PADDING, (height - button_height) / 2}, .{label_width + 2 * PADDING, button_height}};
+ record_ui.buttons[it_index].rect = r;
+ lit := (it.ask == .SYSTEM_SOUND && record_audio_on(*screen_record.recorder, .SYSTEM)) || (it.ask == .MICROPHONE && record_audio_on(*screen_record.recorder, .MICROPHONE));
+ fill(cg, r, ifx lit then RED else ifx it_index == record_ui.hovered then HOVER else BUTTON);
+ draw_text(it.label, r.origin.x + PADDING, height, ifx it.ask == .DISCARD then AMBER else TEXT);
+ right = r.origin.x - SPACE;
+ }
+ if record_ui.confirming draw_text("Discard it?", right - text_size("Discard it?").width, height, AMBER);
+}
+
+// What the toolbar shows now, left to right: the status, then what went wrong.
+record_toolbar_texts :: () -> status: string, dropped: string {
+ stats := record_stats(*screen_record.recorder);
+ paused := record_paused(*screen_record.recorder);
+ seconds := max(to_float64_seconds(current_time_monotonic() - record_ui.started) - stats.paused_seconds, 0.0);
+ whole := cast(s64) seconds;
+ two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); }
+ time := ifx whole >= 3600 then tprint("%:%:%", whole / 3600, two(whole / 60 % 60), two(whole % 60)) else tprint("%:%", two(whole / 60), two(whole % 60));
+ status := tprint("% % %", ifx paused then "PAUSED" else "REC", time, record_ui.size_text);
+ dropped := ifx stats.dropped then tprint("% dropped", stats.dropped) else "";
+ return status, dropped;
+}
+
+record_toolbar_buttons :: () -> [] Record_UI_Button {
+ if record_ui.confirming {
+ record_ui.buttons[0] = .{label = "Yes, discard", ask = .DISCARD};
+ record_ui.buttons[1] = .{label = "No", confirm = true};
+ record_ui.button_count = 2;
+ } else {
+ recorder := *screen_record.recorder;
+ record_ui.buttons[0] = .{label = ifx record_audio_on(recorder, .SYSTEM) then "System sound: on" else "System sound: off", ask = .SYSTEM_SOUND};
+ record_ui.buttons[1] = .{label = ifx record_audio_on(recorder, .MICROPHONE) then "Mic: on" else "Mic: off", ask = .MICROPHONE};
+ record_ui.buttons[2] = .{label = ifx record_paused(recorder) then "Resume" else "Pause", ask = .PAUSE};
+ record_ui.buttons[3] = .{label = "Stop", ask = .STOP};
+ record_ui.buttons[4] = .{label = "Discard", confirm = true};
+ record_ui.button_count = 5;
+ }
+ return array_view(record_ui.buttons, 0, record_ui.button_count);
+}
+
+// The toolbar's size, for the longest it is likely to be.
+record_toolbar_size :: () -> width: float64, height: float64 {
+ status_width := text_size(tprint("PAUSED 00:00:00 %", record_ui.size_text)).width;
+ dropped_width := text_size("999 dropped").width;
+ buttons_width: float64 = 4 * SPACE + 5 * 2 * PADDING;
+ for string.["System sound: off", "Mic: off", "Resume", "Stop", "Yes, discard"] buttons_width += text_size(it).width;
+ return PADDING + DOT + SPACE + status_width + SPACE + dropped_width + SPACE + buttons_width + PADDING, HEIGHT;
+}
+
+record_toolbar_hit :: (p: Vector2) -> s32 {
+ for array_view(record_ui.buttons, 0, record_ui.button_count) {
+ r := it.rect;
+ if p.x >= r.origin.x && p.x < r.origin.x + r.size.width && p.y >= r.origin.y && p.y < r.origin.y + r.size.height return cast(s32) it_index;
+ }
+ return -1;
+}
+
+text_attributes :: (color: Vector4) -> *NSObject {
+ func_font :: #type (*void, Selector, CGFloat, CGFloat) -> *NSObject #c_call;
+ font := (cast(func_font) objc_msgSend)(objc_getClass("NSFont"), selector("monospacedDigitSystemFontOfSize:weight:"), 13, 0.3);
+ func_color :: #type (*void, Selector, CGFloat, CGFloat, CGFloat, CGFloat) -> *NSObject #c_call;
+ ns_color := (cast(func_color) objc_msgSend)(objc_getClass("NSColor"), selector("colorWithSRGBRed:green:blue:alpha:"), color.x, color.y, color.z, color.w);
+ keys := *void.[NSFontAttributeName, NSForegroundColorAttributeName];
+ values := *void.[font, ns_color];
+ func_dictionary :: #type (*void, Selector, **void, **void, NSUInteger) -> *NSObject #c_call;
+ return (cast(func_dictionary) objc_msgSend)(objc_getClass("NSDictionary"), selector("dictionaryWithObjects:forKeys:count:"), values.data, keys.data, 2);
+}
+
+text_size :: (text: string) -> NSSize {
+ func :: #type (*void, Selector, *NSObject) -> NSSize #c_call;
+ return (cast(func) objc_msgSend)(NSString.getTempString(text), selector("sizeWithAttributes:"), text_attributes(TEXT));
+}
+
+// Text at x, centred in height.
+draw_text :: (text: string, x: float64, height: float64, color: Vector4) {
+ size := text_size(text);
+ func :: #type (*void, Selector, NSPoint, *NSObject) -> void #c_call;
+ (cast(func) objc_msgSend)(NSString.getTempString(text), selector("drawAtPoint:withAttributes:"), NSMakePoint(x, (height - size.height) / 2), text_attributes(color));
+}
+
+CGRect :: struct { origin: struct { x, y: float64; }; size: struct { width, height: float64; }; }
+CoreGraphics_Library :: #library,system "CoreGraphics";
+CGContextSetRGBFillColor :: (c: *void, r: float64, g: float64, b: float64, a: float64) #foreign CoreGraphics_Library;
+CGContextFillRect :: (c: *void, rect: CGRect) #foreign CoreGraphics_Library;
+CGContextFillEllipseInRect :: (c: *void, rect: CGRect) #foreign CoreGraphics_Library;
+AppKit_Library :: #library,system "AppKit";
+NSFontAttributeName: *NSString #elsewhere AppKit_Library;
+NSForegroundColorAttributeName: *NSString #elsewhere AppKit_Library;
+libSystem_Library :: #library,system "libSystem";
+_dispatch_main_q: u8 #elsewhere libSystem_Library;
+dispatch_async_f :: (queue: *void, data: *void, work: (data: *void) #c_call) #foreign libSystem_Library;
+
+#import "Objective_C";
+#import "Objective_C/AppKit";
diff --git a/src/ui/imgui_keyboard.jai b/src/ui/imgui_keyboard.jai
index 90ab29c..2738b54 100644
--- a/src/ui/imgui_keyboard.jai
+++ b/src/ui/imgui_keyboard.jai
@@ -350,7 +350,7 @@ GLOBAL_HOTKEY_ACTION_COUNT :: 3; // sizes the platforms' registration a
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false});
array_add(out, .{"capture_region", *capture_region_hotkey, OS == .LINUX}); // app/screen_capture.jai: Windows and macOS
- array_add(out, .{"record_region", *record_region_hotkey, true}); // app/screen_record.jai
+ array_add(out, .{"record_region", *record_region_hotkey, OS == .LINUX}); // app/screen_record.jai: Windows and macOS
}
global_hotkey_actions: [..] Hotkey_Action;
diff --git a/src/ui/imgui_theme_registry.jai b/src/ui/imgui_theme_registry.jai
index e7d1672..6edc3f7 100644
--- a/src/ui/imgui_theme_registry.jai
+++ b/src/ui/imgui_theme_registry.jai
@@ -736,8 +736,8 @@ app_save_user_settings_file :: () -> bool {
append(*builder, "# starts; -library FOLDER on the command line overrides it.\n");
print_to_builder(*builder, "folder: %\n\n", settings_quote_if_needed(ifx app_user_settings.library_folder.count then app_user_settings.library_folder else LIBRARY_FOLDER_DEFAULT));
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");
+ append(*builder, "# Screen recording (Ctrl+PrintScreen; Opt+Cmd+Shift+2 on macOS). fps: frames a second at most, written only when the\n");
+ append(*builder, "# screen changes. cursor: true or false. capture (Windows): auto (Desktop Duplication, else\n");
append(*builder, "# Windows.Graphics.Capture), dxgi or wgc. system_audio: what the computer plays; microphone: the\n");
append(*builder, "# default input device. Each true or false.\n");
print_to_builder(*builder, "fps: %\n", app_user_settings.video_fps);