Compare commits

...

14 Commits

Author SHA1 Message Date
68ab91b03c [0.1.38] gpu-bench: OCR's GPU memory budget in the results
What the engine measured its runs to need (detector MB fixed + per megapixel,
recognizer per 1000 line-pixels), the limit at the end, and budget_waits, the
runs that waited for GPU memory, in the stages.
2026-10-17 21:34:15 -04:00
8b76e506f3 [0.1.37] gpu-bench: a memory trace per step, -ocr-workers and -ort-log
Each result now has the GPU's memory over the baseline after loading, the
first image, each warm-up pass and each timed pass (with the pass's peak), in
the output and as trace= in the results file, so growth across passes (a leak)
shows apart from a working set. -ocr-workers sets OCR's GPU worker threads, and
-ort-log info prints onnxruntime's arena extensions: what found the cause of
0.1.24's memory fix.
2026-10-17 21:34:15 -04:00
32af1f6496 [0.1.36] GPU benchmark (gpu-bench branch, not for master)
bin/gpu-bench times every GPU model (OCR mobile and server, the label
image encoder, segmentation; optionally the CPU and the label text
encoder) over a fixed 30-image corpus in tools/gpu_bench/corpus. Every
corpus file is checked against corpus.txt's SHA-256 before anything
runs; each model's files and models.config entry are fingerprinted;
the GPU, driver, power limit, CUDA runtime, cuDNN and onnxruntime are
recorded, and NVML samples memory, load, power and clocks during the
timed passes. Results go to bin/gpu-bench-results as text files.

The GPU Bench button opens a window that runs gpu-bench in its own
process (no console) and lays results files from several machines side
by side against a baseline, marking a different corpus or model.
2026-10-17 21:34:06 -04:00
5dd62ac201 [0.1.35] OCR on the GPU runs as many detector and recognizer runs at once as fit in its memory
The workers ran gpu_workers (4) runs at once whatever the images: the server
model, four large images at a time, took 15 GB of a 16 GB GPU and spilled into
shared system memory, and a smaller GPU would fare far worse.

modules/OCR/budget.jai: each run reserves what it is expected to need, and
waits while the runs in flight or the GPU's free memory leave too little; one
run always goes. The need is measured when the engine is made, from the arena's
peak (AllocatorGetStats MaxInUse) over runs on two input sizes, so it holds for
any model and GPU: the mobile detector 6 MB + 137 MB a megapixel, the server's
4 MB + 1015, against 511 and 522 MB measured for a 2560 x 1440 tile. The limit
is the GPU's free memory less 10% (at least 512 MB), read at the start of each
ocr_process call (cudaMemGetInfo, ort_cuda_memory); the free memory is also read
as each run starts, since finished runs' blocks stay in the arena until the
stage ends.

gpu-bench, RTX 5080 laptop: the server model 335 ms a capture (854 before),
peak 13.1 GB (15.4), passes within 4%; the mobile model unchanged (140 ms,
5.7 GB), as its runs fit.
2026-10-17 21:33:32 -04:00
74b273f0e0 [0.1.34] macOS phase 7: screen recording with Opt+Cmd+Shift+2, through ScreenCaptureKit and VideoToolbox 2026-10-17 21:17:00 -04:00
63a0cdb64e [0.1.33] macOS phase 6: screen capture stills with Cmd+Shift+2, through ScreenCaptureKit 2026-10-17 21:17:00 -04:00
91f6cf768f [0.1.32] macOS phase 5: Mask2Former segmentation on the GPU through native Core ML 2026-10-17 21:17:00 -04:00
a1ab9f3a96 [0.1.31] macOS phase 5: PP-OCRv5 server OCR on the GPU through native Core ML 2026-10-17 21:17:00 -04:00
30cd00b6f2 [0.1.30] macOS phase 5: PP-OCRv5 mobile OCR on the GPU through native Core ML 2026-10-17 21:16:59 -04:00
2d0fb855b1 [0.1.29] macOS phase 5: SigLIP 2 labels on the GPU through native Core ML 2026-10-17 21:16:31 -04:00
3e353c77d8 [0.1.28] macOS phase 4: videos through FFmpeg 6.0 for macOS, an LGPL build made by build_macos.jai 2026-10-17 21:16:30 -04:00
a66928b223 [0.1.27] macOS phase 3: HEIC, AVIF, WebP, TIFF and RAW through ImageIO 2026-10-17 21:16:30 -04:00
5571c21d0b [0.1.26] macOS phase 2: folder picker, the Trash, the temporary folder, copying images 2026-10-17 21:16:30 -04:00
12d0f30fc4 [0.1.25] macOS phase 1: builds and runs on a Mac, the library folder as a setting 2026-10-17 21:16:29 -04:00
114 changed files with 20225 additions and 1315 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
tools/gpu_bench/corpus/** binary

5
.gitignore vendored
View File

@ -27,5 +27,10 @@ Thumbs.db
/modules/Labels/models/
# Segmentation: exported by tools/export_mask2former.py, not committed
/modules/Segment/models/
# FFmpeg for macOS: the source, build and headers modules/ffmpeg/build_macos.jai makes (its libraries in macos/ are kept)
/modules/ffmpeg/macos-build/
# The ScreenCaptureKit shim, as first.jai compiles it
/modules/Screen_Record/macos/*.o
/modules/Screen_Record/macos/*.a
# Releases: made by release.jai
/release/

View File

@ -9,9 +9,9 @@ and labels are tested on Windows with an NVIDIA GPU).
The `.mcap` files come from the **Import** window, which takes any folder of images (PNG, JPEG, HEIC,
AVIF, WebP, GIF, BMP, TIFF, camera RAW), videos (MP4, MOV, MKV, WebM, AVI, WMV, MPEG) and Snagit
`.snagx` captures, or from `snagit-converter`
(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. On
Windows, **PrintScreen** captures a region of the screen straight into the library, as Snagit does;
see [Screen capture](#screen-capture).
(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. **PrintScreen**
on Windows, **Cmd+Shift+2** on macOS, captures a region of the screen straight into the library, as
Snagit does; see [Screen capture](#screen-capture).
## Getting started
@ -28,11 +28,22 @@ see [Screen capture](#screen-capture).
place (`C:\Program Files\NVIDIA\CUDNN`) is found without changing PATH. The OCR window says
what, if anything, is missing.
**On macOS**, labelling, OCR and segmentation run on the GPU through Core ML, from Core ML copies
of the models made once with `tools/export_siglip_coreml.py` (SigLIP 2's image model, about ten
times as fast as the CPU), `tools/export_ppocr_coreml.py` (PP-OCRv5's detector and recognizer,
about twice as fast; run it with `--model mobile` and `--model server`) and
`tools/export_mask2former_coreml.py` (Mask2Former, about four times as fast; after
`tools/export_mask2former.py`). Their first lines say how to set up the Python they need. Without them, each runs on the
CPU. The first scan after making them is slower: Core ML prepares each image size the first time it
meets it, and macOS keeps that for later runs.
3. **Build and run:** `jai first.jai - release`, then `bin\mcap.exe`. The build copies onnxruntime
and the models beside the program.
4. **The library** is `D:\McapIndex\mcap`, a folder for each place imported from
(`SnagitLibraryPersonal`, `SnagitLibraryWork`, ...); `-library FOLDER` shows another folder.
4. **The library** is `D:\McapIndex\mcap` on Windows and `~/Pictures/mcap` on macOS and Linux, a
folder for each place imported from (`SnagitLibraryPersonal`, `SnagitLibraryWork`, ...).
`folder:` under `[library]` in `settings.config` names another, read when mcap starts, and
`-library FOLDER` overrides both.
**Import** (button above the grid) brings in more; see [Importing](#importing).
5. **Read the library:** open the **OCR** window (button above the grid), check **Labels too**,
@ -55,14 +66,15 @@ see [Screen capture](#screen-capture).
what search needs from every file, is still loading: a line beside the count says so). **Size**
sets the thumbnails' size. Click a thumbnail to open it in the Capture window.
- **Delete:** hover a thumbnail and click the trash button in its corner. You are asked whether to
move the file to the Recycle Bin or to delete it permanently, overwriting it with random bytes
move the file to the Recycle Bin (the Trash on macOS, a `deleted` folder in the library on Linux)
or to delete it permanently, overwriting it with random bytes
first; "Don't ask again" keeps the choice until the app restarts. Only the `.mcap` goes: the
`.snagx` it was converted from is untouched.
- **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then
**Ctrl-C** copies its image, as the Capture window shows it (annotations and all; a video's
frame), and **Ctrl-Shift-C** its full path. Neither acts while you type in the search box. They
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config`. Copying images is Windows
only so far.
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config` (**Cmd** for Ctrl on macOS).
Copying images works on Windows and macOS, not yet on Linux.
- **Ctrl-F** puts the keyboard in the search box, its text selected (`focus_search` in `[hotkeys]`).
- **Files added or deleted outside the app** (in Explorer, by another program) appear in the grid
or leave it on their own, folders added, renamed or deleted too: the library is watched.
@ -107,9 +119,12 @@ grid (in its order, so through a search's matches).
from CUDA, and OCR's inputs change size every run, so each larger image or wider batch of lines left
another block behind. OCR now runs the largest images and widest batches first, has the detector's
and recognizer's arenas give back what they hold after each stage of a call, and keeps its 4 worker
threads for the engine's life. The mobile model now peaks at 5.7 GB and drops to 2 GB between
calls, at 142 ms a capture against 126 before (gpu-bench, 2026-10-17). The server model has not been
measured again yet.
threads for the engine's life. Since 0.1.35, on CUDA, the runs on the GPU at once are as many as fit in its
memory: each engine measures what its runs need when it starts, and each run waits while the runs in
flight and the GPU's free memory leave too little for it (`modules/OCR/budget.jai`). On an RTX 5080
laptop (16 GB), gpu-bench, 2026-10-17: the mobile model peaks at 5.7 GB and drops to 2 GB between
calls, at 140 ms a capture against 126 before; the server model peaks at 13.1 GB, at 335 ms a
capture against 10.9 s.
- **Running on:** the GPU or the CPU, and if the CPU, what to install for the GPU.
- **Scan the library** reads every capture the model has not read yet and, with **Labels too**,
labels every capture not yet labelled with the current list. While a search is active the button
@ -170,8 +185,8 @@ of them are segmented, it also shows **What is in the images**: how many images
## Screen capture
On Windows, **PrintScreen** captures any part of the screen, from any app, even while mcap is hidden
in the tray:
**PrintScreen** on Windows, **Cmd+Shift+2** on macOS, captures any part of the screen, from any app,
even while mcap is hidden in the tray or the menu bar:
1. **The screens freeze** the moment it is pressed: everything on every screen is copied and shown
darkened over them, so menus, tooltips and video hold still while you choose.
@ -194,9 +209,21 @@ have PrintScreen: if Snagit is running it has it, and so does Windows while Sett
Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot
register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`.
On macOS (Cmd+Shift+3, 4 and 5 are the system's own screenshot keys):
- **Screen Recording** must be allowed for mcap (System Settings > Privacy & Security > Screen
Recording). The first capture asks for it and opens that pane; macOS may want mcap restarted after.
The permission belongs to the signed `mcap.app`, so start that, not the bare executable, which
would have it asked for on behalf of the terminal.
- Each screen freezes in a window of its own, and a selection stays on the screen it began on. What
is taken is that screen's own pixels, two to a point on a Retina screen, and the magnifier, the
position and the size are in them.
- The screens are copied with ScreenCaptureKit (`modules/Screen_Record/macos/capture.m`, which the
build compiles with clang).
### 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:
@ -214,14 +241,15 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
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
@ -230,11 +258,13 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
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]`.
@ -243,7 +273,7 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
**Import** (above the grid) brings a folder of screenshots, photos and videos into the library:
1. **Import from:** the folder, typed or chosen with **Browse...**. Everything under it is looked
1. **Import from:** the folder, typed or chosen with **Browse...** (Windows and macOS). Everything under it is looked
at, its folders too (not hidden ones, such as `.thumbnails`).
2. **Into library folder:** the folder in the library it goes into, such as `SnagitLibraryWork` or
`Phone`. It starts as the name of the folder imported from, or the library folder that folder went
@ -259,13 +289,14 @@ What becomes of each file:
way up (by its EXIF orientation), and where it came from: its file name (searched by `title:`), the
camera or phone (`app:`), and the date it was taken, from its EXIF, else a date in its name
(`IMG_20240512_101530`, `Screenshot 2024-05-12 at 10.15.30`), else the file's time. It is named
`2024-05-12_10-15-30_1a2b3c4d.mcap`, the date and the start of its hash. HEIC, AVIF, WebP and RAW
are read by Windows' own codecs: the HEIF, AV1, WebP and Raw Image Extensions from the Microsoft
Store.
`2024-05-12_10-15-30_1a2b3c4d.mcap`, the date and the start of its hash. HEIC, AVIF, WebP, TIFF and RAW
are read by the system's own codecs: on Windows the HEIF, AV1, WebP and Raw Image Extensions from
the Microsoft Store, and on macOS ImageIO, which has them all built in. Linux reads only PNG, JPEG,
GIF, BMP, PSD and TGA.
- **Animated GIFs** are images; their thumbnail is marked **GIF**, and the Capture window says how
long they run and opens them in the default viewer.
- **Videos** (MP4, MOV, M4V, 3GP, MKV, WebM, AVI, WMV, MPEG, MTS and M2TS, FLV), read with FFmpeg
(`modules/ffmpeg`, Windows only): an `.mcap` holding the file exactly as it was, of any size (it is
(`modules/ffmpeg`; Windows and macOS, not Linux): an `.mcap` holding the file exactly as it was, of any size (it is
copied a few MB at a time, never read whole), and a frame from it a tenth of the way in (at most 3
seconds), turned the right way up, which is what the grid, the Capture window, OCR and labels see.
The date is the phone's (Apple's creation date), else one in its name, else the video's own
@ -394,7 +425,7 @@ then in that order instead of by date.
| --- | --- |
| `FILE` (first) | Opens FILE: an `.mcap`, image or video; in view mode when mcap is already running. See [Opening a file](#opening-a-file). |
| `-open-import DEST` | With FILE: imports it into the library folder DEST as its Import button does, then exits: 0 once the library has it (imported, or there already), else 1. |
| `-library FOLDER` | Shows the `.mcap` files in FOLDER (and its folders) instead of `D:\McapIndex\mcap`. |
| `-library FOLDER` | Shows the `.mcap` files in FOLDER (and its folders) instead of the library folder in `settings.config` (`[library] folder`). |
| `-import SOURCE DEST` | Imports SOURCE, a folder or a single file, into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed. |
| `-search QUERY` | Starts with QUERY in the search box. |
| `-cold` | Loads the library as on a cold start: its files and the catalog cache are dropped from the system's file cache first (Windows and Linux; not macOS, which allows it only with `sudo purge`). The `[startup]` lines in the log time each step. With `-stats`, a startup benchmark: `bin\mcap.exe -cold -stats`. |
@ -452,7 +483,8 @@ converted copy.
library index up to date (hashing the image in any `.mcap` it has not seen), then imports on
several threads; captures written are handed to the grid each frame.
- `modules/Image_Decode`: any image file's bytes to pixels, the right way up: stb_image for PNG,
JPEG, GIF, BMP, PSD and TGA, Windows Imaging Component for the rest; EXIF (from JPEG, PNG, WebP,
JPEG, GIF, BMP, PSD and TGA, and for the rest Windows Imaging Component (`wic.jai`) or macOS's
ImageIO (`imageio.jai`); EXIF (from JPEG, PNG, WebP,
HEIF and TIFF); previews; and videos' frames and what their containers say, with FFmpeg
(`video.jai`), and how many frames a GIF has. `bin\image-probe FILE...` (`jai first.jai -
image_probe`) shows what it makes of files.
@ -517,10 +549,11 @@ Binaries are written to `bin/`, with `assets/` copied beside them.
| Platform | Output | Notes |
| --- | --- | --- |
| Windows | `bin/mcap-debug.exe`, `bin/mcap.exe` | The release build gets the icon and a manifest, and no console. |
| macOS | `bin/mcap-macos-debug`, `bin/mcap.app` | Apple silicon (arm64) only, with a native `jai`. The release build is an ad hoc signed `.app`. |
| macOS | `bin/mcap-debug.app`, `bin/mcap.app` (and the bare `bin/mcap-macos-debug`, `bin/mcap-macos`) | Apple silicon (arm64) only, with a native `jai`; macOS 14 or later. Each `.app` holds onnxruntime and the models, and is signed with the self-signed "Ex4 Dev" identity when the keychain has it (made by Ex4's `scripts/macos_dev_identity.sh`), so macOS keeps its permissions across builds; otherwise ad hoc. |
| Linux | `bin/mcap-linux-debug`, `bin/mcap-linux` | Needs SDL3 installed (`libSDL3.so`). |
`jai check_targets.jai - linux` type-checks the Linux build from any OS without linking it.
`jai check_targets.jai - linux` type-checks the Linux build from any OS without linking it (on the
host's CPU: from a Mac, Linux on arm64).
The macOS check only works on a Mac, since the Objective-C module runs code at compile time.
Run any build with `run_tests` to run the test suites (debug builds include them),

View File

@ -5,5 +5,5 @@
# Changes apply as soon as the file is saved.
none bring_to_foreground
none capture_region
none record_region
Cmd-Shift-2 capture_region
Opt-Cmd-Shift-2 record_region

View File

@ -8,8 +8,10 @@
#run,stallable {
set_build_options_dc(.{do_output = false});
args := get_build_options().compile_time_command_line;
// Linux on the host's CPU: the ARM compiler cannot run x64 code at compile time (String's #asm
// in the hotkey checks' #run), so from a Mac it checks Linux on arm64.
target := Operating_System_Tag.LINUX;
cpu := CPU_Tag.X64;
cpu := CPU;
if array_find(args, "macos") { target = .MACOS; cpu = .ARM64; }
options := get_build_options();
@ -38,6 +40,9 @@ SHIM, jai_modules));
options.import_path = paths;
}
// The macOS check links nothing, but the ScreenCaptureKit shim is built as first.jai builds it.
#if OS == .MACOS if target == .MACOS build_macos_shim();
w := compiler_create_workspace("check");
set_build_options(options, w);
compiler_begin_intercept(w);
@ -47,7 +52,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
VERSION :: "0.1.24";
VERSION :: "0.1.38";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;
@ -65,6 +70,31 @@ S, w);
compiler_end_intercept(w);
}
#if OS == .MACOS {
// macOS: modules/Screen_Record/macos/capture.m, mcap's ScreenCaptureKit shim, compiled with clang
// into capture.a beside it whenever the source is newer, for the app to link (screen_capture_macos.jai).
// check_targets.jai has the same; keep the two in step.
build_macos_shim :: () -> bool {
FOLDER :: "modules/Screen_Record/macos";
source := tprint("%/capture.m", FOLDER);
object := tprint("%/capture.o", FOLDER);
library := tprint("%/capture.a", FOLDER);
source_time, _, source_ok := file_modtime_and_size(source);
header_time := file_modtime_and_size(tprint("%/capture.h", FOLDER));
library_time, _, library_ok := file_modtime_and_size(library);
if source_ok && library_ok && library_time >= source_time && library_time >= header_time return true;
result := run_command("clang", "-c", "-fobjc-arc", "-O2", "-Wall", "-mmacosx-version-min=14.0", source, "-o", object);
if result.exit_code != 0 { log_error("ERROR: clang could not compile %.", source); return false; }
file_delete(library);
result = run_command("ar", "rcs", library, object);
if result.exit_code != 0 { log_error("ERROR: ar could not make %.", library); return false; }
log("Built %", library);
return true;
}
#import "Process";
#import "File_Utilities";
}
#import "Basic";
#import "Compiler";
#import "File";

173
first.jai
View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.24";
VERSION :: "0.1.38";
// 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";
@ -54,6 +54,7 @@ build_project :: () {
set_working_directory(#filepath); // so that we load fonts correctly.
make_directory_if_it_does_not_exist(build_directory);
#if OS == .MACOS if !build_macos_shim() return;
// Workspaces compile concurrently once source is added, so every build is
// started before any is waited on.
@ -70,6 +71,10 @@ build_project :: () {
// jai first.jai - record_probe: also bin/record-probe, a screen recorded with modules/Screen_Record (video-capture-plan.html, phase 1).
if array_find(args, "record_probe") array_add(*pending, start_tool_build("record-probe", "tools/record_probe.jai", optimized = build_release));
// The gpu-bench branch: bin/gpu-bench, every GPU model timed over a fixed corpus (tools/gpu_bench),
// always built, as the GPU Benchmark window runs it.
array_add(*pending, start_tool_build("gpu-bench", "tools/gpu_bench/gpu_bench.jai", optimized = build_release));
wait_for_builds(pending);
for pending if !it.failed {
@ -125,6 +130,7 @@ start_main_build :: (optimized: bool) -> Pending_Build {
#if CPU == .X64 options.backend = .X64; // the fastest to compile; LLVM elsewhere
options.output_executable_name = tprint("%-debug", output_executable_name_temp);
}
#if OS == .MACOS options.minimum_os_version = MACOS_MINIMUM_VERSION;
#if OS == .WINDOWS {
file_delete(tprint("%/%.pdb", build_directory, options.output_executable_name));
@ -166,6 +172,7 @@ start_tool_build :: (name: string, file: string, optimized: bool) -> Pending_Bui
options.output_executable_name = tprint("%-debug", name);
}
options.output_path = build_directory;
#if OS == .MACOS options.minimum_os_version = MACOS_MINIMUM_VERSION;
w := compiler_create_workspace(options.output_executable_name);
set_build_options(options, w);
@ -194,52 +201,163 @@ finish_main_build :: (build: Pending_Build) {
}
}
} else #if OS == .MACOS {
if build.optimized create_macos_app_bundle(build.executable_name);
create_macos_app_bundle(build.executable_name, build.optimized);
}
log("Built %/%", build_directory, build.executable_name);
}
#if OS == .MACOS {
MACOS_APP_BUNDLE :: #run tprint("%.app", APP_NAME);
// macOS: modules/Screen_Record/macos/capture.m, mcap's ScreenCaptureKit shim, compiled with clang
// into capture.a beside it whenever the source is newer, for the app to link (screen_capture_macos.jai).
// check_targets.jai has the same; keep the two in step.
build_macos_shim :: () -> bool {
FOLDER :: "modules/Screen_Record/macos";
source := tprint("%/capture.m", FOLDER);
object := tprint("%/capture.o", FOLDER);
library := tprint("%/capture.a", FOLDER);
source_time, _, source_ok := file_modtime_and_size(source);
header_time := file_modtime_and_size(tprint("%/capture.h", FOLDER));
library_time, _, library_ok := file_modtime_and_size(library);
if source_ok && library_ok && library_time >= source_time && library_time >= header_time return true;
result := run_command("clang", "-c", "-fobjc-arc", "-O2", "-Wall", "-mmacosx-version-min=14.0", source, "-o", object);
if result.exit_code != 0 { log_error("ERROR: clang could not compile %.", source); return false; }
file_delete(library);
result = run_command("ar", "rcs", library, object);
if result.exit_code != 0 { log_error("ERROR: ar could not make %.", library); return false; }
log("Built %", library);
return true;
}
// The oldest macOS it runs on: ScreenCaptureKit's screenshots need 14, and so does onnxruntime 1.30.
MACOS_MINIMUM_VERSION :: Build_Options.Os_Version.{ major = 14, minor = 0 };
// A self-signed code-signing identity, made once per Mac by Ex4's scripts/macos_dev_identity.sh
// and shared with it. macOS ties Screen Recording and the other privacy permissions to an app's
// signature, and an ad hoc one changes with every build, so they would be asked for again each
// time. Without it in the keychain, the bundle is signed ad hoc.
MACOS_SIGNING_IDENTITY :: "Ex4 Dev";
MACOS_BUNDLE_DYLIBS :: string.["libSDL3.0.dylib"];
// The .app holds only the executable, the dylibs it loads from beside itself, the icon and
// Info.plist. The app finds data/, logs/ and images/ in the folder holding the bundle; see
// app_install_directory.
create_macos_app_bundle :: (executable_name: string) {
app := tprint("%/%", build_directory, MACOS_APP_BUNDLE);
run_command("rm", "-rf", app);
// mcap.app for the release build, mcap-debug.app for the debug one, with an identifier of its own
// so that its permissions are granted apart. Each holds the executable, SDL3 and FFmpeg beside it,
// onnxruntime in Contents/Frameworks, and the icon, Info.plist and models in Contents/Resources.
// The app finds data/, logs/ and images/ in the folder holding the bundle (see
// app_install_directory), and the models in Contents/Resources (app_resource_directory).
//
// The bundle is updated in place, not made afresh: the models are some 900 MB, so they are copied
// only when changed, as APFS clones, which take no space of their own.
//
// Started with `open`, the bundle asks for permissions as itself; the bare executable started
// from a terminal would have them asked for, and granted, to the terminal.
create_macos_app_bundle :: (executable_name: string, optimized: bool) {
bundle_name := ifx optimized then tprint("%.app", APP_NAME) else tprint("%-debug.app", APP_NAME);
identifier := ifx optimized then APP_BUNDLE_IDENTIFIER else tprint("%.debug", APP_BUNDLE_IDENTIFIER);
display := ifx optimized then APP_NAME else tprint("% Debug", APP_NAME);
app := tprint("%/%", build_directory, bundle_name);
executable_folder := tprint("%/Contents/MacOS", app);
frameworks_folder := tprint("%/Contents/Frameworks", app);
resources_folder := tprint("%/Contents/Resources", app);
// The executable folder is made afresh, so a renamed executable leaves nothing behind.
run_command("rm", "-rf", executable_folder);
make_directory_if_it_does_not_exist(executable_folder, recursive = true);
make_directory_if_it_does_not_exist(frameworks_folder, recursive = true);
make_directory_if_it_does_not_exist(resources_folder, recursive = true);
copied := copy_file(tprint("%/%", build_directory, executable_name), tprint("%/%", executable_folder, executable_name));
for MACOS_BUNDLE_DYLIBS copied &&= copy_file(tprint("assets/%", it), tprint("%/%", executable_folder, it));
// FFmpeg, linked against, so beside the executable as SDL3 is (@loader_path).
for file_list("modules/ffmpeg/macos") if ends_with(it, ".dylib") copied &&= copy_file(it, tprint("%/%", executable_folder, path_filename(it)));
if !copied {
log_error("ERROR: Couldn't copy the executable and its dylibs into %\n", app);
return;
}
run_command("chmod", "+x", tprint("%/%", executable_folder, executable_name));
// sips makes an .icns only from a size the format holds, and icon.png may be any square size.
resized := tprint("%/icon_1024.png", build_directory);
result := run_command("sips", "-z", "1024", "1024", "assets/images/icon.png", "--out", resized);
if result.exit_code == 0 result = run_command("sips", "-s", "format", "icns", resized, "--out", tprint("%/%.icns", resources_folder, APP_FOLDER_NAME));
file_delete(resized);
if result.exit_code != 0 log_error("ERROR: Couldn't make the icon for %; the Dock will show the generic one.\n", app);
// onnxruntime, and the models, as copy_assets puts them beside the executable.
ORT_LIBRARY :: "modules/onnxruntime/macos/libonnxruntime.dylib";
if file_exists(ORT_LIBRARY) clone_if_changed(ORT_LIBRARY, tprint("%/libonnxruntime.dylib", frameworks_folder));
Model_Folder :: struct { module: string; folder: string; }
for Model_Folder.[.{"OCR", "ocr-models"}, .{"Labels", "label-models"}, .{"Segment", "segment-models"}] {
folder := tprint("%/%", resources_folder, it.folder);
make_directory_if_it_does_not_exist(folder);
clone_if_changed(tprint("modules/%/models.config", it.module), tprint("%/models.config", folder));
models := tprint("modules/%/models", it.module);
if file_exists(models) for file_list(models) clone_if_changed(it, tprint("%/%", folder, path_filename(it)));
if file_exists(models) clone_packages(models, folder);
}
if !write_entire_file(tprint("%/Contents/Info.plist", app), tprint(MACOS_INFO_PLIST, APP_NAME, APP_NAME, executable_name, APP_BUNDLE_IDENTIFIER, APP_FOLDER_NAME, VERSION, VERSION)) {
// An .iconset of every size, made with sips, then iconutil: sips -s format icns fails on
// macOS 27 (error 13), and icon.png may be any square size.
icon := tprint("%/%.icns", resources_folder, APP_FOLDER_NAME);
if !file_exists(icon) {
iconset := tprint("%/%.iconset", build_directory, APP_FOLDER_NAME);
run_command("rm", "-rf", iconset);
make_directory_if_it_does_not_exist(iconset);
result: Process_Result;
for size: s32.[16, 32, 128, 256, 512] {
for scale: 1..2 {
pixels := tprint("%", size * scale);
name := ifx scale == 1 then tprint("icon_%x%.png", size, size) else tprint("icon_%x%@2x.png", size, size);
result = run_command("sips", "-z", pixels, pixels, "assets/images/icon.png", "--out", tprint("%/%", iconset, name));
if result.exit_code != 0 break size;
}
}
if result.exit_code == 0 result = run_command("iconutil", "-c", "icns", iconset, "-o", icon);
run_command("rm", "-rf", iconset);
if result.exit_code != 0 log_error("ERROR: Couldn't make the icon for %; the Dock will show the generic one.\n", app);
}
minimum := tprint("%.%", MACOS_MINIMUM_VERSION.major, MACOS_MINIMUM_VERSION.minor);
if !write_entire_file(tprint("%/Contents/Info.plist", app), tprint(MACOS_INFO_PLIST, display, display, executable_name, identifier, APP_FOLDER_NAME, VERSION, VERSION, minimum)) {
log_error("ERROR: Couldn't write %/Contents/Info.plist\n", app);
return;
}
// Ad hoc: a signature over the whole bundle, which macOS expects, not a Developer ID.
result = run_command("codesign", "--force", "--deep", "--sign", "-", app);
if result.exit_code != 0 log_error("WARNING: Couldn't sign %; it may still run.\n", app);
log("Built % (signed %)", app, sign_macos_app_bundle(app));
}
log("Built %", app);
// With MACOS_SIGNING_IDENTITY when the keychain has it, and otherwise ad hoc, which is still
// a signature over the whole bundle, as macOS expects. Returns what it was signed with.
sign_macos_app_bundle :: (app: string) -> string {
result, identities := run_command("security", "find-identity", "-p", "codesigning", capture_and_return_output = true);
if result.exit_code == 0 && contains(identities, tprint("\"%\"", MACOS_SIGNING_IDENTITY)) {
result = run_command("codesign", "--force", "--deep", "--sign", MACOS_SIGNING_IDENTITY, app);
if result.exit_code == 0 return MACOS_SIGNING_IDENTITY;
log_error("WARNING: Couldn't sign % as %; signing it ad hoc instead.\n", app, MACOS_SIGNING_IDENTITY);
} else {
log("No \"%\" identity in the keychain, so % is signed ad hoc, and macOS will ask for its permissions again after every build. Ex4's scripts/macos_dev_identity.sh makes one.", MACOS_SIGNING_IDENTITY, app);
}
result = run_command("codesign", "--force", "--deep", "--sign", "-", app);
if result.exit_code != 0 {
log_error("WARNING: Couldn't sign %; it may still run.\n", app);
return "not at all";
}
return "ad hoc";
}
// Core ML packages (.mlpackage, folders) in from, cloned whole into to: made afresh each build, which
// as clones costs no time or space.
clone_packages :: (from: string, to: string) {
visit_files(from, false, to, (info: *File_Visit_Info, to: string) {
if !ends_with(info.short_name, ".mlpackage") return;
target := tprint("%/%", to, info.short_name);
run_command("rm", "-rf", target);
result := run_command("cp", "-c", "-R", info.full_name, target);
if result.exit_code != 0 log_error("Could not copy % to %.", info.full_name, target);
}, visit_files = false, visit_directories = true);
}
// As copy_if_changed, but an APFS clone (cp -c), which shares the original's blocks.
clone_if_changed :: (from: string, to: string) {
from_time, from_size, from_ok := file_modtime_and_size(from);
to_time, to_size, to_ok := file_modtime_and_size(to);
if to_ok && from_ok && to_size == from_size && to_time >= from_time return;
file_delete(to);
result := run_command("cp", "-c", "-p", from, to);
if result.exit_code != 0 && !copy_file(from, to) log_error("Could not copy % to %.", from, to);
}
MACOS_INFO_PLIST :: #string PLIST
@ -257,7 +375,9 @@ finish_main_build :: (build: Pending_Build) {
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key> <string>%</string>
<key>CFBundleVersion</key> <string>%</string>
<key>LSMinimumSystemVersion</key> <string>%</string>
<key>NSHighResolutionCapable</key> <true/>
<key>NSMicrophoneUsageDescription</key> <string>mcap records the microphone into a screen recording when its Mic button is on.</string>
</dict>
</plist>
PLIST
@ -286,13 +406,17 @@ copy_assets :: () {
copy_if_changed("modules/OCR/models.config", tprint("%/ocr-models/models.config", build_directory));
if file_exists("modules/OCR/models") {
for file_list("modules/OCR/models") copy_if_changed(it, tprint("%/ocr-models/%", build_directory, path_filename(it)));
#if OS == .MACOS clone_packages("modules/OCR/models", tprint("%/ocr-models", build_directory));
} else {
log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR.");
}
// Videos: FFmpeg's DLLs beside the executable (modules/ffmpeg/windows, in git), the same way.
// Videos: FFmpeg's libraries beside the executable (modules/ffmpeg/windows and macos, in git), the
// same way. The executable finds the dylibs there through its @loader_path rpath.
#if OS == .WINDOWS {
for file_list("modules/ffmpeg/windows") if ends_with(it, ".dll") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it)));
} else #if OS == .MACOS {
for file_list("modules/ffmpeg/macos") if ends_with(it, ".dylib") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it)));
}
// Labels: its models in bin/label-models, the same way.
@ -300,6 +424,7 @@ copy_assets :: () {
copy_if_changed("modules/Labels/models.config", tprint("%/label-models/models.config", build_directory));
if file_exists("modules/Labels/models") {
for file_list("modules/Labels/models") copy_if_changed(it, tprint("%/label-models/%", build_directory, path_filename(it)));
#if OS == .MACOS clone_packages("modules/Labels/models", tprint("%/label-models", build_directory));
} else {
log("Labels: the models have not been fetched; run jai fetch_models.jai in modules/Labels for labels.");
}
@ -309,9 +434,15 @@ copy_assets :: () {
copy_if_changed("modules/Segment/models.config", tprint("%/segment-models/models.config", build_directory));
if file_exists("modules/Segment/models") {
for file_list("modules/Segment/models") copy_if_changed(it, tprint("%/segment-models/%", build_directory, path_filename(it)));
#if OS == .MACOS clone_packages("modules/Segment/models", tprint("%/segment-models", build_directory));
} else {
log("Segmentation: the model has not been made; see modules/Segment/README.md.");
}
// gpu-bench's corpus, in bin/gpu-bench-corpus: in git, so every machine has the same images.
make_directory_if_it_does_not_exist(tprint("%/gpu-bench-corpus", build_directory));
copy_if_changed("tools/gpu_bench/corpus.txt", tprint("%/gpu-bench-corpus/corpus.txt", build_directory));
for file_list("tools/gpu_bench/corpus") copy_if_changed(it, tprint("%/gpu-bench-corpus/%", build_directory, path_filename(it)));
}
copy_if_changed :: (from: string, to: string) {

View File

@ -1,335 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>macOS Port Plan</title>
<style>
:root {
--bg: #fbfaf8; --surface: #ffffff; --text: #1d1d1f; --dim: #5d5d66; --line: #e3e1dc;
--accent: #3a5bd9; --accent-soft: #e9edfb; --warn: #9a5b00; --warn-soft: #fbf1e1;
--code-bg: #f2f0ec; --ok: #2e7d4f; --ok-soft: #e5f3ea;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #16171a; --surface: #1d1f23; --text: #e7e7ea; --dim: #a2a3ab; --line: #30333a;
--accent: #8aa2ff; --accent-soft: #232a45; --warn: #f0b45a; --warn-soft: #33281a;
--code-bg: #25272c; --ok: #6fcf97; --ok-soft: #1c2e24;
}
}
:root[data-theme="dark"] {
--bg: #16171a; --surface: #1d1f23; --text: #e7e7ea; --dim: #a2a3ab; --line: #30333a;
--accent: #8aa2ff; --accent-soft: #232a45; --warn: #f0b45a; --warn-soft: #33281a;
--code-bg: #25272c; --ok: #6fcf97; --ok-soft: #1c2e24;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 860px; margin: 0 auto; padding: 40px 16px 80px; }
header p { color: var(--dim); margin-top: 4px; }
h1 { font-size: 2rem; line-height: 1.2; margin: 0; }
h2 { font-size: 1.35rem; margin: 48px 0 8px; padding-top: 16px; border-top: 1px solid var(--line); }
h3 { font-size: 1.05rem; margin: 28px 0 6px; }
p, li { max-width: 72ch; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.88em; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code-bg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; line-height: 1.45; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.94rem; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--dim); font-weight: 600; }
.table-wrap { overflow-x: auto; }
.summary { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 20px; margin-top: 24px; }
.summary ul { margin: 6px 0 0; padding-left: 20px; }
.note { background: var(--accent-soft); border-radius: 8px; padding: 10px 14px; margin: 14px 0; }
.risk { background: var(--warn-soft); border-radius: 8px; padding: 10px 14px; margin: 14px 0; }
.risk strong { color: var(--warn); }
.tag { display: inline-block; font-size: 0.78rem; font-weight: 600; padding: 1px 8px; border-radius: 99px; background: var(--accent-soft); color: var(--accent); }
.tag.later { background: var(--code-bg); color: var(--dim); }
.tag.decided { background: var(--ok-soft); color: var(--ok); }
nav ol { columns: 2; column-gap: 32px; padding-left: 20px; margin: 8px 0 0; }
@media (max-width: 600px) { nav ol { columns: 1; } h1 { font-size: 1.6rem; } }
.phase { border-left: 3px solid var(--accent); padding: 2px 0 2px 14px; margin: 16px 0; }
.phase h3 { margin-top: 0; }
figure { margin: 18px 0; }
figcaption { color: var(--dim); font-size: 0.88rem; margin-top: 6px; }
svg text { fill: var(--text); font: 13px system-ui, sans-serif; }
svg .dim { fill: var(--dim); }
svg .box { fill: var(--surface); stroke: var(--line); }
svg .hl { fill: var(--accent-soft); stroke: var(--accent); }
svg .arrow { stroke: var(--dim); fill: none; marker-end: url(#a); }
</style>
</head>
<body>
<main>
<header>
<h1>mcap on macOS: building again, then feature parity</h1>
<p>Planning document · 2026-10-10 · for mcap after 0.1.11 · <strong>not started</strong></p>
</header>
<div class="summary">
<strong>In short</strong>
<ul>
<li><strong>Where it stands:</strong> mcap was built to compile on macOS (Apple silicon, SDL3 and Metal), but it hasn't been built on a Mac since at least 0.0.8. Two build breaks were found and fixed in 0.1.11 without a Mac. Everything added since the template is Windows first, and much of it Windows only.</li>
<li><span class="tag decided">Proposed</span> <strong>Eight phases</strong>, smallest risk first:
<ol>
<li>Build and run on a Mac.</li>
<li>Fill the small platform gaps: choosing a folder, the Trash, the clipboard, the temporary folder.</li>
<li>Images through ImageIO.</li>
<li>Video through FFmpeg for macOS.</li>
<li>The models on the GPU through CoreML.</li>
<li>Screen capture stills.</li>
<li>Screen recording.</li>
<li>A signed release.</li>
</ol></li>
<li><span class="tag decided">Proposed</span> <strong>One codebase, the same file formats.</strong> An <code>.mcap</code> made on either system opens on the other. Where Windows uses WIC, DXGI and WASAPI, macOS uses ImageIO and ScreenCaptureKit. FFmpeg, onnxruntime, the encoder, the remux and the mixer stay shared.</li>
<li><span class="tag decided">Proposed</span> <strong>ScreenCaptureKit through a small Objective-C shim.</strong> It is asynchronous: completion blocks, delegates, dispatch queues. A few hundred lines of Objective-C, compiled with clang into a static library with a plain C interface, is far safer than doing that from Jai. Open question 6.</li>
<li><strong>The big unknowns</strong> need a Mac to settle. Which macOS to require (14 Sonoma is proposed). Signing: macOS remembers the Screen Recording permission per signature, and an ad hoc signature changes with every build. What the capture keys should be: Mac keyboards have no PrintScreen. See <a href="#questions">open questions</a>.</li>
</ul>
</div>
<nav>
<ol>
<li><a href="#today">Where it stands</a></li>
<li><a href="#gaps">What is Windows only</a></li>
<li><a href="#approach">Approach</a></li>
<li><a href="#capture">Screen capture on macOS</a></li>
<li><a href="#permissions">Permissions and signing</a></li>
<li><a href="#phases">Phases</a></li>
<li><a href="#risks">Risks</a></li>
<li><a href="#questions">Open questions</a></li>
</ol>
</nav>
<h2 id="today">1. Where it stands</h2>
<p>From the code; none of this has been run on a Mac in this project's history.</p>
<div class="table-wrap"><table>
<tr><th>Part</th><th>macOS today</th></tr>
<tr><td>Window, ImGui, themes, fonts</td><td>SDL3 with SDL_GPU on Metal (<code>src/backends/sdl3_gpu3.jai</code>). The menu-bar tray is SDL's. Fonts and CJK fallbacks have macOS paths (<code>platform_macos.jai</code>).</td></tr>
<tr><td>Build</td><td><code>jai first.jai</code> makes <code>bin/mcap-macos-debug</code>, and with <code>- release</code> <code>bin/mcap.app</code>, signed ad hoc. Apple silicon only, with a native <code>jai</code>. The <code>.app</code> holds only the executable and <code>libSDL3.0.dylib</code>. onnxruntime and the models are copied beside it, not into it.</td></tr>
<tr><td>Type check from Windows</td><td>Not possible: <code>jai check_targets.jai - macos</code> runs only on a Mac, because the Objective-C module runs code at compile time.</td></tr>
<tr><td>Known breaks, fixed in 0.1.11</td><td>
<ul>
<li><code>unix_sdl3_gpu3.jai</code> pulled all of ImGui into the app's scope, so ImGui's <code>Image</code> clashed with Image_Decode's (broken since 0.0.8).</li>
<li>A Windows <code>RECT</code> was in the shared <code>App</code> struct (since 0.1.6).</li>
</ul>
The Linux check passes after both, and macOS shares the same files.</td></tr>
<tr><td>Library location</td><td>Hard-coded as <code>D:/McapIndex/mcap</code> (<code>VIEWER_FOLDER</code>). On a Mac only <code>-library</code> works.</td></tr>
<tr><td>Global hotkeys</td><td>Carbon <code>RegisterEventHotKey</code> (<code>macos_global_hotkeys.jai</code>). <code>capture_region</code> and <code>record_region</code> default to <code>none</code>.</td></tr>
<tr><td>OCR, labels, segmentation</td><td>onnxruntime 1.30.0 for macOS arm64 is in <code>fetch.jai</code>. Sessions use CUDA or the CPU (<code>session.jai</code>), so on a Mac it's CPU only, several times slower than a GPU.</td></tr>
</table></div>
<h2 id="gaps">2. What is Windows only</h2>
<div class="table-wrap"><table>
<tr><th>Feature</th><th>Windows</th><th>macOS today</th><th>macOS plan</th></tr>
<tr><td>Images other than PNG, JPEG, GIF, BMP, PSD, TGA</td><td>WIC (<code>Image_Decode/wic.jai</code>)</td><td>Refused</td><td>ImageIO, <code>CGImageSource</code> (phase 3)</td></tr>
<tr><td>Videos: import, frame for the thumbnail</td><td>FFmpeg (<code>modules/ffmpeg</code>, Windows bindings only)</td><td>Skipped</td><td>FFmpeg for macOS (phase 4)</td></tr>
<tr><td>Import: choosing a folder</td><td>Explorer's picker (<code>windows_choose_folder</code>)</td><td>Typed path only</td><td><code>NSOpenPanel</code> (phase 2)</td></tr>
<tr><td>Delete to the bin</td><td>Recycle Bin (<code>SHFileOperationW</code>)</td><td>A "deleted" folder beside the file</td><td><code>NSFileManager trashItemAtURL</code> (phase 2)</td></tr>
<tr><td>Temporary folder (open original, recordings)</td><td><code>%TEMP%</code></td><td><code>/tmp</code></td><td><code>NSTemporaryDirectory()</code> (phase 2)</td></tr>
<tr><td>Image to the clipboard</td><td><code>CF_DIB</code></td><td>None</td><td><code>NSPasteboard</code>, PNG (phase 2)</td></tr>
<tr><td>Models on the GPU</td><td>CUDA</td><td>CPU</td><td>CoreML execution provider (phase 5)</td></tr>
<tr><td>Screen capture stills</td><td>GDI overlay, <code>BitBlt</code></td><td>None</td><td>Cocoa overlay, ScreenCaptureKit screenshots (phase 6)</td></tr>
<tr><td>Screen recording, border, toolbar</td><td>DXGI or Windows.Graphics.Capture, GDI windows</td><td>None</td><td>ScreenCaptureKit stream, Cocoa panels (phase 7)</td></tr>
<tr><td>Sound</td><td>WASAPI</td><td>None</td><td>ScreenCaptureKit audio, plus its microphone or AVAudioEngine (phase 7)</td></tr>
<tr><td>Release</td><td><code>release.jai</code>: a zip with CUDA</td><td>None</td><td>A signed <code>.app</code> in a <code>.dmg</code> (phase 8)</td></tr>
</table></div>
<p>Already shared and fine as they are:</p>
<ul>
<li>The <code>.mcap</code> format, catalog, search, Stats.</li>
<li><code>.snagx</code> import, EXIF (<code>exif.jai</code> reads it itself).</li>
<li>The settings and hotkey files.</li>
<li>The encoder, remux and mixer in <code>modules/Screen_Record</code>. Their capture sources aren't shared.</li>
</ul>
<h2 id="approach">3. Approach</h2>
<ul>
<li><strong>Same interfaces, a second implementation.</strong> Each Windows-only piece already sits behind a function the app calls. The macOS version goes beside it, in its own file:
<ul>
<li><code>platform_screen_capture_select</code>, <code>platform_copy_image_to_clipboard</code>.</li>
<li><code>image_decode</code>'s WIC branch.</li>
<li><code>video_poster</code>.</li>
<li><code>Screen_Record</code>'s source: a third source beside <code>dxgi_source</code> and <code>wgc_source</code>.</li>
</ul>
The app's own files (<code>screen_capture.jai</code>, <code>screen_record.jai</code>, <code>import.jai</code>) shouldn't need to know which system they're on beyond what they do now.</li>
<li><strong>FFmpeg on macOS too</strong>, rather than AVFoundation. Video import, the thumbnail frame, the recording encoder and the remux are FFmpeg code already. On a Mac the encoder would be <code>h264_videotoolbox</code>, Apple's hardware encoder, put first in <code>RECORD_ENCODERS</code>. The cost is shipping FFmpeg's dylibs in the <code>.app</code>, and bindings for their macOS names.</li>
<li><strong>The Objective-C shim</strong> (<code>modules/Screen_Record/macos/capture.m</code>) holds ScreenCaptureKit and nothing else. It has a C interface:
<ul>
<li><code>mcap_sck_screenshot</code></li>
<li><code>mcap_sck_stream_start</code></li>
<li><code>mcap_sck_next_frame</code></li>
<li><code>mcap_sck_next_audio</code></li>
<li><code>mcap_sck_stream_stop</code></li>
</ul>
It is compiled by <code>first.jai</code> with clang when building on a Mac, and linked statically. AppKit work that is plain message sends (<code>NSOpenPanel</code>, <code>NSPasteboard</code>, the overlay and toolbar windows) stays in Jai through the Objective-C module the app already uses.</li>
<li><strong>The build is checked on every phase</strong> with <code>jai check_targets.jai - macos</code> and the test suites on the Mac. <code>jai check_targets.jai - linux</code> on Windows catches the shared files between Mac sessions.</li>
</ul>
<h2 id="capture">4. Screen capture on macOS</h2>
<ul>
<li><strong>Stills:</strong> the moment the key is pressed, each screen is copied:
<ul>
<li>Copied with <code>SCScreenshotManager captureImageWithFilter</code>, on macOS 14 and later. <code>CGWindowListCreateImage</code> is deprecated in macOS 15.</li>
<li>Shown frozen in a borderless <code>NSWindow</code> per screen, at the screen-saver level. A window can't span screens on macOS as the one Windows overlay does.</li>
<li>The drag, magnifier and keys work as on Windows. The overlay's drawing (darkened copy, bright selection, outline, magnifier) becomes Core Graphics in each window's <code>drawRect</code>, or an <code>NSImage</code> drawn by the shim.</li>
</ul></li>
<li><strong>Points and pixels:</strong> AppKit works in points. A Retina screen has 2 pixels a point, and screens can differ. The region is chosen in points and converted to each screen's pixels for the capture. The 240 × 180 minimum is in pixels, as on Windows.</li>
<li><strong>Recording:</strong> an <code>SCStream</code> for the region's display (<code>sourceRect</code>) delivers BGRA <code>CVPixelBuffer</code>s with timestamps on the host clock.
<ul>
<li>The shim hands them over as the pipeline's frames, and the encoder and the rest are unchanged.</li>
<li>The cursor: <code>showsCursor</code>.</li>
<li>Keeping mcap's own border and toolbar out: an <code>SCContentFilter</code> that excludes the app's windows. That's cleaner than Windows' window flag, and doesn't depend on it.</li>
</ul></li>
<li><strong>Sound:</strong>
<ul>
<li>System sound comes from the same stream (<code>capturesAudio</code>, macOS 13 on), as 48 kHz float.</li>
<li>The microphone comes from the stream too on macOS 15 (<code>captureMicrophone</code>), else from <code>AVAudioEngine</code>'s input.</li>
<li>Both go into the mixer as packets with host-clock times, as WASAPI's do.</li>
</ul></li>
<li><strong>The border and toolbar:</strong> non-activating <code>NSPanel</code>s at the floating level. The toolbar's buttons ask, and the app acts on its next frame, as on Windows.</li>
<li><strong>The window it was taken from:</strong> the frontmost app comes from <code>NSWorkspace</code>, and window titles from <code>CGWindowListCopyWindowInfo</code>. The titles need the Screen Recording permission, which capture needs anyway.</li>
<li><strong>Hotkeys:</strong> no PrintScreen on Mac keyboards. ⌘⇧3, 4 and 5 are the system's screenshot keys. See open question 3.</li>
</ul>
<h2 id="permissions">5. Permissions and signing</h2>
<ul>
<li><strong>Screen Recording</strong> (Privacy &amp; Security) must be granted before any capture. The first capture asks; until it is granted, the overlay gets only the desktop, without other apps' windows. mcap will check (<code>CGPreflightScreenCaptureAccess</code>) and say what to do instead of showing an empty capture.</li>
<li><strong>Microphone</strong> permission is asked the first time the Mic button is turned on. <code>Info.plist</code> gains <code>NSMicrophoneUsageDescription</code>.</li>
<li><strong>macOS remembers these per code signature.</strong> The release <code>.app</code> is signed ad hoc today, and an ad hoc signature changes with every build, so each build would ask again, or silently stay refused. A stable signing identity solves it: an Apple Development certificate for builds, and a Developer ID for releases, notarized so Gatekeeper opens it. See open question 4.</li>
</ul>
<h2 id="phases">6. Phases</h2>
<p>Each phase ends with something to run and check on the Mac, and is a commit. They are ordered so a usable mcap comes first: by the end of phase 4 the library browser, search, imports and OCR all work.</p>
<div class="phase">
<h3>Phase 1: Build and run on a Mac</h3>
<ul>
<li><strong>The type check:</strong> <code>jai check_targets.jai - macos</code>, fixing what it finds.</li>
<li><strong>The builds:</strong> <code>jai first.jai</code> and <code>- release</code>.</li>
<li><strong>A library folder for macOS:</strong> a default in place of <code>D:/McapIndex/mcap</code>, open question 5.</li>
<li><strong>The <code>.app</code> made whole:</strong> <code>libonnxruntime.dylib</code> in <code>Contents/Frameworks</code>, and the OCR, label and segmentation models in <code>Contents/Resources</code>, found from there.</li>
<li><strong>Tests:</strong> <code>run_tests -headless</code> on the Mac.</li>
</ul>
<p><strong>Done when:</strong></p>
<ul>
<li>Both builds succeed and the test suites pass on the Mac.</li>
<li><code>mcap.app</code>, started from Finder, shows a library copied from Windows: thumbnails, the Capture window, search, Stats.</li>
<li>An OCR scan reads captures on the CPU, and its time per capture is recorded.</li>
</ul>
</div>
<div class="phase">
<h3>Phase 2: The small platform gaps</h3>
<ul>
<li><strong>Choosing a folder:</strong> <code>NSOpenPanel</code> for Import's Browse button.</li>
<li><strong>Delete:</strong> into the Trash with <code>NSFileManager trashItemAtURL</code>, with "Move to Trash" in the delete dialog.</li>
<li><strong>Temporary files:</strong> <code>NSTemporaryDirectory()</code> for opening an original and for recordings.</li>
<li><strong>The clipboard:</strong> <code>platform_copy_image_to_clipboard</code> through <code>NSPasteboard</code>, as PNG and TIFF.</li>
</ul>
<p><strong>Done when:</strong> Browse picks a folder; a deleted capture is in the Trash and can be put back from there; Open plays a video's original; an image pasted from the clipboard (phase 6 is its first user, so a test fills it) pastes into Preview.</p>
</div>
<div class="phase">
<h3>Phase 3: Images through ImageIO</h3>
<p><code>Image_Decode/imageio.jai</code>: <code>CGImageSourceCreateWithData</code> and <code>CGImageSourceCreateImageAtIndex</code>, drawn into an RGBA bitmap context. It takes the formats WIC takes on Windows:</p>
<ul>
<li>HEIC and AVIF (AVIF from macOS 13).</li>
<li>WebP (from macOS 11).</li>
<li>TIFF.</li>
<li>The camera RAW formats.</li>
</ul>
<p>EXIF orientation stays <code>exif.jai</code>'s. ImageIO is asked for the image as stored, so it isn't turned twice.</p>
<p><strong>Done when:</strong> <code>image-probe</code> on the Mac decodes the same test images as on Windows, the same way up and the same size, and importing an iPhone photo folder makes the same <code>.mcap</code> files, except for the import date.</p>
</div>
<div class="phase">
<h3>Phase 4: Video through FFmpeg for macOS</h3>
<ul>
<li><strong>FFmpeg itself:</strong> a shared FFmpeg 6 build for macOS arm64, with VideoToolbox and the native AAC encoder. Its dylibs and their SHA-256 sums go in <code>modules/ffmpeg/fetch.jai</code>, as onnxruntime's are.</li>
<li><strong>Bindings:</strong> <code>modules/ffmpeg/macos.jai</code>, from the same headers with the dylib names, and <code>#load</code>ed on macOS.</li>
<li><strong>The video code:</strong> <code>video_ffmpeg.jai</code> and the import's video path, no longer Windows only.</li>
<li><strong>The <code>.app</code>:</strong> the dylibs go in it.</li>
</ul>
<p><strong>Done when:</strong> importing a folder with MP4, MOV and HEVC iPhone videos gives the same <code>.mcap</code> files as on Windows, with thumbnails, and they play with Open.</p>
</div>
<div class="phase">
<h3>Phase 5: The models on the GPU, with CoreML</h3>
<ul>
<li><strong>CoreML first:</strong> <code>session.jai</code> asks onnxruntime for the CoreML execution provider on macOS (<code>SessionOptionsAppendExecutionProvider</code> with <code>"CoreML"</code>, the ML Program format), and the CPU for what it can't take.</li>
<li><strong>Measured per model:</strong> PP-OCR detection and recognition, SigLIP 2, Mask2Former. Each is timed against the CPU and checked to give the same output as on Windows, within the tolerances the Windows probes already use.</li>
<li><strong>The status line</strong> says CoreML, as it says CUDA on Windows.</li>
</ul>
<p><strong>Done when:</strong> OCR, labels and segmentation give the same results as on Windows on the probes' test images, and each is faster than the CPU, or is left on the CPU with the reason recorded.</p>
</div>
<div class="phase">
<h3>Phase 6: Screen capture stills</h3>
<ul>
<li><strong>The shim's screenshot</strong> (<code>mcap_sck_screenshot</code>, per display).</li>
<li><strong>The overlay:</strong> a window per screen, with the drag, magnifier, keys, and a click for the whole screen.</li>
<li><strong>The window it came from:</strong> its title and app.</li>
<li><strong>The clipboard</strong> (phase 2).</li>
<li><strong>The capture hotkey</strong> (open question 3).</li>
<li><strong>Permission:</strong> the check, and what mcap says when Screen Recording isn't granted.</li>
</ul>
<p><strong>Done when:</strong> on a Mac with a Retina screen and an external non-Retina one:</p>
<ul>
<li>The hotkey freezes both screens.</li>
<li>A region dragged on either is taken at the screen's own pixels, and is on the clipboard and in Screen Captures with its window's title and app.</li>
<li>With the permission refused, mcap says how to grant it.</li>
</ul>
</div>
<div class="phase">
<h3>Phase 7: Screen recording</h3>
<ul>
<li><strong>The source:</strong> <code>modules/Screen_Record/sck_source.jai</code> over the shim's stream, as the third source. The encoder tries <code>h264_videotoolbox</code> first.</li>
<li><strong>Sound:</strong> system sound and the microphone, through the existing mixer.</li>
<li><strong>The overlay's video mode</strong>, with the 240 × 180 minimum.</li>
<li><strong>The border and toolbar</strong> as panels, kept out of the stream by the content filter. Pause, Discard, the sound buttons, and leftovers after a crash.</li>
</ul>
<p><strong>Done when</strong> the same checks pass as on Windows:</p>
<ul>
<li><code>record-probe</code> runs on the Mac, with <code>-sync-test</code>, <code>-pause</code> and <code>-mic-toggle</code>.</li>
<li>Neither border nor toolbar is in a recording.</li>
<li>A 10 s pause leaves no gap.</li>
<li>Beeps stay within about 25 ms of their flashes with no drift over 3 minutes.</li>
<li>A killed recording is offered at the next start.</li>
</ul>
</div>
<div class="phase">
<h3>Phase 8: A signed release</h3>
<ul>
<li><strong><code>release.jai</code> for macOS:</strong> the <code>.app</code> with everything in it (SDL3, onnxruntime, FFmpeg, the models), signed with a Developer ID with the hardened runtime and the entitlements it needs.</li>
<li><strong>Notarized and stapled.</strong></li>
<li><strong>Packaged</strong> in a <code>.dmg</code>, with its SHA-256 printed, as the Windows zip's is.</li>
</ul>
<p><strong>Done when:</strong> the <code>.dmg</code>, downloaded on a second Mac, opens without a Gatekeeper warning, asks once for Screen Recording, and keeps that permission across an update to the next release.</p>
</div>
<h2 id="risks">7. Risks</h2>
<div class="risk"><strong>No Mac in this loop yet.</strong> Everything here can be written on Windows, but only built, run and checked on a Mac. Each phase needs a session there, yours or a remote one (open question 1). The Linux type check catches the shared files in between, but not the Objective-C or the macOS-only files.</div>
<div class="risk"><strong>CoreML and the models.</strong> CoreML takes some operators and not others. Mask2Former's attention and the panoptic post-processing may fall back to the CPU in parts, which can be slower than the CPU alone, as the data moves back and forth. Phase 5 measures each model, and keeps the CPU where CoreML doesn't win.</div>
<div class="risk"><strong>Permissions and signing.</strong> Without a stable signature, Screen Recording has to be granted again after every build, and a refused permission gives a capture of the empty desktop rather than an error. Development needs a certificate early, by phase 6.</div>
<div class="risk"><strong>FFmpeg licensing.</strong> The Windows FFmpeg is a GPL build (with libx264). On macOS an LGPL build is enough: VideoToolbox does H.264, and FFmpeg's own AAC encoder does sound. That avoids GPL obligations for the Mac release; the Windows release should be checked against the same question.</div>
<div class="risk"><strong>Multi-screen overlays.</strong> One window per screen, with Spaces, full-screen apps and screens at different scales, is where macOS differs most from Windows. The overlay must sit above full-screen apps (<code>NSWindowCollectionBehaviorCanJoinAllSpaces</code> and <code>FullScreenAuxiliary</code>).</div>
<h2 id="questions">8. Open questions</h2>
<ol>
<li><strong>The Mac:</strong> which Mac and macOS version will the work be checked on? Can I build and run there (SSH, or a remote session), or will you run the builds and send back the output?</li>
<li><strong>The oldest macOS:</strong> 14 Sonoma, for ScreenCaptureKit's screenshots, is proposed. 13 would need the deprecated <code>CGWindowListCreateImage</code> for stills; 15 would give the microphone in the stream itself.</li>
<li><strong>The keys:</strong> Mac keyboards have no PrintScreen, and ⌘⇧3/4/5 are the system's. Proposed: <strong>⌘⇧2</strong> for a still and <strong>⌃⌘⇧2</strong> to record, or F13 (PrintScreen on Apple's full keyboards) and ⌃F13, or your own.</li>
<li><strong>Signing:</strong> do you have, or want, an Apple Developer account (99 USD a year) for a stable development signature now and Developer ID notarization for releases? Without it, phases 6 and 7 work but ask for permission after every build.</li>
<li><strong>The library on a Mac:</strong> where should it default to? Proposed: <code>~/Pictures/mcap</code>. Should the library folder become a setting on both systems, in place of the hard-coded <code>D:/McapIndex/mcap</code>?</li>
<li><strong>The shim:</strong> is a small Objective-C file built with clang acceptable in the repo, or should ScreenCaptureKit be done from Jai alone, at more cost and risk?</li>
</ol>
</main>
</body>
</html>

264
modules/Core_ML/module.jai Normal file
View File

@ -0,0 +1,264 @@
// Core_ML: a Core ML model (an .mlpackage made by coremltools) run on one float tensor, on macOS.
// The models that run faster here than through onnxruntime on the CPU (tools/export_siglip_coreml.py
// says how much) use it; the rest stay with modules/onnxruntime.
//
// model, ok, error := coreml_load(path, "pixel_values", "pooler_output");
// ok, error = coreml_run(model, input, s64.[1, 3, 384, 384], output); // output: the floats it gives
// coreml_free(model);
//
// Loading compiles the package (Core ML's compileModelAtURL, a fraction of a second) into a temporary
// .mlmodelc, removed by coreml_free. What Core ML makes of a model for the GPU, the first time each
// size runs, is kept by macOS across runs (the very first run of the OCR models took 5 s more, later
// ones none). The model runs on the CPU and GPU, never the Neural Engine
// unless asked: with float16 weights it can be badly wrong there (SigLIP 2: cosine 0.72 to the CPU).
// A model runs one prediction at a time (a mutex), from any thread; outputs are read outside it.
Coreml_Units :: enum s64 { // MLComputeUnits
CPU_ONLY :: 0;
CPU_AND_GPU :: 1;
ALL :: 2;
CPU_AND_ENGINE :: 3;
}
Coreml_Model :: struct {
model: *NSObject; // MLModel, retained
compiled: *NSObject; // the .mlmodelc's NSURL, retained; removed on free
input_name, output_name: string;
mutex: Mutex;
}
coreml_load :: (path: string, input_name: string, output_name: string, units := Coreml_Units.CPU_AND_GPU) -> *Coreml_Model, ok: bool, error: string {
init_objective_c(); // before anything Objective-C, the pool included; once per process does it all
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
if !file_exists(path) return null, false, tprint("% is not there.", path);
package := NSURL.fileURLWithPath(NSString.getTempString(path));
error: *NSError;
func_compile :: #type (*void, Selector, *NSURL, **NSError) -> *NSURL #c_call;
compiled := (cast(func_compile) objc_msgSend)(objc_getClass("MLModel"), selector("compileModelAtURL:error:"), package, *error);
if !compiled return null, false, tprint("Core ML could not compile %: %", path, error_text(error));
configuration := objc_init(objc_alloc(objc_getClass("MLModelConfiguration")));
defer release(configuration);
func_units :: #type (*void, Selector, s64) -> void #c_call;
(cast(func_units) objc_msgSend)(configuration, selector("setComputeUnits:"), cast(s64) units);
func_load :: #type (*void, Selector, *NSURL, *NSObject, **NSError) -> *NSObject #c_call;
model := (cast(func_load) objc_msgSend)(objc_getClass("MLModel"), selector("modelWithContentsOfURL:configuration:error:"), compiled, configuration, *error);
if !model {
remove_compiled(compiled);
return null, false, tprint("Core ML could not load %: %", path, error_text(error));
}
result := New(Coreml_Model,, context.default_allocator);
result.model = xx retain(model);
result.compiled = xx retain(compiled);
result.input_name = copy_string(input_name,, context.default_allocator);
result.output_name = copy_string(output_name,, context.default_allocator);
init(*result.mutex);
return result, true, "";
}
coreml_free :: (model: *Coreml_Model) {
if !model return;
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
release(model.model);
remove_compiled(cast(*NSURL) model.compiled);
release(model.compiled);
destroy(*model.mutex);
free(model.input_name,, context.default_allocator);
free(model.output_name,, context.default_allocator);
free(model,, context.default_allocator);
}
// A run's output, as float32 values one after another (C order): a view of Core ML's own array when it
// is float32 and packed, else a packed copy. Valid until coreml_release_output.
Coreml_Output :: struct {
data: *float;
dims: [8] s64;
dim_count: s64;
count: s64;
array: *NSObject; // the MLMultiArray, retained
copy: *float; // when data is a copy: the default allocator's
}
// Runs the model on input, a float32 tensor of shape: its output (model.output_name). ok is false,
// with why, when it fails.
coreml_predict :: (model: *Coreml_Model, input: [] float, shape: [] s64) -> Coreml_Output, ok: bool, error: string {
outs: [1] Coreml_Output;
ok, error := coreml_predict_outputs(model, input, shape, .[model.output_name], outs);
return outs[0], ok, error;
}
// As coreml_predict, for a model with several outputs: those named, into outs (one each, in order),
// each released with coreml_release_output. On failure none is set.
coreml_predict_outputs :: (model: *Coreml_Model, input: [] float, shape: [] s64, names: [] string, outs: [] Coreml_Output) -> ok: bool, error: string {
assert(names.count == outs.count);
init_objective_c();
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
// The input, over the caller's floats: packed, so each stride is the product of the sizes after it.
strides := NewArray(shape.count, s64,, temp);
step: s64 = 1;
for < shape { strides[it_index] = step; step *= it; }
if step != input.count return false, tprint("the input has % values, not % for its shape %", input.count, step, shape);
error: *NSError;
func_array_init :: #type (*void, Selector, *void, *NSObject, s64, *NSObject, *void, **NSError) -> *NSObject #c_call;
array := (cast(func_array_init) objc_msgSend)(objc_alloc(objc_getClass("MLMultiArray")), selector("initWithDataPointer:shape:dataType:strides:deallocator:error:"),
input.data, numbers(shape), MULTIARRAY_FLOAT32, numbers(strides), null, *error);
if !array return false, tprint("Core ML could not take the input: %", error_text(error));
autorelease(array);
func_value :: #type (*void, Selector, *NSObject) -> *NSObject #c_call;
value := (cast(func_value) objc_msgSend)(objc_getClass("MLFeatureValue"), selector("featureValueWithMultiArray:"), array);
key := NSString.getTempString(model.input_name);
func_dictionary :: #type (*void, Selector, *NSObject, *NSObject) -> *NSObject #c_call;
dictionary := (cast(func_dictionary) objc_msgSend)(objc_getClass("NSDictionary"), selector("dictionaryWithObject:forKey:"), value, key);
func_provider :: #type (*void, Selector, *NSObject, **NSError) -> *NSObject #c_call;
features := (cast(func_provider) objc_msgSend)(objc_alloc(objc_getClass("MLDictionaryFeatureProvider")), selector("initWithDictionary:error:"), dictionary, *error);
if !features return false, tprint("Core ML could not take the input: %", error_text(error));
autorelease(features);
func_predict :: #type (*void, Selector, *NSObject, **NSError) -> *NSObject #c_call;
lock(*model.mutex);
prediction := (cast(func_predict) objc_msgSend)(model.model, selector("predictionFromFeatures:error:"), features, *error);
unlock(*model.mutex);
if !prediction return false, tprint("Core ML could not run the model: %", error_text(error));
for names {
out, ok, read_error := read_output(prediction, it);
if !ok {
for 0..it_index - 1 coreml_release_output(*outs[it]);
return false, read_error;
}
outs[it_index] = out;
}
return true, "";
}
coreml_release_output :: (out: *Coreml_Output) {
if out.array release(out.array);
if out.copy free(out.copy,, context.default_allocator);
out.* = .{};
}
// coreml_predict, its output copied into output (which must hold exactly as many values).
coreml_run :: (model: *Coreml_Model, input: [] float, shape: [] s64, output: [] float) -> ok: bool, error: string {
out, ok, error := coreml_predict(model, input, shape);
if !ok return false, error;
defer coreml_release_output(*out);
if out.count != output.count return false, tprint("the model gives % values, not %", out.count, output.count);
memcpy(output.data, out.data, out.count * size_of(float));
return true, "";
}
#scope_file
// The output named name of a prediction (an MLFeatureProvider), as float32 values in C order.
read_output :: (prediction: *NSObject, name: string) -> Coreml_Output, ok: bool, error: string {
result: Coreml_Output;
func_value :: #type (*void, Selector, *NSObject) -> *NSObject #c_call;
out_value := (cast(func_value) objc_msgSend)(prediction, selector("featureValueForName:"), NSString.getTempString(name));
out_array := ifx out_value then cast(*NSObject) objc_msgSend_typed(out_value, selector("multiArrayValue")) else null;
if !out_array return result, false, tprint("the model gives no %", name);
out_shape := integers(cast(*NSObject) objc_msgSend_typed(out_array, selector("shape")));
out_strides := integers(cast(*NSObject) objc_msgSend_typed(out_array, selector("strides")));
if out_shape.count > result.dims.count || out_shape.count != out_strides.count return result, false, tprint("the model's output has an unexpected shape %", out_shape);
result.dim_count = out_shape.count;
result.count = 1;
for out_shape { result.dims[it_index] = it; result.count *= it; }
func_type :: #type (*void, Selector) -> s64 #c_call;
type := (cast(func_type) objc_msgSend)(out_array, selector("dataType"));
if type != MULTIARRAY_FLOAT32 && type != MULTIARRAY_FLOAT16 return result, false, tprint("the model's output is of type 0x%, not float32 or float16", formatInt(type, base = 16));
packed := true;
expected: s64 = 1;
for < out_shape { if out_strides[it_index] != expected packed = false; expected *= it; }
data := objc_msgSend_typed(out_array, selector("dataPointer"));
result.array = xx retain(out_array); // outlives the pool, and with it the data
if type == MULTIARRAY_FLOAT32 && packed {
result.data = cast(*float) data;
return result, true, "";
}
// A copy, packed: float16 widened, and rows Core ML padded (GPU outputs may be) closed up.
result.copy = cast(*float) alloc(result.count * size_of(float),, context.default_allocator);
result.data = result.copy;
index: [8] s64;
for i: 0..result.count - 1 {
offset: s64 = 0;
for d: 0..result.dim_count - 1 offset += index[d] * out_strides[d];
result.copy[i] = ifx type == MULTIARRAY_FLOAT32 then (cast(*float) data)[offset] else float16_to_float32((cast(*u16) data)[offset]);
d := result.dim_count - 1;
while d >= 0 { index[d] += 1; if index[d] < out_shape[d] break; index[d] = 0; d -= 1; }
}
return result, true, "";
}
MULTIARRAY_FLOAT32 :: 0x10000 | 32; // MLMultiArrayDataTypeFloat32
MULTIARRAY_FLOAT16 :: 0x10000 | 16; // MLMultiArrayDataTypeFloat16
numbers :: (values: [] s64) -> *NSObject {
objects := NewArray(values.count, *NSObject,, temp);
func_number :: #type (*void, Selector, s64) -> *NSObject #c_call;
for values objects[it_index] = (cast(func_number) objc_msgSend)(objc_getClass("NSNumber"), selector("numberWithLongLong:"), it);
func_array :: #type (*void, Selector, **NSObject, u64) -> *NSObject #c_call;
return (cast(func_array) objc_msgSend)(objc_getClass("NSArray"), selector("arrayWithObjects:count:"), objects.data, xx objects.count);
}
// An NSArray of NSNumbers, as integers. Temp.
integers :: (array: *NSObject) -> [] s64 {
if !array return .[];
count := cast(s64) objc_msgSend_typed(array, selector("count"));
result := NewArray(count, s64,, temp);
func_at :: #type (*void, Selector, u64) -> *NSObject #c_call;
func_value :: #type (*void, Selector) -> s64 #c_call;
for 0..count - 1 {
number := (cast(func_at) objc_msgSend)(array, selector("objectAtIndex:"), xx it);
result[it] = (cast(func_value) objc_msgSend)(number, selector("longLongValue"));
}
return result;
}
error_text :: (error: *NSError) -> string {
if !error return "no reason given";
return copy_string(to_string(NSError.localizedDescription(error)),, temp);
}
remove_compiled :: (url: *NSURL) {
func :: #type (*void, Selector, *NSURL, **NSError) -> BOOL #c_call;
(cast(func) objc_msgSend)(NSFileManager.defaultManager(), selector("removeItemAtURL:error:"), url, null);
}
float16_to_float32 :: (h: u16) -> float {
sign := (cast(u32) h & 0x8000) << 16;
exponent := (cast(u32) h >> 10) & 0x1F;
mantissa := cast(u32) h & 0x3FF;
bits: u32;
if exponent == 0 {
if mantissa == 0 bits = sign;
else { // subnormal: normalised
e: u32 = 113;
while !(mantissa & 0x400) { mantissa <<= 1; e -= 1; }
bits = sign | (e << 23) | ((mantissa & 0x3FF) << 13);
}
} else if exponent == 31 bits = sign | 0x7F80_0000 | (mantissa << 13);
else bits = sign | ((exponent + 112) << 23) | (mantissa << 13);
return (cast(*float) *bits).*;
}
CoreML_Framework :: #library,system,link_always "CoreML";
#import "Basic";
#import "File";
#import "File_Utilities";
#import "Thread";
#import "Objective_C";
#import "Objective_C/AppKit";

View File

@ -258,7 +258,7 @@ heif_info :: (bytes: [] u8) -> Heif_Info {
box := properties[index - 1];
type := cast(string) slice_bytes(box, 4, 4);
if type == "ispe" && box.count >= 20 { info.width = be32(box, 12); info.height = be32(box, 16); }
if type == "irot" && box.count >= 9 info.rotation = (box[8] & 3) * 90;
if type == "irot" && box.count >= 9 info.rotation = cast(s64) (box[8] & 3) * 90; // as a u8, 270 wrapped to 14
if type == "imir" && box.count >= 9 info.mirror = box[8] & 1;
}
}

View File

@ -0,0 +1,149 @@
// ImageIO, macOS's own codecs: HEIC and AVIF, WebP, TIFF and the camera RAW formats, as WIC reads them
// on Windows. Only the few Core Foundation, ImageIO and Core Graphics calls used are declared.
// The primary image in bytes, as non-premultiplied RGBA (default allocator), as stored: not turned.
// orientation is what ImageIO says the image's orientation is (1-8, from a HEIF's irot and imir, or
// its EXIF), for image_decode to apply where exif.jai cannot read it.
//
// The pixels keep the image's own RGB color space, as WIC and stb_image leave them; only gray, CMYK
// and the like are converted, to sRGB.
imageio_decode :: (bytes: [] u8) -> Image, ok: bool, error: string, orientation: s64 {
image: Image;
// Not copied: the source is let go of before this returns.
data := CFDataCreateWithBytesNoCopy(null, bytes.data, bytes.count, kCFAllocatorNull);
if !data return image, false, "ImageIO could not take the bytes", 1;
defer CFRelease(data);
source := CGImageSourceCreateWithData(data, null);
if !source return image, false, "ImageIO cannot read it", 1;
defer CFRelease(source);
if CGImageSourceGetCount(source) < 1 return image, false, "ImageIO finds no image in it", 1;
index := CGImageSourceGetPrimaryImageIndex(source);
cg := CGImageSourceCreateImageAtIndex(source, index, null);
if !cg return image, false, "ImageIO cannot decode it", 1;
defer CGImageRelease(cg);
orientation: s32 = 1;
properties := CGImageSourceCopyPropertiesAtIndex(source, index, null);
if properties {
number := CFDictionaryGetValue(properties, kCGImagePropertyOrientation);
if number CFNumberGetValue(number, kCFNumberSInt32Type, *orientation);
CFRelease(properties);
}
w := CGImageGetWidth(cg);
h := CGImageGetHeight(cg);
if !w || !h return image, false, "ImageIO gives it no size", orientation;
if w * h * 4 > U32_MAX return image, false, tprint("it is % x %, too large to decode in one piece", w, h), orientation;
// Opaque images are drawn as 8-bit RGB with the fourth byte left out, so their pixels are exact.
// Others are drawn premultiplied, the only RGBA a bitmap context takes, at 16 bits a channel so
// that dividing back gives the 8-bit colors again, even where alpha is small.
alpha_info := CGImageGetAlphaInfo(cg);
opaque := alpha_info == kCGImageAlphaNone || alpha_info == kCGImageAlphaNoneSkipLast || alpha_info == kCGImageAlphaNoneSkipFirst;
bits: u64 = 16;
bitmap_info: u32 = kCGBitmapByteOrder16Little | kCGImageAlphaPremultipliedLast;
if opaque { bits = 8; bitmap_info = kCGBitmapByteOrder32Big | kCGImageAlphaNoneSkipLast; }
row_bytes := w * bits / 2;
count := cast(s64) (w * h);
pixels := cast(*u8) alloc(count * 4,, context.default_allocator);
drawn := pixels;
if !opaque drawn = cast(*u8) alloc(count * 8,, context.default_allocator);
defer if drawn != pixels free(drawn,, context.default_allocator);
memset(drawn, 0, cast(s64) (row_bytes * h));
space := CGImageGetColorSpace(cg);
own_space := space && CGColorSpaceGetModel(space) == kCGColorSpaceModelRGB;
bitmap: CGContextRef;
if own_space bitmap = CGBitmapContextCreate(drawn, w, h, bits, row_bytes, space, bitmap_info);
if !bitmap {
// Not RGB, or a space a bitmap context cannot draw in (an extended range one): converted to sRGB.
srgb := CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
defer CGColorSpaceRelease(srgb);
bitmap = CGBitmapContextCreate(drawn, w, h, bits, row_bytes, srgb, bitmap_info);
}
if !bitmap {
free(pixels,, context.default_allocator);
return image, false, "Core Graphics could not make a bitmap to draw it in", orientation;
}
CGContextSetBlendMode(bitmap, kCGBlendModeCopy);
CGContextDrawImage(bitmap, .{.{0, 0}, .{cast(float64) w, cast(float64) h}}, cg);
CGContextRelease(bitmap);
if opaque {
for 0..count - 1 pixels[it * 4 + 3] = 255;
} else {
wide := cast(*u16) drawn;
for 0..count - 1 {
in := wide + it * 4;
out := pixels + it * 4;
a := cast(u32) in[3];
out[3] = cast(u8) ((a * 255 + 32767) / 65535);
if a == 0 { out[0], out[1], out[2] = 0; continue; }
for c: 0..2 out[c] = cast(u8) min(255, (cast(u32) in[c] * 255 + a / 2) / a);
}
}
image = .{pixels = pixels, width = cast(s32) w, height = cast(s32) h};
return image, true, "", orientation;
}
#scope_file
CFTypeRef :: *void;
CFDataRef :: *void;
CFDictionaryRef :: *void;
CFStringRef :: *void;
CFAllocatorRef :: *void;
CGImageSourceRef :: *void;
CGImageRef :: *void;
CGColorSpaceRef :: *void;
CGContextRef :: *void;
CGPoint :: struct { x, y: float64; }
CGSize :: struct { width, height: float64; }
CGRect :: struct { origin: CGPoint; size: CGSize; }
kCFNumberSInt32Type :: 3;
kCGColorSpaceModelRGB :: 1;
kCGBlendModeCopy :: 17;
kCGImageAlphaNone :: 0;
kCGImageAlphaPremultipliedLast :: 1;
kCGImageAlphaNoneSkipLast :: 5;
kCGImageAlphaNoneSkipFirst :: 6;
kCGBitmapByteOrder16Little :: 1 << 12;
kCGBitmapByteOrder32Big :: 4 << 12;
CoreFoundation :: #library,system "CoreFoundation";
ImageIO :: #library,system "ImageIO";
CoreGraphics :: #library,system "CoreGraphics";
kCFAllocatorNull: CFAllocatorRef #elsewhere CoreFoundation;
kCGImagePropertyOrientation: CFStringRef #elsewhere ImageIO;
kCGColorSpaceSRGB: CFStringRef #elsewhere CoreGraphics;
CFDataCreateWithBytesNoCopy :: (allocator: CFAllocatorRef, bytes: *u8, length: s64, deallocator: CFAllocatorRef) -> CFDataRef #foreign CoreFoundation;
CFRelease :: (object: CFTypeRef) #foreign CoreFoundation;
CFDictionaryGetValue :: (dictionary: CFDictionaryRef, key: *void) -> *void #foreign CoreFoundation;
CFNumberGetValue :: (number: *void, type: s64, value: *void) -> u8 #foreign CoreFoundation;
CGImageSourceCreateWithData :: (data: CFDataRef, options: CFDictionaryRef) -> CGImageSourceRef #foreign ImageIO;
CGImageSourceGetCount :: (source: CGImageSourceRef) -> u64 #foreign ImageIO;
CGImageSourceGetPrimaryImageIndex :: (source: CGImageSourceRef) -> u64 #foreign ImageIO;
CGImageSourceCreateImageAtIndex :: (source: CGImageSourceRef, index: u64, options: CFDictionaryRef) -> CGImageRef #foreign ImageIO;
CGImageSourceCopyPropertiesAtIndex :: (source: CGImageSourceRef, index: u64, options: CFDictionaryRef) -> CFDictionaryRef #foreign ImageIO;
CGImageGetWidth :: (image: CGImageRef) -> u64 #foreign CoreGraphics;
CGImageGetHeight :: (image: CGImageRef) -> u64 #foreign CoreGraphics;
CGImageGetAlphaInfo :: (image: CGImageRef) -> u32 #foreign CoreGraphics;
CGImageGetColorSpace :: (image: CGImageRef) -> CGColorSpaceRef #foreign CoreGraphics;
CGImageRelease :: (image: CGImageRef) #foreign CoreGraphics;
CGColorSpaceGetModel :: (space: CGColorSpaceRef) -> s32 #foreign CoreGraphics;
CGColorSpaceCreateWithName :: (name: CFStringRef) -> CGColorSpaceRef #foreign CoreGraphics;
CGColorSpaceRelease :: (space: CGColorSpaceRef) #foreign CoreGraphics;
CGBitmapContextCreate :: (data: *void, width: u64, height: u64, bits_per_component: u64, bytes_per_row: u64, space: CGColorSpaceRef, bitmap_info: u32) -> CGContextRef #foreign CoreGraphics;
CGContextSetBlendMode :: (context_ref: CGContextRef, mode: s32) #foreign CoreGraphics;
CGContextDrawImage :: (context_ref: CGContextRef, rect: CGRect, image: CGImageRef) #foreign CoreGraphics;
CGContextRelease :: (context_ref: CGContextRef) #foreign CoreGraphics;

View File

@ -1,21 +1,23 @@
// Image_Decode: any image file's bytes to RGBA pixels, the right way up, and the PNG previews .mcap
// files hold. stb_image reads PNG, JPEG, GIF, BMP, PSD and TGA; everything else (HEIC, AVIF, WebP,
// TIFF, camera RAW) goes to Windows' own codecs through WIC, which read what the installed Store
// extensions (HEIF, WebP, Raw Image) can. A photo's EXIF orientation is applied, so a phone's portrait
// TIFF, camera RAW) goes to the system's own codecs: on Windows WIC, which reads what the installed
// Store extensions (HEIF, WebP, Raw Image) can, and on macOS ImageIO (imageio.jai). Linux reads only
// what stb_image does. A photo's EXIF orientation is applied, so a phone's portrait
// photo comes out standing up.
//
// What the bytes are comes from their first bytes (image_kind), never from a file name, except for
// the camera RAW formats that are TIFF inside: image_kind_from_name tells those apart.
//
// Videos (video.jai) are told apart here too, by their first bytes or their extension; video_poster
// reads a frame from one with FFmpeg, on Windows, and gif_info counts an animated GIF's frames.
// reads a frame from one with FFmpeg, on Windows and macOS, and gif_info counts an animated GIF's frames.
//
// Thread-safe: every call stands alone, and WIC is set up and released on the calling thread.
// Thread-safe: every call stands alone, and WIC is set up and released on the calling thread; ImageIO
// needs no setting up.
Image_Kind :: enum u8 {
UNKNOWN;
PNG; JPEG; GIF; BMP; PSD; TGA; // stb_image
WEBP; HEIF; AVIF; TIFF; RAW; // WIC
WEBP; HEIF; AVIF; TIFF; RAW; // WIC on Windows, ImageIO on macOS
VIDEO; // MP4, QuickTime, Matroska and WebM, AVI, WMV, MPEG: see video_poster
}
@ -154,6 +156,7 @@ image_decode :: (bytes: [] u8, kind := Image_Kind.UNKNOWN, orient := true) -> Im
error = to_string(stbi_failure_reason());
}
}
container_orientation := 0; // ImageIO's, for HEIF and AVIF on macOS; 0 when not known
if !decoded {
#if OS == .WINDOWS {
wic_ok: bool;
@ -163,14 +166,25 @@ image_decode :: (bytes: [] u8, kind := Image_Kind.UNKNOWN, orient := true) -> Im
if error return image, false, tprint("% (stb_image: %)", wic_error, error);
return image, false, wic_error;
}
} else #if OS == .MACOS {
imageio_ok: bool;
imageio_error: string;
image, imageio_ok, imageio_error, container_orientation = imageio_decode(bytes);
if !imageio_ok {
if error return image, false, tprint("% (stb_image: %)", imageio_error, error);
return image, false, imageio_error;
}
} else {
return image, false, ifx error then tprint("stb_image: %", error) else tprint("% images are only read on Windows", image_kind_name(kind));
return image, false, ifx error then tprint("stb_image: %", error) else tprint("% images are only read on Windows and macOS", image_kind_name(kind));
}
}
if orient {
orientation := 1;
if kind == .HEIF || kind == .AVIF {
if (kind == .HEIF || kind == .AVIF) && container_orientation {
// ImageIO gives the image as stored, and the container's irot and imir as an orientation.
orientation = container_orientation;
} else if kind == .HEIF || kind == .AVIF {
// The container's irot and imir are the orientation; the EXIF tag only repeats it. WIC applies
// them (HEIF requires a reader to); a quarter turn it left out shows as the sides not swapped.
heif := heif_info(bytes);
@ -251,7 +265,8 @@ png_size :: (png: [] u8) -> width: s64, height: s64, ok: bool {
#load "exif.jai";
#load "video.jai";
#if OS == .WINDOWS #load "wic.jai";
#if OS == .WINDOWS #load "video_ffmpeg.jai";
#if OS == .MACOS #load "imageio.jai";
#if OS == .WINDOWS || OS == .MACOS #load "video_ffmpeg.jai";
#scope_module

View File

@ -24,9 +24,9 @@ Video_Info :: struct {
// start with; the first frame of a clip shorter than a second.
VIDEO_POSTER_MAX_SECONDS :: 3.0;
#if OS != .WINDOWS {
#if OS == .LINUX {
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
return .{}, .{}, false, "videos are only read on Windows";
return .{}, .{}, false, "videos are only read on Windows and macOS";
}
}

View File

@ -1,4 +1,4 @@
// video_poster on Windows, with FFmpeg (modules/ffmpeg). See video.jai.
// video_poster on Windows and macOS, with FFmpeg (modules/ffmpeg). See video.jai.
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
image: Image;

View File

@ -52,7 +52,8 @@ sha256_of :: (path: string) -> string {
if lines.count < 2 return "";
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
} else {
command := ifx OS == .MACOS then string.["shasum", "-a", "256", path] else string.["sha256sum", path];
#if OS == .MACOS command := string.["shasum", "-a", "256", path];
else command := string.["sha256sum", path];
result, output := run_command(..command, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));

View File

@ -10,6 +10,8 @@
# logit_scale, logit_bias a phrase's score for an image is sigmoid(e^logit_scale * cosine + logit_bias),
# from the trained model (they are not in the ONNX encoders)
# image_batch images run through the vision model together
# vision_coreml on macOS, the vision model as a Core ML package, quoted, run on the GPU
# (tools/export_siglip_coreml.py makes it); without it, onnxruntime runs it
[model siglip2-base-patch16-384]
title: "SigLIP 2 base, 384 px"
@ -21,3 +23,4 @@ max_tokens: 64
logit_scale: 4.726024
logit_bias: -16.772243
image_batch: 8
vision_coreml: "siglip2-base-patch16-384-vision.mlpackage"

View File

@ -8,9 +8,11 @@
//
// The models are listed in models_folder/models.config (fetch_models.jai downloads them) and run
// with onnxruntime (modules/onnxruntime): the vision model on an NVIDIA GPU through CUDA when it
// can, else on the CPU; the text model, small inputs and only a few at a time, on the CPU.
// can, else on the CPU; the text model, small inputs and only a few at a time, on the CPU. On macOS
// the vision model runs on the GPU through Core ML (modules/Core_ML) when its vision_coreml package
// is there (tools/export_siglip_coreml.py makes it): about ten times the CPU's speed.
Labels_Provider :: enum { CPU; CUDA; }
Labels_Provider :: enum { CPU; CUDA; COREML; }
// Tightly packed or not: stride is the bytes from one row to the next.
Labels_Image :: struct {
@ -22,6 +24,7 @@ Labels_Model_Info :: struct {
name: string;
title: string;
vision, text, tokenizer: string; // file names in the models folder
vision_coreml: string; // the vision model as a Core ML package, for macOS; "" for none
image_size: s32 = 384;
max_tokens: s32 = 64;
logit_scale: float = 0;
@ -36,6 +39,7 @@ Labels_Engine :: struct {
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
vision, text: Ort_Session;
#if OS == .MACOS coreml: *Coreml_Model; // the vision model, when it runs through Core ML
tokenizer: *Gemma_Tokenizer;
dimensions: s32; // of every vector
}
@ -75,6 +79,7 @@ labels_list_models :: (models_folder: string) -> [] Labels_Model_Info, error: st
case "vision"; model.vision = copy_string(value);
case "text"; model.text = copy_string(value);
case "tokenizer"; model.tokenizer = copy_string(value);
case "vision_coreml"; model.vision_coreml = copy_string(value);
case "image_size"; if number_ok model.image_size = cast(s32) number;
case "max_tokens"; if number_ok model.max_tokens = cast(s32) number;
case "logit_scale"; if number_ok model.logit_scale = number;
@ -113,11 +118,23 @@ labels_create :: (models_folder: string, model_name: string, prefer_gpu := true)
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1);
vision_path := tprint("%/%", models_folder, model.vision);
use_gpu := false;
if prefer_gpu {
#if OS == .MACOS if prefer_gpu && model.vision_coreml && !file_exists(tprint("%/%", models_folder, model.vision_coreml)) {
engine.gpu_explanation = sprint("Labelling runs on the CPU: % is not there; tools/export_siglip_coreml.py makes it.", model.vision_coreml);
} else if prefer_gpu && model.vision_coreml {
// Core ML on the GPU; on any failure, onnxruntime on the CPU below, with the reason.
coreml_path := tprint("%/%", models_folder, model.vision_coreml);
engine.coreml, ok, error = coreml_load(coreml_path, "pixel_values", "pooler_output");
if ok engine.provider = .COREML; // its first run is below, once the vectors' size is known
else engine.gpu_explanation = sprint("Labelling runs on the CPU: Core ML could not load % on the GPU:\n%", model.vision_coreml, error);
}
#if OS == .MACOS if prefer_gpu && !model.vision_coreml {
engine.gpu_explanation = sprint("Labelling runs on the CPU: there is no Core ML model for it; tools/export_siglip_coreml.py makes one.");
}
if prefer_gpu && OS != .MACOS {
available, explanation := ort_cuda_check("Labelling");
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else {
} else if !prefer_gpu {
engine.gpu_explanation = copy_string("Labelling runs on the CPU, as it was asked to.");
}
if use_gpu {
@ -133,7 +150,7 @@ labels_create :: (models_folder: string, model_name: string, prefer_gpu := true)
use_gpu = false;
}
}
if !use_gpu {
if !use_gpu && engine.provider != .COREML {
engine.vision, ok, error = ort_create_session(engine.env, vision_path, false, cores, output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
engine.provider = .CPU;
@ -144,6 +161,19 @@ labels_create :: (models_folder: string, model_name: string, prefer_gpu := true)
// The vectors' size, from a run of the text model on an empty text.
probe := labels_embed_texts(engine, .[""],, temp);
if !engine.dimensions { labels_destroy(engine); return null, false, "The text model gave no vector."; }
#if OS == .MACOS if engine.provider == .COREML {
// Core ML's first run; when it fails, onnxruntime on the CPU after all.
ok, error = labels_warm_up(engine);
if !ok {
coreml_free(engine.coreml);
engine.coreml = null;
engine.provider = .CPU;
engine.gpu_explanation = sprint("Labelling runs on the CPU: Core ML could not run % on the GPU:\n%", model.vision_coreml, error);
engine.vision, ok, error = ort_create_session(engine.env, vision_path, false, cores, output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
}
}
return engine, true, "";
}
@ -152,6 +182,7 @@ labels_destroy :: (engine: *Labels_Engine) {
if engine.provider == .CUDA ort_gpu_lock();
ort_release_session(*engine.vision);
if engine.provider == .CUDA ort_gpu_unlock();
#if OS == .MACOS coreml_free(engine.coreml);
ort_release_session(*engine.text);
tokenizer_free(engine.tokenizer,, context.default_allocator);
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
@ -161,7 +192,16 @@ labels_destroy :: (engine: *Labels_Engine) {
}
labels_gpu_status :: (engine: *Labels_Engine) -> in_use: bool, explanation: string {
return engine.provider == .CUDA, engine.gpu_explanation;
return engine.provider == .CUDA || engine.provider == .COREML, engine.gpu_explanation;
}
// What runs the vision model, for people and the files it labels: "CUDA", "Core ML" or "CPU".
labels_provider_name :: (engine: *Labels_Engine) -> string {
if engine.provider == {
case .CUDA; return "CUDA";
case .COREML; return "Core ML";
}
return "CPU";
}
// One unit vector per image, engine.dimensions long, one after another; from allocator. An image
@ -176,6 +216,18 @@ labels_embed_images :: (engine: *Labels_Engine, images: [] Labels_Image, allocat
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
defer array_free(input,, context.default_allocator);
#if OS == .MACOS if engine.coreml {
// One image a run: the Core ML model takes [1, 3, size, size].
out := NewArray(dims, float,, temp);
for images {
labels_prepare_image(it, size, input.data);
ok, run_error := coreml_run(engine.coreml, array_view(input, 0, plane * 3), s64.[1, 3, size, size], out);
if !ok { error = tprint("%", run_error); continue; }
labels_normalize_into(out.data, dims, result.data + it_index * dims);
}
return result, error;
}
first := 0;
while first < images.count {
count := min(batch, images.count - first);
@ -279,6 +331,13 @@ labels_warm_up :: (engine: *Labels_Engine) -> ok: bool, error: string {
size := engine.model.image_size;
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
#if OS == .MACOS if engine.coreml {
out := NewArray(engine.dimensions, float,, context.default_allocator);
defer array_free(out,, context.default_allocator);
ok, error := coreml_run(engine.coreml, input, s64.[1, 3, size, size], out);
if !ok return false, tprint("The first run on the GPU failed: %", error);
return true, "";
}
out, ok, error := ort_run_float(*engine.vision, engine.memory_info, input, s64.[1, 3, size, size]);
if !ok return false, tprint("The first run on the GPU failed: %", error);
ort_release_output(*out);
@ -374,8 +433,10 @@ labels_strip_comment :: (line: string) -> string {
#import "Basic";
#import "String";
#import "File";
#import "File_Utilities";
#import "Math";
#import "System";
#import "Hash_Table";
#import "Float16";
#import "onnxruntime";
#if OS == .MACOS #import "Core_ML";

137
modules/OCR/budget.jai Normal file
View File

@ -0,0 +1,137 @@
// On the GPU, how many detector and recognizer runs share it at once: as many as fit in its memory.
// Each run reserves what it is expected to need before it starts, and waits while the runs in flight
// leave too little; one run always goes. A run's need grows with its input, and the engine measures
// how fast when it is made, so it holds for any model and GPU: from the arena's peak (onnxruntime's
// MaxInUse) over runs on two input sizes. The limit is a share of the GPU's free memory, read again at
// the start of every ocr_process call, as other engines and programs come and go. A run must also fit
// in what the GPU has free when it starts: the arenas keep finished runs' blocks until the stage ends
// (ocr_shrink_arena), a run of another size can need a new block beside them, and cuDNN and cuBLAS
// take memory of their own, so the reservations alone let the server model reach 15.4 GB of 16.
//
// Without it, gpu_workers runs went at once whatever the images: the server model, four large images
// at a time, took 15 GB of a 16 GB GPU, and on a smaller GPU Windows pages the excess into shared
// system memory, running it many times slower.
Ocr_Budget :: struct {
enabled: bool; // on CUDA, when the engine could measure its runs
limit: s64; // bytes the runs in flight may reserve together
in_use: s64; // reserved by the runs in flight; under mutex
headroom: s64; // bytes left free on the GPU, beside the limit and when a run starts
recent: [8] Ocr_Budget_Grant; // the last runs let go, under mutex: what the GPU's free memory may not show yet
mutex: Mutex;
det_fixed, det_per_pixel: float64; // a detector run's need: det_fixed + det_per_pixel * height * width
rec_fixed, rec_per_column: float64; // a recognizer run's: rec_fixed + rec_per_column * lines * width
}
// A run let go, which may not have taken its memory yet.
Ocr_Budget_Grant :: struct { at: Apollo_Time; bytes: s64; }
OCR_BUDGET_GRANT_MS :: 50; // how long a run let go may take to take its memory
OCR_BUDGET_MARGIN :: 1.2; // estimates are raised by this much: other shapes, other kernels
OCR_BUDGET_HEADROOM :: 0.1; // of the free memory, left for everything else on the GPU
OCR_BUDGET_MIN_HEADROOM :: 512 * 1024 * 1024;
// Measures what the detector's and recognizer's runs need, from runs on two sizes each. Called at
// creation, under ort_gpu_lock, after ocr_warm_up; on failure the budget is off and every worker runs.
ocr_budget_calibrate :: (engine: *Ocr_Engine) {
b := *engine.budget;
// What one run needs, from the arena's peak, run after run of growing size so each sets a new peak.
need :: (engine: *Ocr_Engine, session: *Ocr_Session, shape: [4] s64) -> s64, ok: bool {
before, _, ok := ort_gpu_arena_stats(session);
if !ok return 0, false;
count := shape[0] * shape[1] * shape[2] * shape[3];
input := NewArray(count, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
out, run_ok := ocr_run(session, engine.memory_info, input, shape);
if !run_ok return 0, false;
ocr_release_output(*out);
_, peak, stats_ok := ort_gpu_arena_stats(session);
if !stats_ok || peak <= before return 0, false;
return peak - before, true;
}
fit :: (x0: float64, y0: s64, x1: float64, y1: s64) -> fixed: float64, slope: float64 {
slope := max(cast(float64) (y1 - y0) / (x1 - x0), 0);
return max(y0 - slope * x0, 0), slope;
}
// The detector at 512 and 1024 px square; the recognizer on a full batch 256 and 1024 px wide.
d0, ok0 := need(engine, *engine.det, .[1, 3, 512, 512]);
d1, ok1 := need(engine, *engine.det, .[1, 3, 1024, 1024]);
lines := cast(s64) max(engine.model.rec_batch_gpu, 1);
height := cast(s64) engine.model.rec_height;
r0, ok2 := need(engine, *engine.rec, .[lines, 3, height, 256]);
r1, ok3 := need(engine, *engine.rec, .[lines, 3, height, 1024]);
ocr_shrink_arena(engine, *engine.det);
ocr_shrink_arena(engine, *engine.rec);
if !ok0 || !ok1 || !ok2 || !ok3 return;
b.det_fixed, b.det_per_pixel = fit(512.0 * 512, d0, 1024.0 * 1024, d1);
b.rec_fixed, b.rec_per_column = fit(cast(float64) lines * 256, r0, cast(float64) lines * 1024, r1);
init(*b.mutex);
b.enabled = true;
}
// The share of the GPU's free memory the runs of this call may take; at the start of ocr_process.
ocr_budget_update :: (engine: *Ocr_Engine) {
b := *engine.budget;
if !b.enabled return;
free, _, ok := ort_cuda_memory();
if !ok return; // the last limit stands
headroom := max(cast(s64) (free * OCR_BUDGET_HEADROOM), OCR_BUDGET_MIN_HEADROOM);
lock(*b.mutex);
b.headroom = headroom;
b.limit = max(free - headroom, 0);
unlock(*b.mutex);
}
ocr_budget_detector :: (engine: *Ocr_Engine, height: s64, width: s64) -> s64 {
b := *engine.budget;
return cast(s64) ((b.det_fixed + b.det_per_pixel * height * width) * OCR_BUDGET_MARGIN);
}
ocr_budget_recognizer :: (engine: *Ocr_Engine, lines: s64, width: s64) -> s64 {
b := *engine.budget;
return cast(s64) ((b.rec_fixed + b.rec_per_column * lines * width) * OCR_BUDGET_MARGIN);
}
// Waits until bytes fit beside the runs in flight and in the GPU's free memory (at once when no run
// is in flight), and reserves them.
ocr_budget_reserve :: (engine: *Ocr_Engine, bytes: s64) {
b := *engine.budget;
if !b.enabled return;
waited := false;
while true {
free, _, free_ok := ort_cuda_memory();
lock(*b.mutex);
fits := b.in_use == 0;
if !fits && b.in_use + bytes <= b.limit {
// Runs let go in the last moments may not show in free yet.
now := current_time_monotonic();
pending: s64;
for b.recent if it.bytes && to_milliseconds(now - it.at) < OCR_BUDGET_GRANT_MS pending += it.bytes;
fits = !free_ok || bytes + pending <= free - b.headroom;
}
if fits {
b.in_use += bytes;
oldest := 0;
for b.recent if it.at < b.recent[oldest].at oldest = it_index;
b.recent[oldest] = .{current_time_monotonic(), bytes};
}
unlock(*b.mutex);
if fits break;
if !waited { waited = true; atomic_add(*engine.stats.budget_waits, 1); }
sleep_milliseconds(1);
}
}
ocr_budget_release :: (engine: *Ocr_Engine, bytes: s64) {
b := *engine.budget;
if !b.enabled return;
lock(*b.mutex);
b.in_use -= bytes;
unlock(*b.mutex);
}
ocr_budget_destroy :: (engine: *Ocr_Engine) {
if engine.budget.enabled destroy(*engine.budget.mutex);
engine.budget = .{};
}

View File

@ -63,8 +63,14 @@ ocr_detect_tile :: (engine: *Ocr_Engine, image: Ocr_Image, scale: float, scaled_
valid_h := tile_bottom - tile_top;
w := align_up_32(valid_w);
h := align_up_32(valid_h);
#if OS == .MACOS if engine.det_coreml {
// Up to a size the Core ML package takes: its steps, which det_max_side and det_tile_height stay within.
w = align_up(valid_w, model.coreml_det_width_step);
h = align_up(valid_h, model.coreml_det_height_step);
}
// Planar BGR, normalized; the padding to a multiple of 32 is the normalized zero (the mean colour).
// Planar BGR, normalized; the padding to a multiple of 32 (or the Core ML step) is the normalized
// zero (the mean colour).
plane := cast(s64) w * h;
input := NewArray(plane * 3, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
@ -83,14 +89,34 @@ ocr_detect_tile :: (engine: *Ocr_Engine, image: Ocr_Image, scale: float, scaled_
}
shape := s64.[1, 3, h, w];
out, ok, error := ocr_run(*engine.det, engine.memory_info, input, shape);
if !ok return false, error;
probabilities: *float;
dims: [] s64;
out: Ocr_Output;
defer ocr_release_output(*out);
if out.dim_count != 4 || out.dims[2] != h || out.dims[3] != w {
return false, tprint("The detector gave an output of shape %, not [1, 1, %, %].", array_view(out.dims, 0, out.dim_count), h, w);
#if OS == .MACOS {
coreml_out: Coreml_Output;
defer coreml_release_output(*coreml_out);
}
#if OS == .MACOS if engine.det_coreml {
ok: bool; error: string;
coreml_out, ok, error = coreml_predict(engine.det_coreml, input, shape);
if !ok return false, error;
probabilities, dims = coreml_out.data, array_view(coreml_out.dims, 0, coreml_out.dim_count);
}
if !probabilities {
ok: bool; error: string;
reserved := ocr_budget_detector(engine, h, w);
ocr_budget_reserve(engine, reserved); // budget.jai: waits while the GPU's memory is taken
out, ok, error = ocr_run(*engine.det, engine.memory_info, input, shape);
ocr_budget_release(engine, reserved);
if !ok return false, error;
probabilities, dims = out.data, array_view(out.dims, 0, out.dim_count);
}
if dims.count != 4 || dims[2] != h || dims[3] != w {
return false, tprint("The detector gave an output of shape %, not [1, 1, %, %].", dims, h, w);
}
regions := ocr_find_regions(out.data, w, valid_w, valid_h, model.det_threshold);
regions := ocr_find_regions(probabilities, w, valid_w, valid_h, model.det_threshold);
defer {
for regions array_free(it.outline);
array_free(regions,, context.default_allocator);
@ -254,6 +280,12 @@ align_up_32 :: (n: s32) -> s32 {
return (n + 31) & cast(s32) ~31;
}
// n rounded up to a multiple of step (at least one step).
align_up :: (n: s32, step: s32) -> s32 {
step = max(step, 1);
return max((n + step - 1) / step, 1) * step;
}
Region :: struct {
pixels: s64;
probability_sum: float64;

View File

@ -59,7 +59,8 @@ sha256_of :: (path: string) -> string {
if lines.count < 2 return "";
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
} else {
command := ifx OS == .MACOS then string.["shasum", "-a", "256", path] else string.["sha256sum", path];
#if OS == .MACOS command := string.["shasum", "-a", "256", path];
else command := string.["sha256sum", path];
result, output := run_command(..command, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));

View File

@ -16,6 +16,12 @@
# rec_batch_gpu text lines recognized together in one run, on the GPU
# rec_batch_cpu the same on the CPU, where one at a time is fastest: a batch is padded to its
# widest line, and the padding costs time and changes what is read a little
# detector_coreml, recognizer_coreml
# macOS: the detector and recognizer as Core ML packages, quoted, run on the GPU
# (tools/export_ppocr_coreml.py makes them); without both, onnxruntime runs OCR
# coreml_det_height_step, coreml_det_width_step, coreml_rec_batch, coreml_rec_width_step
# the sizes those packages take, as the script made them (the defaults: 128, 256,
# 16, 64): detection strips are padded up to the steps, lines to 16 at the step
[model ppocr-v5-mobile]
title: "PP-OCRv5 mobile (light)"
@ -31,6 +37,8 @@ unclip_ratio: 1.5
rec_height: 48
rec_batch_gpu: 16
rec_batch_cpu: 1
detector_coreml: "ch_PP-OCRv5_det_mobile.mlpackage"
recognizer_coreml: "ch_PP-OCRv5_rec_mobile.mlpackage"
[model ppocr-v5-server]
title: "PP-OCRv5 server (heavy)"
@ -46,3 +54,5 @@ unclip_ratio: 1.5
rec_height: 48
rec_batch_gpu: 8
rec_batch_cpu: 1
detector_coreml: "ch_PP-OCRv5_det_server.mlpackage"
recognizer_coreml: "ch_PP-OCRv5_rec_server.mlpackage"

View File

@ -14,6 +14,15 @@ Ocr_Model_Info :: struct {
rec_height: s32 = 48;
rec_batch_gpu: s32 = 16;
rec_batch_cpu: s32 = 1;
// macOS: the detector and recognizer as Core ML packages (tools/export_ppocr_coreml.py), "" for none,
// and the sizes they take, which inputs are padded up to: the detector's height and width multiples
// of these steps, and the recognizer coreml_rec_batch lines at a multiple of coreml_rec_width_step.
detector_coreml, recognizer_coreml: string;
coreml_det_height_step: s32 = 128;
coreml_det_width_step: s32 = 256;
coreml_rec_batch: s32 = 16;
coreml_rec_width_step: s32 = 64;
}
OCR_MODELS_CONFIG :: "models.config";
@ -64,6 +73,12 @@ ocr_list_models :: (models_folder: string) -> [] Ocr_Model_Info, error: string {
case "rec_height"; if number_ok model.rec_height = cast(s32) number;
case "rec_batch_gpu"; if number_ok model.rec_batch_gpu = cast(s32) number;
case "rec_batch_cpu"; if number_ok model.rec_batch_cpu = cast(s32) number;
case "detector_coreml"; model.detector_coreml = copy_string(value);
case "recognizer_coreml"; model.recognizer_coreml = copy_string(value);
case "coreml_det_height_step"; if number_ok model.coreml_det_height_step = cast(s32) number;
case "coreml_det_width_step"; if number_ok model.coreml_det_width_step = cast(s32) number;
case "coreml_rec_batch"; if number_ok model.coreml_rec_batch = cast(s32) number;
case "coreml_rec_width_step"; if number_ok model.coreml_rec_width_step = cast(s32) number;
case; log_error("[OCR] %:%: unknown key %. Ignored.", path, line_number, key);
}
}
@ -98,14 +113,67 @@ ocr_create_sessions :: (engine: *Ocr_Engine, models_folder: string, gpu: bool, i
if model.textline_ori {
engine.orientation, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.textline_ori), gpu, intra_op_threads, engine.options);
if !ok return false, error;
// PP-LCNet text line orientation takes 160 by 80; a model that says otherwise is taken at its word.
shape := engine.orientation.input_shape;
engine.orientation_height = ifx engine.orientation.input_rank == 4 && shape[2] > 0 then cast(s32) shape[2] else 80;
engine.orientation_width = ifx engine.orientation.input_rank == 4 && shape[3] > 0 then cast(s32) shape[3] else 160;
ocr_orientation_size(engine);
}
return true, "";
}
// PP-LCNet text line orientation takes 160 by 80; a model that says otherwise is taken at its word.
ocr_orientation_size :: (engine: *Ocr_Engine) {
shape := engine.orientation.input_shape;
engine.orientation_height = ifx engine.orientation.input_rank == 4 && shape[2] > 0 then cast(s32) shape[2] else 80;
engine.orientation_width = ifx engine.orientation.input_rank == 4 && shape[3] > 0 then cast(s32) shape[3] else 160;
}
// macOS: the detector and recognizer through Core ML (modules/Core_ML) on the GPU, each run once on
// its smallest size, and the orientation classifier, if any, through onnxruntime on the CPU. On
// failure, why; what was made is left for ocr_release_coreml and ocr_release_sessions.
ocr_create_coreml :: (engine: *Ocr_Engine, models_folder: string, cores: s32) -> ok: bool, error: string {
#if OS != .MACOS {
return false, "Core ML is macOS's.";
} else {
model := *engine.model;
if !model.detector_coreml || !model.recognizer_coreml return false, tprint("there are no Core ML models for %; tools/export_ppocr_coreml.py makes them.", model.name);
for string.[model.detector_coreml, model.recognizer_coreml] {
if !file_exists(tprint("%/%", models_folder, it)) return false, tprint("% is not there; tools/export_ppocr_coreml.py makes it.", it);
}
ok: bool;
error: string;
engine.det_coreml, ok, error = coreml_load(tprint("%/%", models_folder, model.detector_coreml), "x", "y");
if !ok return false, error;
engine.rec_coreml, ok, error = coreml_load(tprint("%/%", models_folder, model.recognizer_coreml), "x", "y");
if !ok return false, error;
if model.textline_ori {
engine.orientation, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.textline_ori), false, max(cores / 4, 1), engine.options);
if !ok return false, error;
ocr_orientation_size(engine);
}
// The first runs, at the smallest sizes the packages take.
Warm_Up :: struct { model: *Coreml_Model; shape: [4] s64; }
runs := Warm_Up.[
.{engine.det_coreml, .[1, 3, model.coreml_det_height_step, model.coreml_det_width_step]},
.{engine.rec_coreml, .[model.coreml_rec_batch, 3, model.rec_height, model.coreml_rec_width_step]},
];
for runs {
input := NewArray(it.shape[0] * it.shape[1] * it.shape[2] * it.shape[3], float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
out, run_ok, run_error := coreml_predict(it.model, input, it.shape);
if !run_ok return false, tprint("Core ML could not run the % on the GPU: %", ifx it_index == 0 then "detector" else "recognizer", run_error);
coreml_release_output(*out);
}
return true, "";
}
}
ocr_release_coreml :: (engine: *Ocr_Engine) {
#if OS == .MACOS {
coreml_free(engine.det_coreml);
coreml_free(engine.rec_coreml);
engine.det_coreml, engine.rec_coreml = null;
}
}
ocr_release_sessions :: (engine: *Ocr_Engine) {
ocr_release_session(*engine.det);
ocr_release_session(*engine.rec);

View File

@ -12,7 +12,7 @@
// then recognition (recognize.jai) reads it level. When the model lists an orientation classifier,
// lines that look upside down are read both ways; lines read downwards or upwards are read too.
Ocr_Provider :: enum { CPU; CUDA; }
Ocr_Provider :: enum { CPU; CUDA; COREML; }
// Tightly packed or not: stride is the bytes from one row to the next.
Ocr_Image :: struct {
@ -40,6 +40,7 @@ Ocr_Engine :: struct {
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
det, rec: Ocr_Session;
#if OS == .MACOS det_coreml, rec_coreml: *Coreml_Model; // the detector and recognizer, when Core ML runs them
orientation: Ocr_Session; // the text line orientation classifier; none when its session is null
orientation_width, orientation_height: s32; // its input size
dictionary: [] string; // class n is dictionary[n - 1]
@ -48,6 +49,7 @@ Ocr_Engine :: struct {
options: Ocr_Options;
stats: Ocr_Stats; // since creation, or the last ocr_reset_stats
pool: Ocr_Pool; // the worker threads, for the engine's life (run_on_workers)
budget: Ocr_Budget; // on CUDA: how many runs fit in GPU memory at once (budget.jai)
// When set, and true, ocr_process stops starting work: the batch of lines or image each worker is
// on is finished, and then every image fails as "cancelled" (none is part read). Set by the caller.
@ -68,6 +70,7 @@ Ocr_Stats :: struct {
recognize_us: s64; // cropping lines, the recognizer, and decoding the text
orientation_us: s64; // cropping lines and the orientation classifier
turned: s64; // lines that read better the other way up, or level as found
budget_waits: s64; // detector and recognizer runs that waited for GPU memory (budget.jai)
}
ocr_stats :: (engine: *Ocr_Engine) -> Ocr_Stats { return engine.stats; }
@ -117,11 +120,24 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1); // efficiency cores included
use_gpu := false;
if prefer_gpu {
#if OS == .MACOS if prefer_gpu {
// Core ML on the GPU: the detector and recognizer, the orientation classifier on the CPU. On any
// failure, onnxruntime on the CPU below, with the reason.
coreml_ok, coreml_error := ocr_create_coreml(engine, models_folder, cores);
if coreml_ok {
engine.provider = .COREML;
engine.workers = max(options.gpu_workers, 1);
engine.rec_batch = max(engine.model.coreml_rec_batch, 1);
return engine, true, "";
}
ocr_release_coreml(engine);
engine.gpu_explanation = sprint("OCR runs on the CPU: %", coreml_error);
}
if prefer_gpu && OS != .MACOS {
available, explanation := ort_cuda_check("OCR"); // modules/onnxruntime/cuda.jai
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else {
} else if !prefer_gpu {
engine.gpu_explanation = copy_string("OCR runs on the CPU, as it was asked to.");
}
@ -132,6 +148,10 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2);
if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine);
if !sessions_ok ocr_release_sessions(engine);
if sessions_ok {
engine.provider = .CUDA; // before calibrating: ocr_shrink_arena only works on CUDA
ocr_budget_calibrate(engine);
}
ort_gpu_unlock();
if sessions_ok {
engine.provider = .CUDA;
@ -160,15 +180,26 @@ ocr_provider :: (engine: *Ocr_Engine) -> Ocr_Provider {
// Whether the GPU is in use, and when not, why, and what to install so it can be.
ocr_gpu_status :: (engine: *Ocr_Engine) -> in_use: bool, explanation: string {
return engine.provider == .CUDA, engine.gpu_explanation;
return engine.provider == .CUDA || engine.provider == .COREML, engine.gpu_explanation;
}
// What runs the detector and recognizer, for people and the files it reads: "CUDA", "Core ML" or "CPU".
ocr_provider_name :: (engine: *Ocr_Engine) -> string {
if engine.provider == {
case .CUDA; return "CUDA";
case .COREML; return "Core ML";
}
return "CPU";
}
ocr_destroy :: (engine: *Ocr_Engine) {
if !engine return;
ocr_pool_stop(engine);
ocr_budget_destroy(engine);
if engine.provider == .CUDA ort_gpu_lock();
ocr_release_sessions(engine);
if engine.provider == .CUDA ort_gpu_unlock();
ocr_release_coreml(engine);
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
if engine.env ort.api.ReleaseEnv(engine.env);
free(engine.gpu_explanation,, context.default_allocator);
@ -180,6 +211,7 @@ ocr_destroy :: (engine: *Ocr_Engine) {
ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.allocator) -> [] Ocr_Result {
results := NewArray(images.count, Ocr_Result,, allocator);
if !images.count return results;
ocr_budget_update(engine); // budget.jai: the GPU memory this call's runs may share
// Detection: images shared out among the workers.
per_image := NewArray(images.count, Image_Work,, context.default_allocator);
@ -446,6 +478,7 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void
#load "models.jai";
#load "detect.jai";
#load "recognize.jai";
#load "budget.jai";
#import "Basic";
#import "String";
@ -458,6 +491,7 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void
#import "Atomics";
#import "onnxruntime";
Default_Allocator :: #import "Default_Allocator"; // rpmalloc (modules/Default_Allocator), for thread_idle where the workers wait
#if OS == .MACOS #import "Core_ML";
#if OS == .WINDOWS {
#import "Windows";
#import "Windows_Utf8";

View File

@ -29,21 +29,47 @@ ocr_recognize_batch :: (engine: *Ocr_Engine, jobs: [] *Line_Job) -> ok: bool, er
step := max(engine.options.rec_width_step, 1);
width = (width + step - 1) / step * step;
}
rows := cast(s64) jobs.count;
#if OS == .MACOS if engine.rec_coreml {
// The sizes the Core ML package takes: coreml_rec_batch lines (the rest padding), at its width step.
width = align_up(width, engine.model.coreml_rec_width_step);
rows = max(rows, cast(s64) engine.model.coreml_rec_batch);
}
plane := cast(s64) height * width;
input := NewArray(plane * 3 * jobs.count, float, initialized = true,, context.default_allocator); // 0 is the padding
input := NewArray(plane * 3 * rows, float, initialized = true,, context.default_allocator); // 0 is the padding
defer array_free(input,, context.default_allocator);
for job, n: jobs {
ocr_sample_crop(job.image, job.corners, job.width, height, input.data + plane * 3 * n, width, plane, REC_MEAN, REC_STD);
}
shape := s64.[jobs.count, 3, height, width];
out, ok, error := ocr_run(*engine.rec, engine.memory_info, input, shape);
if !ok return false, error;
shape := s64.[rows, 3, height, width];
scores: *float;
dims: [] s64;
out: Ocr_Output;
defer ocr_release_output(*out);
if out.dim_count != 3 || out.dims[0] != jobs.count return false, tprint("The recognizer gave an output of shape %.", array_view(out.dims, 0, out.dim_count));
steps := out.dims[1];
classes := out.dims[2];
#if OS == .MACOS {
coreml_out: Coreml_Output;
defer coreml_release_output(*coreml_out);
}
#if OS == .MACOS if engine.rec_coreml {
ok: bool; error: string;
coreml_out, ok, error = coreml_predict(engine.rec_coreml, input, shape);
if !ok return false, error;
scores, dims = coreml_out.data, array_view(coreml_out.dims, 0, coreml_out.dim_count);
}
if !scores {
ok: bool; error: string;
reserved := ocr_budget_recognizer(engine, rows, width);
ocr_budget_reserve(engine, reserved); // budget.jai: waits while the GPU's memory is taken
out, ok, error = ocr_run(*engine.rec, engine.memory_info, input, shape);
ocr_budget_release(engine, reserved);
if !ok return false, error;
scores, dims = out.data, array_view(out.dims, 0, out.dim_count);
}
if dims.count != 3 || dims[0] != rows return false, tprint("The recognizer gave an output of shape %.", dims);
steps := dims[1];
classes := dims[2];
if classes != engine.dictionary.count + 2 {
return false, tprint("The recognizer has % classes, but its dictionary % characters (plus blank and space, % expected).", classes, engine.dictionary.count, engine.dictionary.count + 2);
}
@ -55,7 +81,7 @@ ocr_recognize_batch :: (engine: *Ocr_Engine, jobs: [] *Line_Job) -> ok: bool, er
probability_sum: float64;
characters := 0;
for t: 0..steps - 1 {
row := out.data + (n * steps + t) * classes;
row := scores + (n * steps + t) * classes;
best := 0;
best_p := row[0];
for c: 1..classes - 1 if row[c] > best_p { best = c; best_p = row[c]; }

View File

@ -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, "";
}

View File

@ -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);

View File

@ -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";

View File

@ -0,0 +1,53 @@
// mcap's ScreenCaptureKit shim (capture.m): plain C, for Jai (src/app/screen_capture_macos.jai).
#pragma once
#include <stdint.h>
// One screen to copy. display_id, width and height (its size in pixels) are given; ScreenCaptureKit's
// own width and height and the pixels (packed RGBA, opaque, sRGB; free with mcap_sck_free) come back.
typedef struct {
uint32_t display_id;
int32_t width, height;
uint8_t *pixels;
} Mcap_Sck_Shot;
// Copies each screen, all at once, and waits for them. 1 when every one was copied; else 0, with why
// in error (pixels already made are left set, to be freed).
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);

View File

@ -0,0 +1,392 @@
// 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. 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 <ScreenCaptureKit/ScreenCaptureKit.h>
#import <CoreGraphics/CoreGraphics.h>
#import <CoreMedia/CoreMedia.h>
#import <CoreVideo/CoreVideo.h>
#include <stdlib.h>
#include <string.h>
static void set_error(char *error, int error_size, NSString *text) {
if (!error || error_size <= 0) return;
const char *utf8 = text.UTF8String ? text.UTF8String : "unknown error";
strncpy(error, utf8, (size_t) error_size - 1);
error[error_size - 1] = 0;
}
// The image as packed RGBA, opaque, in sRGB (which the capture is already in): malloc'd.
static uint8_t *rgba_from_image(CGImageRef image, int32_t width, int32_t height) {
size_t row = (size_t) width * 4;
uint8_t *pixels = malloc(row * (size_t) height);
if (!pixels) return NULL;
CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
CGContextRef bitmap = CGBitmapContextCreate(pixels, (size_t) width, (size_t) height, 8, row, srgb,
kCGBitmapByteOrder32Big | kCGImageAlphaNoneSkipLast);
CGColorSpaceRelease(srgb);
if (!bitmap) { free(pixels); return NULL; }
CGContextSetBlendMode(bitmap, kCGBlendModeCopy);
CGContextDrawImage(bitmap, CGRectMake(0, 0, width, height), image);
CGContextRelease(bitmap);
for (size_t i = 0; i < (size_t) width * (size_t) height; i++) pixels[i * 4 + 3] = 255;
return pixels;
}
int mcap_sck_screenshots(Mcap_Sck_Shot *shots, int count, char *error, int error_size) {
@autoreleasepool {
for (int i = 0; i < count; i++) shots[i].pixels = NULL;
__block SCShareableContent *content = nil;
__block NSError *content_error = nil;
dispatch_semaphore_t got = dispatch_semaphore_create(0);
[SCShareableContent getShareableContentExcludingDesktopWindows:NO onScreenWindowsOnly:YES
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) {
set_error(error, error_size, @"ScreenCaptureKit did not list the screens in time");
return 0;
}
if (!content) {
set_error(error, error_size, content_error ? content_error.localizedDescription : @"ScreenCaptureKit listed no screens");
return 0;
}
// Every screen at once; each completion fills its own entry.
dispatch_group_t group = dispatch_group_create();
__block NSString *failure = nil;
NSLock *lock = [NSLock new];
for (int i = 0; i < count; i++) {
SCDisplay *display = nil;
for (SCDisplay *d in content.displays) if (d.displayID == shots[i].display_id) display = d;
if (!display) {
failure = [NSString stringWithFormat:@"ScreenCaptureKit has no display %u", shots[i].display_id];
continue;
}
SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:display excludingWindows:@[]];
SCStreamConfiguration *configuration = [SCStreamConfiguration new];
configuration.width = (size_t) shots[i].width;
configuration.height = (size_t) shots[i].height;
configuration.showsCursor = NO;
configuration.colorSpaceName = kCGColorSpaceSRGB;
configuration.captureResolution = SCCaptureResolutionBest;
Mcap_Sck_Shot *shot = &shots[i];
dispatch_group_enter(group);
[SCScreenshotManager captureImageWithFilter:filter configuration:configuration
completionHandler:^(CGImageRef image, NSError *e) {
if (image) {
int32_t w = (int32_t) CGImageGetWidth(image), h = (int32_t) CGImageGetHeight(image);
shot->width = w;
shot->height = h;
shot->pixels = rgba_from_image(image, w, h);
}
if (!shot->pixels) {
[lock lock];
failure = e ? e.localizedDescription : @"ScreenCaptureKit gave no image";
[lock unlock];
}
dispatch_group_leave(group);
}];
}
if (dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) != 0) {
failure = @"ScreenCaptureKit did not copy the screens in time";
}
if (failure) {
// Entries still being filled after a timeout are left to leak rather than raced.
set_error(error, error_size, failure);
return 0;
}
return 1;
}
}
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 <SCStreamOutput, SCStreamDelegate>
@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<McapAudioPacket *> *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<McapAudioPacket *> *)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<SCWindow *> *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<McapAudioPacket *> *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
}
}

View File

@ -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 <adapter>", "Windows.Graphics.Capture on <adapter>, 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";

View File

@ -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 <adapter>", "Windows.Graphics.Capture on <adapter>, 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";

View File

@ -18,7 +18,10 @@ conda run -n mcap-seg python tools/export_mask2former.py --check FOLDER_OF_IMAGE
```
This writes `models/mask2former-swin-base-coco-panoptic.onnx` (462 MB) and its class list, then checks
the .onnx against PyTorch on the images. Building the app copies `models.config` and the models into
the .onnx against PyTorch on the images. On macOS, `tools/export_mask2former_coreml.py --check
FOLDER_OF_IMAGES` then makes `models/mask2former-swin-base-coco-panoptic.mlpackage`, which runs on the
GPU through Core ML (`modules/Core_ML`), one image a run, about four times as fast as the CPU with the
same segments. Building the app copies `models.config` and the models into
`bin/segment-models`. The models folder is not in git.
## Using it

View File

@ -6,6 +6,7 @@
[model mask2former-swin-base-coco-panoptic]
title: "Mask2Former Swin-B, COCO panoptic (133 classes)"
model: "mask2former-swin-base-coco-panoptic.onnx"
model_coreml: "mask2former-swin-base-coco-panoptic.mlpackage" # macOS: on the GPU (tools/export_mask2former_coreml.py makes it)
classes: "mask2former-swin-base-coco-panoptic.classes.txt"
image_size: 384 # squashed to 384 x 384, not keeping its shape, as the checkpoint's processor does
mean: 0.485 0.456 0.406

View File

@ -11,7 +11,7 @@
// transformers' post_process_panoptic_segmentation does (tools/export_mask2former.py checks that
// they match).
Segment_Provider :: enum { CPU; CUDA; }
Segment_Provider :: enum { CPU; CUDA; COREML; }
// Tightly packed or not: stride is the bytes from one row to the next.
Segment_Image :: struct {
@ -23,6 +23,7 @@ Segment_Model_Info :: struct {
name: string;
title: string;
model: string; // the .onnx, in the models folder
model_coreml: string; // macOS: the model as a Core ML package (tools/export_mask2former_coreml.py), run on the GPU; "" for none
classes: string; // the class list, in the models folder
image_size: s32 = 384; // the input's width and height: images are resized to it, not keeping their shape
mean := float.[0.485, 0.456, 0.406]; // ImageNet's, per channel, after scaling to 0..1
@ -50,6 +51,7 @@ Segment_Engine :: struct {
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
session: Ort_Session;
#if OS == .MACOS coreml: *Coreml_Model; // the model, when Core ML runs it (one image a run)
cancel: *bool; // when set, and true, segment_images stops between batches: the rest fail as "cancelled"
stats: Segment_Stats; // since it was made
}
@ -114,6 +116,7 @@ segment_list_models :: (models_folder: string) -> [] Segment_Model_Info, error:
case "title"; model.title = copy_string(value);
case "model"; model.model = copy_string(value);
case "classes"; model.classes = copy_string(value);
case "model_coreml"; model.model_coreml = copy_string(value);
case "image_size"; if number_ok model.image_size = cast(s32) number;
case "mean"; segment_parse_three(value, *model.mean, path, line_number);
case "std"; segment_parse_three(value, *model.std, path, line_number);
@ -155,11 +158,29 @@ segment_create :: (models_folder: string, model_name: string, prefer_gpu := true
HINT :: "make it with tools/export_mask2former.py (see modules/Segment/README.md) and rebuild";
path := tprint("%/%", models_folder, model.model);
use_gpu := false;
if prefer_gpu {
#if OS == .MACOS if prefer_gpu {
// Core ML on the GPU; on any failure, onnxruntime on the CPU below, with the reason.
coreml_path := tprint("%/%", models_folder, model.model_coreml);
if !model.model_coreml || !file_exists(coreml_path) {
engine.gpu_explanation = sprint("Segmentation runs on the CPU: % is not there; tools/export_mask2former_coreml.py makes it.", ifx model.model_coreml then model.model_coreml else "its Core ML model");
} else {
engine.coreml, ok, error = coreml_load(coreml_path, "pixel_values", "class_queries_logits");
if ok {
engine.provider = .COREML;
ok, error = segment_warm_up(engine);
if ok return engine, true, "";
coreml_free(engine.coreml);
engine.coreml = null;
engine.provider = .CPU;
}
engine.gpu_explanation = sprint("Segmentation runs on the CPU: Core ML could not run % on the GPU:\n%", model.model_coreml, error);
}
}
if prefer_gpu && OS != .MACOS {
available, explanation := ort_cuda_check("Segmentation");
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else {
} else if !prefer_gpu {
engine.gpu_explanation = copy_string("Segmentation runs on the CPU, as it was asked to.");
}
if use_gpu {
@ -190,6 +211,7 @@ segment_destroy :: (engine: *Segment_Engine) {
if engine.provider == .CUDA ort_gpu_lock();
ort_release_session(*engine.session);
if engine.provider == .CUDA ort_gpu_unlock();
#if OS == .MACOS coreml_free(engine.coreml);
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
if engine.env ort.api.ReleaseEnv(engine.env);
for engine.classes { free(it.name); free(it.model_name); }
@ -198,6 +220,24 @@ segment_destroy :: (engine: *Segment_Engine) {
free(engine);
}
// Images a run: Core ML's model takes one.
segment_batch :: (engine: *Segment_Engine) -> s32 {
if engine.provider == {
case .CUDA; return engine.model.batch_gpu;
case .COREML; return 1;
}
return engine.model.batch_cpu;
}
// What runs the model, for people: "CUDA", "Core ML" or "CPU".
segment_provider_name :: (engine: *Segment_Engine) -> string {
if engine.provider == {
case .CUDA; return "CUDA";
case .COREML; return "Core ML";
}
return "CPU";
}
// The map size for an image of width x height: its shape, the longest side at most max_side.
segment_map_size :: (width: s32, height: s32, max_side: s32) -> s32, s32 {
scale := min(cast(float64) 1, cast(float64) max_side / max(width, height));
@ -210,7 +250,7 @@ segment_images :: (engine: *Segment_Engine, images: [] Segment_Image, allocator
results := NewArray(images.count, Segment_Result,, allocator);
size := engine.model.image_size;
plane := cast(s64) size * size;
batch := cast(s64) max(ifx engine.provider == .CUDA then engine.model.batch_gpu else engine.model.batch_cpu, 1);
batch := cast(s64) max(segment_batch(engine), 1);
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
defer array_free(input,, context.default_allocator);
@ -224,10 +264,10 @@ segment_images :: (engine: *Segment_Engine, images: [] Segment_Image, allocator
start := current_time_monotonic();
for 0..count - 1 segment_prepare_image(engine.model, images[first + it], input.data + plane * 3 * it);
shape := s64.[count, 3, size, size];
outs: [2] Ort_Output;
outs: [2] Segment_Output;
ran := current_time_monotonic();
engine.stats.prepare_us += to_microseconds(ran - start);
ok, error := ort_run_float_outputs(*engine.session, engine.memory_info, array_view(input, 0, plane * 3 * count), shape, .["class_queries_logits", "masks_queries_logits"], outs);
ok, error := segment_run(engine, array_view(input, 0, plane * 3 * count), shape, outs);
posted := current_time_monotonic();
engine.stats.model_us += to_microseconds(posted - ran);
engine.stats.images += count;
@ -249,8 +289,7 @@ segment_images :: (engine: *Segment_Engine, images: [] Segment_Image, allocator
cast(s32) queries, cast(s32) labels, cast(s32) mask_w, cast(s32) mask_h, image.width, image.height, allocator);
}
}
ort_release_output(*outs[0]);
ort_release_output(*outs[1]);
segment_release_outputs(engine, outs);
}
first += count;
}
@ -524,14 +563,48 @@ segment_warm_up :: (engine: *Segment_Engine) -> ok: bool, error: string {
size := engine.model.image_size;
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
outs: [2] Ort_Output;
ok, error := ort_run_float_outputs(*engine.session, engine.memory_info, input, s64.[1, 3, size, size], .["class_queries_logits", "masks_queries_logits"], outs);
outs: [2] Segment_Output;
ok, error := segment_run(engine, input, s64.[1, 3, size, size], outs);
if !ok return false, tprint("The first run on the GPU failed: %", error);
ort_release_output(*outs[0]);
ort_release_output(*outs[1]);
segment_release_outputs(engine, outs);
return true, "";
}
// The two outputs, class and mask logits, as onnxruntime or Core ML gives them.
Segment_Output :: struct {
data: *float;
dims: [4] s64;
dim_count: s64;
ort: Ort_Output;
#if OS == .MACOS coreml: Coreml_Output;
}
segment_run :: (engine: *Segment_Engine, input: [] float, shape: [] s64, outs: [] Segment_Output) -> ok: bool, error: string {
NAMES :: string.["class_queries_logits", "masks_queries_logits"];
#if OS == .MACOS if engine.coreml {
coreml_outs: [2] Coreml_Output;
ok, error := coreml_predict_outputs(engine.coreml, input, shape, NAMES, coreml_outs);
if !ok return false, error;
for * outs {
it.* = .{data = coreml_outs[it_index].data, dim_count = min(coreml_outs[it_index].dim_count, 4), coreml = coreml_outs[it_index]};
for d: 0..it.dim_count - 1 it.dims[d] = coreml_outs[it_index].dims[d];
}
return true, "";
}
ort_outs: [2] Ort_Output;
ok, error := ort_run_float_outputs(*engine.session, engine.memory_info, input, shape, NAMES, ort_outs);
if !ok return false, error;
for * outs it.* = .{data = ort_outs[it_index].data, dims = ort_outs[it_index].dims, dim_count = ort_outs[it_index].dim_count, ort = ort_outs[it_index]};
return true, "";
}
segment_release_outputs :: (engine: *Segment_Engine, outs: [] Segment_Output) {
for * outs {
ort_release_output(*it.ort);
#if OS == .MACOS coreml_release_output(*it.coreml);
}
}
// The class list: `class: INDEX thing|stuff "model's name" "name"`, a line each, in index order.
segment_load_classes :: (engine: *Segment_Engine, path: string) -> ok: bool, error: string {
text, ok := read_entire_file(path, log_errors = false,, temp);
@ -597,9 +670,11 @@ segment_strip_comment :: (line: string) -> string {
#import "Basic";
#import "String";
#import "File";
#import "File_Utilities";
#import "Math";
#import "System";
#import "Hash";
#import "Sort";
#import "Capture_File"; // segment_mcap_chunks
#import "onnxruntime";
#if OS == .MACOS #import "Core_ML";

View File

@ -2,6 +2,19 @@
FFmpeg bindings for jai and some examples decoding and play videos.
## In mcap
FFmpeg 6.0 on both systems, so the two sets of bindings match:
- **Windows:** `windows/` holds the DLLs and import libraries of a GPL "full" build from gyan.dev
(`windows/LICENSE-BUILD.txt`), and `windows.jai` the bindings made from its headers (Setup below).
- **macOS:** `macos/` holds `libavcodec`, `libavformat`, `libavutil`, `libswresample` and `libswscale`,
an LGPL build for Apple silicon made from the release's source by `jai build_macos.jai` (about a
minute; `macos/LICENSE-BUILD.txt` has its configure options). It also installs the headers in
`macos-build/` (not in git), from which `jai generate.jai` on a Mac makes `macos.jai`. The libraries
are linked with `no_dll`, so the compiler does not load them, and copied beside the executable,
where its `@loader_path` rpath finds them.
## Setup
The FFmpeg libaries and header files are not included in this repo as I didn't want to have to deal with license

View File

@ -0,0 +1,117 @@
// Builds the FFmpeg 6.0 libraries mcap uses on macOS (avformat, avcodec, avutil, swscale and
// swresample) from the release's source, its SHA-256 checked, and puts them in macos/ with the
// headers the bindings are made from (generate.jai) in macos-build/install/include. The same version
// as the Windows DLLs (windows/), so the two sets of bindings match.
//
// cd modules/ffmpeg && jai build_macos.jai
//
// An LGPL build: no GPL parts (the Windows DLLs are a GPL build with libx264), nothing from outside
// macOS (--disable-autodetect, pkg-config pointed nowhere, so nothing from Homebrew is linked), and
// VideoToolbox and AudioToolbox for the hardware codecs. Needs Apple's command line tools: curl,
// tar, make and clang. A few minutes on Apple silicon.
FFMPEG_VERSION :: "6.0";
FFMPEG_ARCHIVE :: "ffmpeg-6.0.tar.xz";
FFMPEG_SHA256 :: "57be87c22d9b49c112b6d24bc67d42508660e6b718b3db89c44e47e289137082";
BUILD_FOLDER :: "macos-build"; // the source, the build and the installed headers; not in git
OUTPUT_FOLDER :: "macos"; // the libraries, in git as windows/ is
// Each library as its major version names it, which is how they find each other (@rpath/...).
LIBRARIES :: string.["libavcodec.60.dylib", "libavformat.60.dylib", "libavutil.58.dylib", "libswresample.4.dylib", "libswscale.7.dylib"];
CONFIGURE_OPTIONS :: string.[
"--install-name-dir=@rpath",
"--enable-shared", "--disable-static",
"--disable-programs", "--disable-doc",
"--disable-avdevice", "--disable-avfilter", "--disable-postproc", "--disable-network",
"--disable-autodetect",
"--enable-videotoolbox", "--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib",
"--extra-cflags=-mmacosx-version-min=14.0", "--extra-ldflags=-mmacosx-version-min=14.0",
];
#run,stallable {
set_build_options_dc(.{do_output = false});
#if OS != .MACOS {
log_error("build_macos.jai builds on a Mac only.");
return;
}
if !build() compiler_set_workspace_status(.FAILED);
}
build :: () -> bool {
here := get_working_directory();
build_folder := tprint("%/%", here, BUILD_FOLDER);
make_directory_if_it_does_not_exist(build_folder);
archive := tprint("%/%", build_folder, FFMPEG_ARCHIVE);
if !file_exists(archive) || sha256_of(archive) != FFMPEG_SHA256 {
url := tprint("https://ffmpeg.org/releases/%", FFMPEG_ARCHIVE);
log("Downloading %...", url);
result := run_command("curl", "-L", "--fail", "--progress-bar", "-o", archive, url);
if result.exit_code != 0 { log_error("curl failed (exit code %).", result.exit_code); return false; }
hash := sha256_of(archive);
if hash != FFMPEG_SHA256 {
log_error("% has SHA-256 %, expected %; it was not used.", archive, hash, FFMPEG_SHA256);
return false;
}
}
source := tprint("%/ffmpeg-%", build_folder, FFMPEG_VERSION);
install := tprint("%/install", build_folder);
run_command("rm", "-rf", source, install);
result := run_command("tar", "xf", archive, "-C", build_folder);
if result.exit_code != 0 { log_error("tar could not unpack %.", archive); return false; }
log("Configuring...");
arguments: [..] string;
array_add(*arguments, "env", "PKG_CONFIG_LIBDIR=", "PKG_CONFIG_PATH=", "./configure", tprint("--prefix=%", install));
array_add(*arguments, ..CONFIGURE_OPTIONS);
result = run_command(..arguments, working_directory = source);
if result.exit_code != 0 { log_error("configure failed; see %/ffbuild/config.log.", source); return false; }
log("Building...");
cpus := get_number_of_processors();
result = run_command("make", tprint("-j%", cpus), working_directory = source);
if result.exit_code != 0 { log_error("make failed."); return false; }
result = run_command("make", "install", working_directory = source);
if result.exit_code != 0 { log_error("make install failed."); return false; }
output := tprint("%/%", here, OUTPUT_FOLDER);
run_command("rm", "-rf", output);
make_directory_if_it_does_not_exist(output);
for LIBRARIES {
// The major-version name is a link to the full one; the file itself is copied under that name.
if !copy_file(tprint("%/lib/%", install, it), tprint("%/%", output, it)) { log_error("Could not copy %.", it); return false; }
run_command("strip", "-x", tprint("%/%", output, it));
}
write_entire_file(tprint("%/LICENSE-BUILD.txt", output), tprint(LICENSE_TEXT, FFMPEG_VERSION, FFMPEG_ARCHIVE, FFMPEG_SHA256, join(..CONFIGURE_OPTIONS, "\n ")));
log("Put % libraries in % and the headers in %/include (FFmpeg %, LGPL).", LIBRARIES.count, output, install, FFMPEG_VERSION);
return true;
}
LICENSE_TEXT :: #string TEXT
FFmpeg % shared libraries for macOS on Apple silicon, built by modules/ffmpeg/build_macos.jai.
Source: https://ffmpeg.org/releases/%
SHA-256: %
License: LGPL version 2.1 or later (no GPL or non-free parts). The source is at the address above;
the libraries are built from it unchanged, with these configure options:
%
TEXT
// Lowercase hex, or "" when it could not be worked out.
sha256_of :: (path: string) -> string {
result, output := run_command("shasum", "-a", "256", path, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));
}
#import "Basic";
#import "Compiler";
#import "File";
#import "File_Utilities";
#import "Process";
#import "String";
#import "System";

View File

@ -46,10 +46,13 @@ FOOTER :: #string DONE
DONE
INCLUDE_PATH :: "src";
// Windows: the headers of the downloaded build, copied to src. macOS: those build_macos.jai installs.
#if OS == .MACOS INCLUDE_PATH :: "macos-build/install/include";
else INCLUDE_PATH :: "src";
generate_bindings :: () -> bool {
output_filename := "windows.jai";
#if OS == .MACOS output_filename := "macos.jai";
else output_filename := "windows.jai";
opts: Generate_Bindings_Options;
{
@ -67,19 +70,31 @@ generate_bindings :: () -> bool {
// doesn't work, just get a load of __UnknownLib dont you :roll:.
// strip_flags &= ~.SYMBOLS_WITH_UNKNOWN_FOREIGN_LIBS;
array_add(*libpaths, "windows");
libraries := string.[
"avcodec-60",
"avdevice-60",
"avfilter-9",
"avformat-60",
"avutil-58",
"postproc-57",
"swresample-4",
"swscale-7",
];
for libraries array_add(*libnames, it);
#if OS == .MACOS {
// The five libraries build_macos.jai makes, as their major versions name them; with the
// extension, since the generator takes a name with a dot in it for a whole file name.
array_add(*libpaths, "macos");
library_names :: string.[
"libavcodec.60.dylib",
"libavformat.60.dylib",
"libavutil.58.dylib",
"libswresample.4.dylib",
"libswscale.7.dylib",
];
} else {
array_add(*libpaths, "windows");
library_names :: string.[
"avcodec-60",
"avdevice-60",
"avfilter-9",
"avformat-60",
"avutil-58",
"postproc-57",
"swresample-4",
"swscale-7",
];
}
for library_names array_add(*libnames, it);
array_add(*include_paths, INCLUDE_PATH);
@ -89,7 +104,7 @@ generate_bindings :: () -> bool {
// pick the headers I want!
#if false {
dirs_to_visit: [..] string;
for libraries array_add(*dirs_to_visit, tprint("%/lib%", INCLUDE_PATH, it));
for library_names array_add(*dirs_to_visit, tprint("%/lib%", INCLUDE_PATH, it));
file_visitor :: (info: *File_Visit_Info, source_files : *[..] string) {
assert(info != null, "info (%) != null", info);
@ -121,7 +136,7 @@ generate_bindings :: () -> bool {
array_add(*source_files, tprint("%/libavformat/avformat.h", INCLUDE_PATH));
array_add(*source_files, tprint("%/libswscale/swscale.h", INCLUDE_PATH));
array_add(*system_include_paths, GENERATOR_DEFAULT_SYSTEM_INCLUDE_PATH);
// system_include_paths left empty: the generator fills in this system's own.
builder: String_Builder;
append(*builder, HEADER);
@ -134,6 +149,14 @@ generate_bindings :: () -> bool {
print("Generating ffmpeg bindings...\n");
result := generate_bindings(opts, output_filename);
// The compiler loads each #library to run code at compile time, but there the dylibs cannot find
// each other through @rpath; nothing of FFmpeg's is run then, so none is loaded (no_dll).
#if OS == .MACOS if result {
text, ok := read_entire_file(output_filename);
if !ok return false;
result = write_entire_file(output_filename, replace(text, " :: #library \"macos/", " :: #library,no_dll \"macos/"));
}
return result;
}
@ -243,7 +266,7 @@ generate_error_codes :: (b: *String_Builder) {
for info_struct.members longest_name_length = max(longest_name_length, it.name.count);
for info_struct.members {
slot := cast (*u8) *dummy + it.offset_in_bytes;
value := << cast(*s32) slot;
value := (cast(*s32) slot).*;
append(b, it.name);
num_spaces := longest_name_length - it.name.count;
for 1..num_spaces append(b, " ");

12947
modules/ffmpeg/macos.jai Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
FFmpeg 6.0 shared libraries for macOS on Apple silicon, built by modules/ffmpeg/build_macos.jai.
Source: https://ffmpeg.org/releases/ffmpeg-6.0.tar.xz
SHA-256: 57be87c22d9b49c112b6d24bc67d42508660e6b718b3db89c44e47e289137082
License: LGPL version 2.1 or later (no GPL or non-free parts). The source is at the address above;
the libraries are built from it unchanged, with these configure options:
--install-name-dir=@rpath
--enable-shared
--disable-static
--disable-programs
--disable-doc
--disable-avdevice
--disable-avfilter
--disable-postproc
--disable-network
--disable-autodetect
--enable-videotoolbox
--enable-audiotoolbox
--enable-zlib
--enable-bzlib
--extra-cflags=-mmacosx-version-min=14.0
--extra-ldflags=-mmacosx-version-min=14.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7,7 +7,9 @@ size_t :: u64;
#if OS == .WINDOWS {
#load "windows.jai";
} else #if OS == .MACOS {
#load "macos.jai"; // generated from build_macos.jai's headers; its libraries are in macos/
} else {
#assert false, "Only windows has bindings atm, sorry bros!";
#assert false, "FFmpeg has bindings for Windows and macOS only.";
}

View File

@ -49,8 +49,14 @@ Cuda_Part :: enum { DRIVER; TOOLKIT; CUDNN; }
// Whether the CUDA provider should be tried, and when not, an explanation for people: what is
// missing, and the steps that fix it, starting "<what> runs on the CPU". Temp.
ort_cuda_check :: (what: string) -> available: bool, explanation: string {
// CoreML, onnxruntime's provider for Apple's GPU and Neural Engine, was measured on an M5 Pro
// (onnxruntime 1.30.0) and is slower than the CPU for these models: OCR's take a new image size
// nearly every run, which CoreML compiles for each time, and SigLIP 2 converts only with every
// dimension fixed (its ML Program conversion fails on the patch embedding), then runs at about
// half the CPU's speed. So onnxruntime runs them on the CPU on macOS; OCR and labels use native
// Core ML instead when their converted models are there (modules/Core_ML).
#if OS == .MACOS {
return false, tprint("% runs on the CPU: onnxruntime has no CUDA on macOS.", what);
return false, tprint("% runs on the CPU: onnxruntime has no CUDA on macOS, and its CoreML provider was measured slower than the CPU on Apple silicon.", what);
}
provider_path := tprint("%/%", path_strip_filename(ort.library_path), CUDA_PROVIDER_LIBRARY);

View File

@ -113,7 +113,8 @@ sha256_of :: (path: string) -> string {
if lines.count < 2 return "";
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
} else {
command := ifx OS == .MACOS then string.["shasum", "-a", "256", path] else string.["sha256sum", path];
#if OS == .MACOS command := string.["shasum", "-a", "256", path];
else command := string.["sha256sum", path];
result, output := run_command(..command, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));

View File

@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
#pragma once
#include "onnxruntime_c_api.h"
// COREMLFlags are bool options we want to set for CoreML EP
// This enum is defined as bit flags, and cannot have negative value
// To generate an uint32_t coreml_flags for using with OrtSessionOptionsAppendExecutionProvider_CoreML below,
// uint32_t coreml_flags = 0;
// coreml_flags |= COREML_FLAG_USE_CPU_ONLY;
enum COREMLFlags {
COREML_FLAG_USE_NONE = 0x000,
// Using CPU only in CoreML EP, this may decrease the perf but will provide
// reference output value without precision loss, which is useful for validation
COREML_FLAG_USE_CPU_ONLY = 0x001,
// Enable CoreML EP on subgraph
COREML_FLAG_ENABLE_ON_SUBGRAPH = 0x002,
// By default CoreML Execution provider will be enabled for all compatible Apple devices
// Enable this option will only enable CoreML EP for Apple devices with ANE (Apple Neural Engine)
// Please note, enable this option does not guarantee the entire model to be executed using ANE only
COREML_FLAG_ONLY_ENABLE_DEVICE_WITH_ANE = 0x004,
// Only allow CoreML EP to take nodes with inputs with static shapes. By default it will also allow inputs with
// dynamic shapes. However, the performance may be negatively impacted if inputs have dynamic shapes.
COREML_FLAG_ONLY_ALLOW_STATIC_INPUT_SHAPES = 0x008,
// Create an MLProgram. By default it will create a NeuralNetwork model. Requires Core ML 5 or later.
COREML_FLAG_CREATE_MLPROGRAM = 0x010,
// https://developer.apple.com/documentation/coreml/mlcomputeunits?language=objc
// there are four compute units:
// MLComputeUnitsCPUAndNeuralEngine|MLComputeUnitsCPUAndGPU|MLComputeUnitsCPUOnly|MLComputeUnitsAll
// different CU will have different performance and power consumption
COREML_FLAG_USE_CPU_AND_GPU = 0x020,
// Keep COREML_FLAG_LAST at the end of the enum definition
// And assign the last COREMLFlag to it
COREML_FLAG_LAST = COREML_FLAG_USE_CPU_AND_GPU,
};
// MLComputeUnits can be one of the following values:
// 'MLComputeUnitsCPUAndNeuralEngine|MLComputeUnitsCPUAndGPU|MLComputeUnitsCPUOnly|MLComputeUnitsAll'
// these values are intended to be used with Ort::SessionOptions::AppendExecutionProvider (C++ API)
// and SessionOptionsAppendExecutionProvider (C API). For the old API, use COREMLFlags instead.
static const char* const kCoremlProviderOption_MLComputeUnits = "MLComputeUnits";
static const char* const kCoremlProviderOption_ModelFormat = "ModelFormat";
// same as COREML_FLAG_ONLY_ALLOW_STATIC_INPUT_SHAPES
static const char* const kCoremlProviderOption_RequireStaticInputShapes = "RequireStaticInputShapes";
static const char* const kCoremlProviderOption_EnableOnSubgraphs = "EnableOnSubgraphs";
// provided by https://developer.apple.com/documentation/coreml/mloptimizationhints-swift.struct/specializationstrategy-swift.property
// Core ML segments the model’s compute graph and specializes each segment for the target compute device.
// This process can affect the model loading time and the prediction latency.
// Use this option to tailor the specialization strategy for your model.
static const char* const kCoremlProviderOption_SpecializationStrategy = "SpecializationStrategy";
// Profile the Core ML MLComputePlan.
// This logs the hardware each operator is dispatched to and the estimated execution time.
// Intended for developer usage but provide useful diagnostic information if performance is not as expected.
static const char* const kCoremlProviderOption_ProfileComputePlan = "ProfileComputePlan";
// please refer to https://developer.apple.com/documentation/coreml/mlmodelconfiguration/allowlowprecisionaccumulationongpu
static const char* const kCoremlProviderOption_AllowLowPrecisionAccumulationOnGPU = "AllowLowPrecisionAccumulationOnGPU";
// Specify the directory to cache any CoreML models created from the ONNX model in.
// CoreML EP will convert onnx subgraph to CoreML model and save to disk.
// If this path is not specified, the model will be saved to a temp directory and deleted after the session is closed.
// otherwise, the model will be saved to the specified path and User should manage to delete the model.
// we do NOT detect if the onnx model has changed and no longer matches the cached model.
// the user should carefully manage the cache if modifying/replacing a model.
// The cache key is generated by
// 1. User provided key in metadata_props if found (preferred)
// 2. Hash of the model url the inference session was created with
// 3. Hash of the input/output names of the model
// Please find out how to set metadata_props in the onnxruntime API documentation. https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html#configuration-options
static const char* const kCoremlProviderOption_ModelCacheDirectory = "ModelCacheDirectory";
// User provided cache-key in metadata_props.
static const char* const kCOREML_CACHE_KEY = "COREML_CACHE_KEY";
#ifdef __cplusplus
extern "C" {
#endif
ORT_EXPORT ORT_API_STATUS(OrtSessionOptionsAppendExecutionProvider_CoreML,
_In_ OrtSessionOptions* options, uint32_t coreml_flags);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// Dummy file to provide a signal in the ONNX Runtime C cocoapod as to whether the WebGPU EP was included in the build.
// If it was, this file will be included in the cocoapod, and a test like this can be used:
//
// #if __has_include(<onnxruntime/webgpu_provider_factory.h>)
// #define WEBGPU_EP_AVAILABLE 1
// #else
// #define WEBGPU_EP_AVAILABLE 0
// #endif
// The WebGPU EP can be enabled via the generic SessionOptionsAppendExecutionProvider method, so no direct usage of
// the provider factory is required.

View File

@ -14,15 +14,17 @@ ort: struct {
log_level := OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING;
}
// Loads onnxruntime from the first folder that has it: beside the running executable, then each
// of folders. A full path is always used: Windows keeps an older onnxruntime.dll in System32 for
// its own use, which a bare name could find first.
// Loads onnxruntime from the first folder that has it: beside the running executable (or in a macOS
// bundle's Contents/Frameworks), then each of folders. A full path is always used: Windows keeps an
// older onnxruntime.dll in System32 for its own use, which a bare name could find first.
ort_load :: (folders: [] string = .[]) -> ok: bool, error: string {
if ort.api return true, "";
candidates: [..] string;
candidates.allocator = temp;
array_add(*candidates, path_strip_filename(get_path_of_running_executable()));
executable_folder := path_strip_filename(get_path_of_running_executable());
array_add(*candidates, executable_folder);
#if OS == .MACOS if ends_with(executable_folder, "/Contents/MacOS/") array_add(*candidates, tprint("%../Frameworks", executable_folder));
for folders array_add(*candidates, it);
tried: [..] string;

View File

@ -123,6 +123,50 @@ ort_release_session :: (s: *Ort_Session) {
s.* = .{};
}
// The GPU's free and total memory in bytes, from the CUDA runtime onnxruntime's CUDA provider loads;
// ok false before a CUDA session has been made, or without CUDA.
ort_cuda_memory :: () -> free: s64, total: s64, ok: bool {
Get_Info :: #type (free: *u64, total: *u64) -> s32 #c_call;
get: Get_Info;
#if OS == .WINDOWS {
module := GetModuleHandleA("cudart64_13.dll");
if module get = cast(Get_Info) GetProcAddress(module, "cudaMemGetInfo");
} else #if OS == .LINUX {
handle := dlopen("libcudart.so.13", RTLD_LAZY);
if handle get = cast(Get_Info) dlsym(handle, "cudaMemGetInfo");
}
if !get return 0, 0, false;
free, total: u64;
if get(*free, *total) != 0 return 0, 0, false;
return cast(s64) free, cast(s64) total, true;
}
// A CUDA session's GPU arena: the bytes its blocks hand out now, and the most they ever have (onnxruntime
// keeps the peak since the session was made). ok false when onnxruntime does not say.
ort_gpu_arena_stats :: (s: *Ort_Session) -> in_use: s64, max_in_use: s64, ok: bool {
api := ort.api;
info: *OrtMemoryInfo;
if !ort_check(api.CreateMemoryInfo("Cuda", .OrtArenaAllocator, 0, .OrtMemTypeDefault, *info)) return 0, 0, false;
defer api.ReleaseMemoryInfo(info);
allocator: *OrtAllocator;
if !ort_check(api.CreateAllocator(s.session, info, *allocator)) return 0, 0, false;
defer api.ReleaseAllocator(allocator);
stats: *OrtKeyValuePairs;
if !ort_check(api.AllocatorGetStats(allocator, *stats)) return 0, 0, false;
defer api.ReleaseKeyValuePairs(stats);
keys, values: **u8;
count: u64;
api.GetKeyValuePairs(stats, *keys, *values, *count);
in_use, max_in_use := -1, -1;
for 0..cast(s64) count - 1 {
key := to_string(keys[it]);
if key == "InUse" in_use = string_to_int(to_string(values[it]));
if key == "MaxInUse" max_in_use = string_to_int(to_string(values[it]));
}
if in_use < 0 || max_in_use < 0 return 0, 0, false;
return in_use, max_in_use, true;
}
// A run's output: its values stay valid until ort_release_output.
Ort_Output :: struct {
value: *OrtValue;

View File

@ -26,12 +26,16 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/import.jai";
#load "app/open_file.jai";
#load "app/segment_ui.jai";
#load "app/gpu_bench_ui.jai"; // the gpu-bench branch: the GPU Benchmark window
#load "../tools/gpu_bench/results.jai"; // its results files
#load "app/library_folders.jai";
#load "app/exit_shutdown.jai";
#load "app/screen_capture.jai";
#load "app/screen_record.jai";
#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";
@ -78,7 +82,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);
@ -199,7 +203,7 @@ App :: struct {
run_tests: bool;
headless: bool; // -headless (or a no_crash_dialog build): no message boxes on failures, and run_tests exits when done
exit_code: s32; // the process's exit code when the main loop ends
library_folder: string; // -library FOLDER: the .mcap folder the viewer shows, in place of VIEWER_FOLDER
library_folder: string; // the .mcap folder the viewer shows: -library FOLDER, else [library] folder, else LIBRARY_FOLDER_DEFAULT
scan_and_exit: bool; // -scan: scan the library with the chosen OCR model once it has loaded, then exit (0, or 1 if any capture failed)
stats_and_exit: bool; // -stats: log the Statistics window's numbers once the catalog has loaded, then exit
catalog_threads: s64; // -catalog-threads N: workers loading the catalog at once, in place of all of the background group's (for measuring)
@ -309,6 +313,14 @@ app_running_from_app_bundle :: () -> bool {
return app_from_app_bundle;
}
// The folder the models' folders (ocr-models, label-models, segment-models) are in: the bundle's
// Contents/Resources on macOS, else beside the executable. Ends in a separator.
app_resource_directory :: () -> string {
directory := path_strip_filename(get_path_of_running_executable());
#if OS == .MACOS if app_running_from_app_bundle() return tprint("%../Resources/", directory);
return directory;
}
#scope_file
app_install_directory_path: string;
app_from_app_bundle: bool;

View File

@ -39,6 +39,7 @@ app_ui_shutdown :: () {
open_file_shutdown();
return;
}
gpu_bench_ui_shutdown(); // ends a benchmark run still going
app_shutdown_step(.CAPTURE);
screen_capture_shutdown();
app_shutdown_step(.IMPORT);
@ -121,6 +122,7 @@ app_main_window_ui :: () {
labels_ui_frame();
segment_ui_frame();
stats_ui_frame();
gpu_bench_ui_frame(); // app/gpu_bench_ui.jai
import_frame();
open_file_frame(); // after import_frame, which hands the grid what an import of it wrote
screen_record_leftovers_popup();

525
src/app/gpu_bench_ui.jai Normal file
View File

@ -0,0 +1,525 @@
// The GPU Benchmark window (the gpu-bench branch): runs gpu-bench (tools/gpu_bench) in a process of
// its own, so the app's engines and memory do not touch its numbers, shows how far it has got, and
// lays the results files in gpu-bench-results side by side. To compare with another machine, copy
// its results file into that folder (Open folder).
GPU_BENCH_WINDOW :: "GPU Benchmark";
GPU_BENCH_LOG_LINES :: 2000;
gpu_bench_ui: struct {
window_open: bool;
run_ocr := true;
run_labels := true;
run_segment := true;
cpu: bool;
repeats: s32 = 3;
note: [128] u8;
// A run: the reader thread owns the child process and appends what it prints to output.
running: bool;
mutex_ready: bool;
mutex: Mutex;
thread: Thread;
command: [..] string; // heap; for the thread
output: [..] u8; // under mutex; heap
finished: bool; // under mutex: the child has exited, or could not start
exit_code: s32; // under mutex
start_error: string; // under mutex; heap
cancelled: bool;
#if OS == .WINDOWS process: HANDLE; // under mutex
else process: Process;
started_at: Apollo_Time;
parsed: s64; // bytes of output made into lines
log: [..] string; // heap
scroll_log: bool;
status: string; // heap: where the run is, or how it ended
reports: [..] Bench_Report; // newest first
shown: [..] bool; // per report: a column in the comparison
baseline: string; // heap: the path of the report the others are compared with
loaded: bool;
};
gpu_bench_results_folder :: () -> string {
return tprint("%gpu-bench-results", path_strip_filename(get_path_of_running_executable()));
}
// The release build, else the debug one; "" when neither is beside the app.
gpu_bench_executable :: () -> path: string, debug: bool {
folder := path_strip_filename(get_path_of_running_executable());
extension := ifx OS == .WINDOWS then ".exe" else "";
release := tprint("%gpu-bench%", folder, extension);
if file_exists(release) return release, false;
debug := tprint("%gpu-bench-debug%", folder, extension);
if file_exists(debug) return debug, true;
return "", false;
}
gpu_bench_ui_frame :: () { using ImGui;
if gpu_bench_ui.running gpu_bench_poll();
if !gpu_bench_ui.window_open return;
if !gpu_bench_ui.loaded gpu_bench_load_reports();
SetNextWindowSize(.{980, 680}, .FirstUseEver);
if Begin(GPU_BENCH_WINDOW, *gpu_bench_ui.window_open) gpu_bench_window_contents();
End();
}
gpu_bench_ui_shutdown :: () {
if gpu_bench_ui.running {
gpu_bench_cancel();
thread_is_done(*gpu_bench_ui.thread, -1);
thread_deinit(*gpu_bench_ui.thread);
gpu_bench_ui.running = false;
}
push_allocator(context.default_allocator);
for * gpu_bench_ui.reports bench_report_free(it);
array_free(gpu_bench_ui.reports);
array_free(gpu_bench_ui.shown);
for gpu_bench_ui.log free(it);
array_free(gpu_bench_ui.log);
array_free(gpu_bench_ui.output);
for gpu_bench_ui.command free(it);
array_free(gpu_bench_ui.command);
free(gpu_bench_ui.status);
free(gpu_bench_ui.baseline);
free(gpu_bench_ui.start_error);
}
#scope_file
gpu_bench_window_contents :: () { using ImGui;
TextWrapped("Times each model mcap runs on the GPU (OCR, labels, segmentation) over a fixed corpus of 30 images that every machine has, each checked against its SHA-256 before anything runs. Each model's files and settings are fingerprinted too, so a comparison shows whether it is like for like.");
exe, debug := gpu_bench_executable();
if gpu_bench_ui.running {
Text("%", gpu_bench_ui.status);
SameLine();
TextColored(app_theme.palette.dim, "(% s)", formatFloat(to_float64_seconds(current_time_monotonic() - gpu_bench_ui.started_at), trailing_width = 0));
SameLine();
if Button("Cancel") gpu_bench_cancel();
} else {
Checkbox("OCR", *gpu_bench_ui.run_ocr);
SameLine(); Checkbox("Labels", *gpu_bench_ui.run_labels);
SameLine(); Checkbox("Segmentation", *gpu_bench_ui.run_segment);
SameLine(); Checkbox("CPU too", *gpu_bench_ui.cpu);
if IsItemHovered() SetTooltip("Each model on the CPU as well, after the GPU, and the label model's text encoder (a like: search). Slow: minutes for segmentation.");
SameLine();
SetNextItemWidth(120);
SliderInt("Timed passes", *gpu_bench_ui.repeats, 1, 10);
if IsItemHovered() SetTooltip("Passes over the corpus that are timed, after one untimed warm-up pass; the median is reported");
SetNextItemWidth(-FLOAT32_MIN);
InputTextWithHint("##note", "A note kept with the results, such as: plugged in, best performance mode", gpu_bench_ui.note.data, gpu_bench_ui.note.count);
reason := "";
if !exe reason = "gpu-bench is not beside the app: build with jai first.jai - release.";
else if ocr_ui.thread_busy reason = "A scan or a reading is running on the GPU: let it finish, or pause or cancel it, first.";
else if !gpu_bench_ui.run_ocr && !gpu_bench_ui.run_labels && !gpu_bench_ui.run_segment reason = "Choose a kind of model to run.";
if reason BeginDisabled();
if Button("Run the benchmark") gpu_bench_start(exe);
if reason EndDisabled();
if reason { SameLine(); TextColored(app_theme.palette.warning, "%", reason); }
else if debug { SameLine(); TextColored(app_theme.palette.warning, "Only the debug gpu-bench is built: its CPU work is slow, so build with - release to compare."); }
else if gpu_bench_ui.status { SameLine(); TextColored(app_theme.palette.dim, "%", gpu_bench_ui.status); }
TextColored(app_theme.palette.dim, "mcap's own models stay loaded while it runs. For the cleanest numbers, close mcap and run gpu-bench in a terminal; its results land in the same folder.");
}
if CollapsingHeader(temp_c_string(tprint("Output (% lines)###output", gpu_bench_ui.log.count))) {
if BeginChild("##log", .{0, 220}, .Borders, .HorizontalScrollbar) {
for gpu_bench_ui.log TextUnformatted(it);
if gpu_bench_ui.scroll_log { SetScrollHereY(1); gpu_bench_ui.scroll_log = false; }
}
EndChild();
}
Separator();
folder := gpu_bench_results_folder();
Text("Results (% files)", gpu_bench_ui.reports.count);
SameLine();
if SmallButton("Open folder") {
make_directory_if_it_does_not_exist(folder, recursive = true);
platform_open_file_in_default_program(folder, folder);
}
if IsItemHovered() SetTooltip("% : copy another machine's results files here to compare", folder);
SameLine();
if SmallButton("Reload") gpu_bench_load_reports();
if !gpu_bench_ui.reports.count {
TextColored(app_theme.palette.dim, "None yet: run the benchmark.");
return;
}
gpu_bench_reports_table();
Spacing();
gpu_bench_comparison_table();
}
// The results files: which are shown, and which is the baseline.
gpu_bench_reports_table :: () { using ImGui;
height := min(cast(float) gpu_bench_ui.reports.count + 1, 7) * GetFrameHeightWithSpacing();
if !BeginTable("##reports", 7, .Borders | .RowBg | .SizingFixedFit | .ScrollY, .{0, height}) return;
TableSetupScrollFreeze(0, 1);
TableSetupColumn("Show");
TableSetupColumn("Baseline");
TableSetupColumn("Date");
TableSetupColumn("Machine");
TableSetupColumn("GPU");
TableSetupColumn("Power");
TableSetupColumn("Note", .WidthStretch);
TableHeadersRow();
for * report, n: gpu_bench_ui.reports {
PushID(cast(s32) n);
TableNextRow();
TableNextColumn(); Checkbox("##show", *gpu_bench_ui.shown[n]);
TableNextColumn();
if RadioButton("##baseline", report.path == gpu_bench_ui.baseline) {
free(gpu_bench_ui.baseline,, context.default_allocator);
gpu_bench_ui.baseline = copy_string(report.path,, context.default_allocator);
gpu_bench_ui.shown[n] = true;
}
TableNextColumn(); TextUnformatted(bench_header(report, "date"));
if IsItemHovered() SetTooltip("%", report.path);
TableNextColumn(); TextUnformatted(bench_header(report, "machine"));
TableNextColumn(); TextUnformatted(bench_header(report, "gpu"));
TableNextColumn();
limit := bench_header(report, "power_limit_w");
power := bench_header(report, "power");
TextUnformatted(tprint("%%%", ifx limit then tprint("% W", limit) else "", ifx limit && power then ", " else "", power));
TableNextColumn(); TextUnformatted(bench_header(report, "note"));
PopID();
}
EndTable();
}
// The shown reports side by side: what each machine is, then each model's time, against the baseline's.
gpu_bench_comparison_table :: () { using ImGui;
columns: [..] *Bench_Report;
columns.allocator = temp;
baseline: *Bench_Report;
for * gpu_bench_ui.reports {
if gpu_bench_ui.shown[it_index] array_add(*columns, it);
if it.path == gpu_bench_ui.baseline baseline = it;
}
if !columns.count { TextColored(app_theme.palette.dim, "Tick Show for the results to compare."); return; }
if !baseline baseline = columns[0];
// Every task and provider any of them ran, in the order they ran.
Bench_Key :: struct { task, provider: string; }
keys: [..] Bench_Key;
keys.allocator = temp;
for report: columns for report.rows {
found := false;
for key: keys if key.task == it.task && key.provider == it.provider found = true;
if !found array_add(*keys, .{it.task, it.provider});
}
if !BeginTable("##compare", cast(s32) columns.count + 1, .Borders | .RowBg | .SizingFixedFit | .ScrollX | .ScrollY, .{0, -FLOAT32_MIN}) return;
TableSetupScrollFreeze(1, 1);
TableSetupColumn("");
for columns TableSetupColumn(temp_c_string(tprint("%##%", bench_header(it, "machine"), it_index)));
TableHeadersRow();
header_row :: (label: string, key: string, tooltip := "") #expand {
TableNextRow();
TableNextColumn(); TextColored(app_theme.palette.dim, "%", label);
if tooltip && IsItemHovered() SetTooltip("%", tooltip);
for columns {
TableNextColumn();
value := bench_header(it, key);
if it != baseline && value != bench_header(baseline, key) TextColored(app_theme.palette.accent, "%", value);
else TextUnformatted(value);
}
}
header_row("GPU", "gpu");
header_row("Memory", "vram_gb", "GB of GPU memory");
header_row("Power limit", "power_limit_w", "The board's enforced limit in watts: a laptop's GPU is often far below the desktop part's");
header_row("Power", "power");
header_row("Driver", "driver");
header_row("cuDNN", "cudnn");
header_row("onnxruntime", "onnxruntime");
header_row("CPU", "cpu");
header_row("Date", "date");
header_row("Note", "note");
// The corpus: different images make every number not comparable.
TableNextRow();
TableNextColumn(); TextColored(app_theme.palette.dim, "Corpus");
for columns {
TableNextColumn();
corpus := bench_header(it, "corpus");
if corpus == bench_header(baseline, "corpus") TextColored(app_theme.palette.success, "same (%)", slice(corpus, 0, min(corpus.count, 12)));
else TextColored(app_theme.palette.error, "DIFFERENT: not comparable");
}
for key: keys {
TableNextRow();
TableNextColumn();
TextUnformatted(tprint("% %", key.task, ifx key.provider == "CUDA" then "(GPU)" else "(CPU)"));
base_row := bench_row(baseline, key.task, key.provider);
base_ms := ifx base_row then string_to_float64(bench_field(base_row, "ms")) else 0;
for report: columns {
TableNextColumn();
row := bench_row(report, key.task, key.provider);
if !row {
error := bench_header(report, tprint("error % %", key.task, key.provider));
if !error error = bench_header(report, tprint("error % CPU", key.task)); // a GPU run that fell back
if error { TextColored(app_theme.palette.error, "failed"); if IsItemHovered() SetTooltip("%", error); }
else TextColored(app_theme.palette.dim, "-");
continue;
}
ms := string_to_float64(bench_field(row, "ms"));
unit := bench_field(row, "unit");
same_model := bench_header(report, tprint("model %", key.task)) == bench_header(baseline, tprint("model %", key.task));
text := tprint("% ms/%", formatFloat(ms, trailing_width = ifx ms < 10 then 2 else 1), unit);
if report != baseline && base_ms > 0 && ms > 0 {
ratio := base_ms / ms; // above 1: faster than the baseline
colour := ifx ratio > 1.05 then app_theme.palette.success else ifx ratio < 0.95 then app_theme.palette.warning else app_theme.palette.neutral;
TextColored(colour, "% (%x)%", text, formatFloat(ratio, trailing_width = 2), ifx same_model then "" else " *");
} else {
TextUnformatted(tprint("%%", text, ifx same_model then "" else " *"));
}
if IsItemHovered() {
builder: String_Builder;
builder.allocator = temp;
if !same_model append(*builder, "* Different model files or models.config settings from the baseline's: not like for like.\n\n");
print_to_builder(*builder, "% on %: % %s per second", key.task, report.path, bench_field(row, "per_s"), unit);
mp := bench_field(row, "mp_per_s");
if mp print_to_builder(*builder, ", % megapixels per second", mp);
print_to_builder(*builder, "\nmedian of the timed passes; best % ms, worst % ms\n", bench_field(row, "best_ms"), bench_field(row, "worst_ms"));
print_to_builder(*builder, "loading % s, the first % % s, batch %\n", bench_field(row, "load_s"), unit, bench_field(row, "first_s"), bench_field(row, "batch"));
vram := bench_field(row, "vram_mb");
if vram print_to_builder(*builder, "GPU: % MB more memory at the peak, % percent busy, % W, % MHz, up to % C\n", vram,
bench_field(row, "gpu_util"), bench_field(row, "power_w"), bench_field(row, "sm_mhz"), bench_field(row, "temp_c"));
stages := bench_field(row, "stages");
if stages print_to_builder(*builder, "ms per %, summed over threads: %\n", unit, stages);
print_to_builder(*builder, "found %", bench_field(row, "output"));
if report != baseline && base_row {
same_output := bench_field(row, "digest") == bench_field(base_row, "digest");
print_to_builder(*builder, ", % the baseline's", ifx same_output then "the same as" else "not quite the same as (GPUs round differently)");
}
SetTooltip("%", builder_to_string(*builder,, temp));
}
}
}
EndTable();
}
gpu_bench_load_reports :: () {
push_allocator(context.default_allocator);
for * gpu_bench_ui.reports bench_report_free(it);
array_reset(*gpu_bench_ui.reports);
array_reset(*gpu_bench_ui.shown);
gpu_bench_ui.loaded = true;
folder := gpu_bench_results_folder();
if !file_exists(folder) return;
for file_list(folder,, temp) {
if !ends_with_nocase(it, ".txt") continue;
text, ok := read_entire_file(it, log_errors = false,, temp);
if !ok continue;
report, parsed, error := bench_parse(text, it);
if !parsed { log_error("[gpu-bench] %", error); bench_report_free(*report); continue; }
array_add(*gpu_bench_ui.reports, report);
}
quick_sort(gpu_bench_ui.reports, (a: Bench_Report, b: Bench_Report) -> s64 { return compare(bench_header(*b, "date"), bench_header(*a, "date")); });
array_resize(*gpu_bench_ui.shown, gpu_bench_ui.reports.count);
for * gpu_bench_ui.shown it.* = true;
found := false;
for gpu_bench_ui.reports if it.path == gpu_bench_ui.baseline found = true;
if !found && gpu_bench_ui.reports.count {
free(gpu_bench_ui.baseline);
gpu_bench_ui.baseline = copy_string(gpu_bench_ui.reports[gpu_bench_ui.reports.count - 1].path); // the oldest: what later runs are measured against
}
}
gpu_bench_start :: (exe: string) {
push_allocator(context.default_allocator);
if !gpu_bench_ui.mutex_ready { init(*gpu_bench_ui.mutex); gpu_bench_ui.mutex_ready = true; }
for gpu_bench_ui.command free(it);
array_reset(*gpu_bench_ui.command);
add :: (s: string) #expand { array_add(*gpu_bench_ui.command, copy_string(s)); }
add(exe);
add("-progress");
add("-repeats"); add(tprint("%", gpu_bench_ui.repeats));
add("-out"); add(gpu_bench_results_folder());
only: [..] string;
only.allocator = temp;
if gpu_bench_ui.run_ocr array_add(*only, "ocr");
if gpu_bench_ui.run_labels array_add(*only, "labels");
if gpu_bench_ui.run_segment array_add(*only, "segment");
add("-only"); add(join(..only, ",",, temp));
if gpu_bench_ui.cpu add("-cpu");
note := trim(to_string(gpu_bench_ui.note.data));
if note { add("-note"); add(note); }
for gpu_bench_ui.log free(it);
array_reset(*gpu_bench_ui.log);
array_reset(*gpu_bench_ui.output);
free(gpu_bench_ui.start_error);
gpu_bench_ui.start_error = "";
gpu_bench_ui.finished = false;
gpu_bench_ui.cancelled = false;
gpu_bench_ui.exit_code = 0;
gpu_bench_ui.parsed = 0;
gpu_bench_set_status("Starting...");
gpu_bench_ui.started_at = current_time_monotonic();
thread_init(*gpu_bench_ui.thread, gpu_bench_thread_proc, name = "gpu-bench");
thread_start(*gpu_bench_ui.thread);
gpu_bench_ui.running = true;
log("[gpu-bench] Running %", join(..gpu_bench_ui.command, " ",, temp));
}
gpu_bench_cancel :: () {
lock(*gpu_bench_ui.mutex);
gpu_bench_ui.cancelled = true;
#if OS == .WINDOWS { if gpu_bench_ui.process TerminateProcess(gpu_bench_ui.process, 1); }
else kill_process(*gpu_bench_ui.process);
unlock(*gpu_bench_ui.mutex);
}
// What the child printed since the last frame, made into lines; and the end of the run.
gpu_bench_poll :: () {
push_allocator(context.default_allocator);
lock(*gpu_bench_ui.mutex);
finished := gpu_bench_ui.finished;
exit_code := gpu_bench_ui.exit_code;
new_text := copy_string(cast(string) array_view(gpu_bench_ui.output, gpu_bench_ui.parsed, gpu_bench_ui.output.count - gpu_bench_ui.parsed),, temp);
start_error := copy_string(gpu_bench_ui.start_error,, temp);
unlock(*gpu_bench_ui.mutex);
// Whole lines only, until the end.
end := find_index_from_right(new_text, #char "\n");
if finished end = new_text.count;
if end > 0 {
gpu_bench_ui.parsed += end;
for raw: split(slice(new_text, 0, end), "\n",, temp) {
line := trim_right(raw, "\r");
if !line.count continue;
if line[0] == 0x1b continue; // onnxruntime's coloured warnings
if begins_with(line, "@progress ") { gpu_bench_set_status(gpu_bench_progress_words(advance(line, 10))); continue; }
if begins_with(line, "@results ") continue;
array_add(*gpu_bench_ui.log, copy_string(line));
gpu_bench_ui.scroll_log = true;
}
while gpu_bench_ui.log.count > GPU_BENCH_LOG_LINES {
free(gpu_bench_ui.log[0]);
array_ordered_remove_by_index(*gpu_bench_ui.log, 0);
}
}
if !finished return;
thread_is_done(*gpu_bench_ui.thread, -1);
thread_deinit(*gpu_bench_ui.thread);
gpu_bench_ui.running = false;
seconds := formatFloat(to_float64_seconds(current_time_monotonic() - gpu_bench_ui.started_at), trailing_width = 0);
if start_error gpu_bench_set_status(start_error);
else if gpu_bench_ui.cancelled gpu_bench_set_status("Cancelled.");
else if exit_code == 0 gpu_bench_set_status(tprint("Done in % s.", seconds));
else gpu_bench_set_status(tprint("gpu-bench failed (exit code %) after % s: Output says why.", exit_code, seconds));
log("[gpu-bench] %", gpu_bench_ui.status);
gpu_bench_load_reports();
}
// "@progress ocr/ppocr-v5-mobile CUDA timing 2 3" in words.
gpu_bench_progress_words :: (text: string) -> string {
parts := split(text, " ",, temp);
if parts.count < 5 return text;
task, provider, phase, done, total := parts[0], parts[1], parts[2], parts[3], parts[4];
on := ifx provider == "CUDA" then "on the GPU" else ifx provider == "CPU" then "on the CPU" else "";
if task == "corpus" return tprint("The corpus: % %...", phase, ifx done != "0" then tprint("image %", done) else "");
if task == "hashing" return tprint("Fingerprinting %: % (% of %)", provider, phase, done, total);
if phase == {
case "loading"; return tprint("% %: loading the model...", task, on);
case "first"; return tprint("% %: the first image...", task, on);
case "warm-up"; return tprint("% %: warm-up pass % of %...", task, on, done, total);
case "timing"; return tprint("% %: timed pass % of %...", task, on, done, total);
}
return text;
}
gpu_bench_set_status :: (text: string) {
free(gpu_bench_ui.status,, context.default_allocator);
gpu_bench_ui.status = copy_string(text,, context.default_allocator);
}
// The reader thread: starts gpu-bench, takes what it prints until it exits.
gpu_bench_thread_proc :: (thread: *Thread) -> s64 {
append_output :: (data: *u8, count: s64) {
lock(*gpu_bench_ui.mutex);
old := gpu_bench_ui.output.count;
array_resize(*gpu_bench_ui.output, old + count, initialize = false,, context.default_allocator);
memcpy(gpu_bench_ui.output.data + old, data, count);
unlock(*gpu_bench_ui.mutex);
}
finish :: (exit_code: s32, error := "") {
lock(*gpu_bench_ui.mutex);
gpu_bench_ui.exit_code = exit_code;
if error gpu_bench_ui.start_error = copy_string(error,, context.default_allocator);
gpu_bench_ui.finished = true;
unlock(*gpu_bench_ui.mutex);
}
buffer: [4096] u8;
#if OS == .WINDOWS {
// Not the Process module: from a GUI program, it would open a console window for gpu-bench.
attributes: SECURITY_ATTRIBUTES;
attributes.nLength = size_of(SECURITY_ATTRIBUTES);
attributes.bInheritHandle = 1;
read_end, write_end: HANDLE;
if !CreatePipe(*read_end, *write_end, *attributes, 0) { finish(-1, tprint("Could not start gpu-bench: no pipe (Windows error %).", GetLastError())); return 0; }
SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0);
startup: STARTUPINFOW;
startup.cb = size_of(STARTUPINFOW);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = write_end;
startup.hStdError = write_end;
builder: String_Builder;
builder.allocator = temp;
for gpu_bench_ui.command { if it_index append(*builder, " "); append(*builder, windows_quote_argument(it)); }
command_line := builder_to_string(*builder,, temp);
info: PROCESS_INFORMATION;
working := path_strip_filename(gpu_bench_ui.command[0]);
started := CreateProcessW(null, utf8_to_wide(command_line,, temp), null, null, 1, CREATE_NO_WINDOW, null, utf8_to_wide(working,, temp), *startup, *info);
CloseHandle(write_end); // the child's copy is the only one left: reading ends when it exits
if !started {
error := GetLastError();
CloseHandle(read_end);
finish(-1, tprint("Could not start % (Windows error %).", gpu_bench_ui.command[0], error));
return 0;
}
CloseHandle(info.hThread);
lock(*gpu_bench_ui.mutex);
gpu_bench_ui.process = info.hProcess;
cancelled := gpu_bench_ui.cancelled;
unlock(*gpu_bench_ui.mutex);
if cancelled TerminateProcess(info.hProcess, 1);
while true {
read: u32;
if !ReadFile(read_end, buffer.data, buffer.count, *read) || !read break;
append_output(buffer.data, read);
}
CloseHandle(read_end);
WaitForSingleObject(info.hProcess, INFINITE);
exit_code: u32;
GetExitCodeProcess(info.hProcess, *exit_code);
lock(*gpu_bench_ui.mutex);
gpu_bench_ui.process = null;
unlock(*gpu_bench_ui.mutex);
CloseHandle(info.hProcess);
finish(cast,no_check(s32) exit_code);
} else {
errors: [4096] u8;
if !create_process(*gpu_bench_ui.process, ..gpu_bench_ui.command, capture_and_return_output = true) {
finish(-1, tprint("Could not start %.", gpu_bench_ui.command[0]));
return 0;
}
while true {
ok, output_bytes, error_bytes := read_from_process(*gpu_bench_ui.process, buffer, errors, -1);
if output_bytes append_output(buffer.data, output_bytes);
if error_bytes append_output(errors.data, error_bytes);
if !ok || (gpu_bench_ui.process.output.eof && gpu_bench_ui.process.error.eof) break;
}
_, result := get_process_result(*gpu_bench_ui.process, -1);
deinit(*gpu_bench_ui.process);
finish(cast(s32) result.exit_code);
}
return 0;
}
#import "Process";

View File

@ -376,7 +376,7 @@ import_file :: (path: string) -> Import_Outcome, text: string, new_path: string
// A video: hashed and copied into its .mcap a few MB at a time, so it can be larger than memory.
import_video_file :: (path: string, name: string, head: [] u8, size: s64, modified: Apollo_Time) -> Import_Outcome, text: string, new_path: string {
if OS != .WINDOWS return .SKIPPED, "video (Windows only)", "";
if OS == .LINUX return .SKIPPED, "video (Windows and macOS only)", "";
hash, hashed := import_hash_file(.{path = path, offset = 0, size = size});
if !hashed return .FAILED, "cannot be read", "";
@ -1038,9 +1038,9 @@ import_window :: () { using ImGui;
import_source_changed();
}
SameLine();
#if OS == .WINDOWS {
#if OS == .WINDOWS || OS == .MACOS {
if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to import");
chosen := platform_choose_folder("Choose a folder to import");
if chosen {
count := min(chosen.count, importer.source_buffer.count - 1);
memcpy(importer.source_buffer.data, chosen.data, count);

View File

@ -53,7 +53,7 @@ labels_ui: struct {
};
labels_models_folder :: () -> string {
return tprint("%label-models", path_strip_filename(get_path_of_running_executable()));
return tprint("%label-models", app_resource_directory());
}
labels_list_path :: () -> string {
@ -244,7 +244,7 @@ labels_ui_task :: (task: Ocr_Task) {
engine, ok, error := labels_create(labels_models_folder(), LABELS_MODEL);
free(labels_ui.engine_error);
labels_ui.engine_error = ifx ok then "" else copy_string(error);
if ok log("[Labels] % on %.%", LABELS_MODEL, engine.provider, ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
if ok log("[Labels] % on %.%", LABELS_MODEL, labels_provider_name(engine), ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
else log_error("[Labels] Could not start %: %", LABELS_MODEL, error);
labels_ui.engine = engine;
labels_ui.engine_tried = true;
@ -273,7 +273,7 @@ labels_window_contents :: () { using ImGui;
TextWrapped("Run `jai fetch_models.jai` in modules/Labels, then rebuild.");
} else if labels_ui.engine {
in_use, explanation := labels_gpu_status(labels_ui.engine);
Text("%: images on %, text on the CPU", labels_ui.engine.model.title, ifx in_use then "the GPU (CUDA)" else "the CPU");
Text("%: images on %, text on the CPU", labels_ui.engine.model.title, ifx in_use then tprint("the GPU (%)", labels_provider_name(labels_ui.engine)) else "the CPU");
if explanation text_with_links(explanation);
} else if !labels_ui.engine_tried {
TextColored(app_theme.palette.dim, "Not loaded: it loads when a capture is read or labelled, or a like: search runs, and unloads after % minutes unused.", MODELS_IDLE_UNLOAD_SECONDS / 60);

View File

@ -18,12 +18,7 @@ main :: () {
app.headless = array_find(app.args, "-headless") || NO_CRASH_DIALOG;
runtime_support_headless = app.headless;
app.allow_multiple_instances = array_find(app.args, "-allow-multiple-instances");
for app.args if it == "-library" && it_index + 1 < app.args.count {
// With / between its folders and none at the end, as the paths found in it are made.
app.library_folder = copy_string(app.args[it_index + 1]);
path_overwrite_separators(app.library_folder, #char "/");
while app.library_folder.count > 3 && app.library_folder[app.library_folder.count - 1] == #char "/" app.library_folder.count -= 1;
}
for app.args if it == "-library" && it_index + 1 < app.args.count app.library_folder = library_folder_normalize(app.args[it_index + 1]);
app.scan_and_exit = array_find(app.args, "-scan");
app.segment_and_exit = array_find(app.args, "-segment");
for app.args if it == "-record" && it_index + 1 < app.args.count app.record_and_exit = max(string_to_float64(app.args[it_index + 1]), 1.0);
@ -193,6 +188,8 @@ main :: () {
settings_write_back := app_load_user_settings_file() && !app.view_mode; // the running instance owns the file
logger_set_event_log_capacity(app_user_settings.event_log_capacity);
library_folder_choose();
log("Library: %", app.library_folder);
// #Startup:CreateWindow
#if USING_SDL {

View File

@ -106,7 +106,7 @@ Scan_Item :: struct {
}
ocr_models_folder :: () -> string {
return tprint("%ocr-models", path_strip_filename(get_path_of_running_executable()));
return tprint("%ocr-models", app_resource_directory());
}
// ---------------------------------------------------------------- startup and shutdown
@ -283,7 +283,7 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
ocr_ui.engine = engine;
free(ocr_ui.engine_error);
ocr_ui.engine_error = ifx ok then "" else copy_string(error);
if ok log("[OCR] % on %.%", task.model, engine.provider, ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
if ok log("[OCR] % on %.%", task.model, ocr_provider_name(engine), ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
else log_error("[OCR] Could not start %: %", task.model, error);
ocr_ui.engine_tried = true;
@ -446,7 +446,7 @@ ocr_read_and_store :: (items: [] Scan_Item, set: *Label_Set) -> [] string {
vectors, label_error := labels_embed_images(labels, label_images,, temp);
if label_error for label_of if !errors[it] errors[it] = sprint("labelling failed: %", label_error);
dims := labels.dimensions;
label_engine := tprint("onnxruntime %, %", ort.version, ifx labels.provider == .CUDA then "CUDA" else "CPU");
label_engine := ifx labels.provider == .COREML then "Core ML, GPU" else tprint("onnxruntime %, %", ort.version, labels_provider_name(labels));
for n: 0..items.count - 1 {
if errors[n] continue;
vector: [] float;
@ -463,7 +463,7 @@ ocr_read_and_store :: (items: [] Scan_Item, set: *Label_Set) -> [] string {
}
ocr_results := ocr_process(ocr_ui.engine, ocr_images,, temp);
engine_text := tprint("onnxruntime %, %", ort.version, ifx ocr_ui.engine.provider == .CUDA then "CUDA" else "CPU");
engine_text := ifx ocr_ui.engine.provider == .COREML then "Core ML, GPU" else tprint("onnxruntime %, %", ort.version, ocr_provider_name(ocr_ui.engine));
for n: 0..items.count - 1 {
if errors[n] continue;
section: Mcap_Ocr_Section;
@ -715,7 +715,7 @@ ocr_scan_progress_ui :: () { using ImGui;
ProgressBar(cast(float) done / max(total, 1), .{-FLOAT32_MIN, 0}, temp_c_string(ocr_scan_progress_text(done, total)));
if scan.erase_ocr || scan.erase_labels || scan.erase_segments Text("% elapsed, % left, % captures/s, % failed", ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1), failed);
else if scan.segmenting Text("% elapsed, % left, % images/s, % failed", ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1), failed); // the engine is the OCR thread's while it runs
else Text("% elapsed, % left, % captures/s, % failed, on %", ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1), failed, ifx ocr_ui.engine && ocr_ui.engine.provider == .CUDA then "the GPU" else "the CPU");
else Text("% elapsed, % left, % captures/s, % failed, on %", ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1), failed, ifx ocr_ui.engine && ocr_gpu_status(ocr_ui.engine) then "the GPU" else "the CPU");
if scan.paused {
if Button("Resume") { scan.paused_seconds += to_float64_seconds(current_time_monotonic() - scan.pause_start); atomic_swap(*scan.paused, false); }
} else {
@ -811,7 +811,7 @@ ocr_ui_frame :: () { using ImGui;
ocr_scan_ui();
} else {
in_use, explanation := ocr_gpu_status(ocr_ui.engine);
Text("Running on: %", ifx in_use then "the GPU (CUDA)" else "the CPU");
Text("Running on: %", ifx in_use then tprint("the GPU (%)", ocr_provider_name(ocr_ui.engine)) else "the CPU");
SameLine();
TextColored(app_theme.palette.dim, "onnxruntime %, % worker threads", ort.version, ocr_ui.engine.workers);
Separator();

View File

@ -183,7 +183,7 @@ open_file_list_folders :: () {
array_reset_keeping_memory(*open_file.folders);
visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) {
if !info.is_directory || !import_name_is_valid(info.short_name) return; // not hidden ones (.thumbnails)
#if OS != .WINDOWS if info.short_name == "deleted" return; // where deleted captures go there
#if OS == .LINUX if info.short_name == "deleted" return; // where deleted captures go there
array_add(folders, copy_string(info.short_name,, context.default_allocator));
}, visit_files = false, visit_directories = true);
quick_sort(open_file.folders, compare);
@ -254,7 +254,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
}
case .VIDEO;
#if OS == .WINDOWS {
#if OS == .WINDOWS || OS == .MACOS {
image, video, ok, poster_error := video_poster(path);
if !ok { error = poster_error; }
else {
@ -265,7 +265,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
}
to_hash = .{path = path, offset = 0, size = open_file.size};
} else {
error = "videos are read on Windows only";
error = "videos are read on Windows and macOS only";
}
case;
@ -359,7 +359,7 @@ open_file_copy_path :: () {
}
open_file_copy_image :: () {
#if OS != .WINDOWS {
#if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet");
return;
}
@ -492,7 +492,7 @@ open_file_library_ui :: (stage: Open_File_Stage) { using ImGui;
return;
}
if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); return; }
if OS != .WINDOWS && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows only."); return; }
if OS == .LINUX && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows and macOS only."); return; }
if open_file.importing {
if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total));

View File

@ -1,6 +1,6 @@
// Screen capture, as Snagit does it: PrintScreen (the capture_region global hotkey, from any app,
// even with mcap hidden in the tray) freezes every screen, and a rectangle dragged over them is taken
// (app/screen_capture_win32.jai; Windows only so far). What is taken goes onto the clipboard at once,
// Screen capture, as Snagit does it: PrintScreen on Windows, Cmd+Shift+2 on macOS (the capture_region
// global hotkey, from any app, even with mcap hidden in the tray) freezes every screen, and a rectangle
// dragged over them is taken (app/screen_capture_win32.jai, app/screen_capture_macos.jai). What is taken goes onto the clipboard at once,
// and into the library as an .mcap in the folder SCREEN_CAPTURE_FOLDER: the pixels as a PNG, with
// the window it was taken from in metadata.json, its title as WindowName and its program as AppName,
// as Snagit records them, so searches find it the same way. mcap then comes to the front showing
@ -219,7 +219,7 @@ screen_capture_out_path :: (name: string) -> path: string, error: string {
return path, "";
}
#if OS != .WINDOWS {
#if OS == .LINUX {
Screen_Shot :: struct {
pixels: [] u8;
width, height: s32;

View File

@ -0,0 +1,827 @@
// The screen capture overlay on macOS (app/screen_capture.jai is the rest; the Windows one is
// screen_capture_win32.jai, which this follows). The moment the hotkey fires, every screen is copied
// with ScreenCaptureKit (modules/Screen_Record/macos/capture.m), and each is shown frozen and darkened
// in a window of its own: a window cannot span screens on macOS, as the one Windows overlay does. A
// rectangle dragged shows at full brightness, and letting go takes it; a click takes the whole screen
// the cursor is on. A magnifier beside the cursor shows the pixels under it, with the position or the
// 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.
//
// The overlay windows are non-activating panels at the overlay level, on every Space and over
// full-screen apps, as Ex4's overlays are (Ex4-Jai/src/base/platform/macos_overlay.jai): the app you
// were in stays the active one. They are drawn with Core Graphics, only where something changed, and
// run in an event loop of their own: platform_screen_capture_select returns once a region is taken or
// the capture is cancelled.
//
// Needs the Screen Recording permission: without it, macOS gives only the desktop, so mcap asks for
// it and opens its pane in System Settings instead. The window it came from is found in the window
// server's list, whose titles need that permission too.
// The region taken, and the window it was taken from.
Screen_Shot :: struct {
pixels: [] u8; // RGBA, rows packed; heap
width, height: s32; // in pixels
left, top: s32; // in points, from the main screen's top left
whole_screen: bool; // a click took the screen under the cursor
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) {
push_allocator(context.default_allocator);
array_free(shot.pixels);
free(shot.window_title);
free(shot.app_name);
free(shot.app_path);
shot.* = .{};
}
// 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;
init_objective_c();
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
if !CGPreflightScreenCaptureAccess() {
// Asked for once (macOS shows its prompt the first time only), and its pane opened, as Ex4 does.
CGRequestScreenCaptureAccess();
run_command("open", SCREEN_RECORDING_PANE);
return shot, false, "mcap needs the Screen Recording permission: turn it on for mcap in System Settings > Privacy & Security > Screen Recording, then capture again";
}
// The app in front, before the overlay is up: what the capture is of when no window is found.
front_name, front_path := frontmost_app();
ok, error := capture_freeze();
defer capture_release();
if !ok return shot, false, error;
capture.video = video;
if immediate {
x, y := capture_cursor_points();
capture.result_screen = capture_screen_at(x, y);
capture.result = .{0, 0, capture.screens[capture.result_screen].pixel_width, capture.screens[capture.result_screen].pixel_height};
capture.whole_screen = true;
} else {
if !capture_open() return shot, false, "the capture windows could not be opened";
capture_choose();
capture_close();
if capture.cancelled return shot, false, "";
}
screen := *capture.screens[capture.result_screen];
r := capture.result;
shot.width = r.right - r.left;
shot.height = r.bottom - r.top;
if shot.width <= 0 || shot.height <= 0 return shot, false, "";
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.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.
center_x := screen.left + (r.left + r.right) / 2.0 / screen.scale;
center_y := screen.top + (r.top + r.bottom) / 2.0 / screen.scale;
window_info(*shot, center_x, center_y, front_name, front_path);
return shot, true, "";
}
SCREEN_RECORDING_PANE :: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
#scope_file
Capture_Screen :: struct {
left, top, width, height: float64; // in points, from the main screen's top left
scale: float64; // pixels a point
display_id: u32;
pixel_width, pixel_height: s32;
pixels: *u8; // RGBA, from the shim (mcap_sck_free)
image: CGImageRef; // over pixels
window: *NSWindow;
view: *NSView;
dirty: [..] CGRect; // what is drawn now that is not the plain dimmed screen, in its points
}
Pixel :: struct { x, y: s32; }
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;
anchor_screen: s64; // where the drag began
anchor: Pixel;
done, cancelled: bool;
result_screen: s64;
result: Pixel_Rect; // in that screen's pixels
whole_screen: bool;
}
MAGNIFIER_PIXELS :: 21; // the pixels it shows across: odd, so one is in the middle
MAGNIFIER_ZOOM :: 6.0; // points each is shown as
MAGNIFIER_OFFSET :: 24.0; // points from the cursor
BORDER :: 2.0; // the selection's outline, in points
CLICK_SLOP :: 3; // a drag smaller than this either way, in pixels, is a click
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};
// Every screen: its place and size, then its pixels, all copied at once.
capture_freeze :: () -> ok: bool, error: string {
screens := objc_msgSend_typed(objc_getClass("NSScreen"), selector("screens"));
count := ifx screens then cast(s64) objc_msgSend_typed(screens, selector("count")) else 0;
if !count return false, "there are no screens";
main_height: float64;
capture.screens.allocator = context.default_allocator;
for 0..count - 1 {
ns := msg_object_at(screens, it);
frame := msg_rect(ns, "frame");
if it == 0 main_height = frame.size.height;
s := array_add(*capture.screens);
s.left = frame.origin.x;
s.top = main_height - frame.origin.y - frame.size.height;
s.width = frame.size.width;
s.height = frame.size.height;
s.scale = msg_float(ns, "backingScaleFactor");
s.pixel_width = cast(s32) (s.width * s.scale + 0.5);
s.pixel_height = cast(s32) (s.height * s.scale + 0.5);
description := objc_msgSend_typed(ns, selector("deviceDescription"));
number := msg_object_for_key(description, "NSScreenNumber");
s.display_id = cast(u32) msg_integer(number, "unsignedIntValue");
}
shots := NewArray(capture.screens.count, Mcap_Sck_Shot,, temp);
for capture.screens shots[it_index] = .{display_id = it.display_id, width = it.pixel_width, height = it.pixel_height};
error_text: [512] u8;
copied := mcap_sck_screenshots(shots.data, xx shots.count, error_text.data, error_text.count);
for * capture.screens {
it.pixels = shots[it_index].pixels;
if it.pixels { it.pixel_width, it.pixel_height = shots[it_index].width, shots[it_index].height; }
}
if !copied return false, tprint("the screens could not be copied: %", to_string(error_text.data));
srgb := CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
defer CGColorSpaceRelease(srgb);
for * capture.screens {
provider := CGDataProviderCreateWithData(null, it.pixels, cast(u64) it.pixel_width * cast(u64) it.pixel_height * 4, null);
it.image = CGImageCreate(xx it.pixel_width, xx it.pixel_height, 8, 32, cast(u64) it.pixel_width * 4, srgb, kCGImageAlphaNoneSkipLast | kCGBitmapByteOrder32Big, provider, null, false, kCGRenderingIntentDefault);
CGDataProviderRelease(provider);
if !it.image return false, "the copied screens could not be shown";
}
return true, "";
}
capture_release :: () {
for * capture.screens {
if it.image CGImageRelease(it.image);
if it.pixels mcap_sck_free(it.pixels);
array_free(it.dirty);
}
array_free(capture.screens);
capture = .{};
}
// A window over each screen, the one under the cursor taking the keys.
capture_open :: () -> bool {
view_class, panel_class := capture_classes();
if !view_class || !panel_class return false;
init_core_graphics_runtime_constants();
capture.cursor_screen = -1;
capture_follow_cursor();
for * screen, index: capture.screens {
main_height := capture.screens[0].height;
frame := NSMakeRect(screen.left, main_height - screen.top - screen.height, screen.width, screen.height);
NSWindowStyleMaskNonactivatingPanel :: 1 << 7;
window := NSWindow.initWithContentRect(cast(*NSWindow) objc_alloc(panel_class), frame, NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel, NSBackingStoreBuffered, NO);
if !window return false;
NSWindow.setReleasedWhenClosed(window, NO);
msg_bool(window, "setHasShadow:", false);
msg_bool(window, "setOpaque:", true);
NSWindow.setLevel(window, kCGOverlayWindowLevel);
NSWindow.setCollectionBehavior(window, NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorStationary |
NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorIgnoresCycle);
NSWindowAnimationBehaviorNone :: 2;
msg_integer_set(window, "setAnimationBehavior:", NSWindowAnimationBehaviorNone);
view := cast(*NSView) objc_init_with_frame(objc_alloc(view_class), NSMakeRect(0, 0, screen.width, screen.height));
(cast(*s64) object_getIndexedIvars(view)).* = index;
NSWindow.setContentView(window, view);
release(view);
screen.window, screen.view = window, view;
NSWindow.display(window); // drawn before it shows, so its first frame is not an empty one
objc_msgSend_typed(window, selector("orderFrontRegardless"));
}
objc_msgSend_typed(capture.screens[max(capture.cursor_screen, 0)].window, selector("makeKeyWindow"));
capture_update();
return true;
}
capture_close :: () {
for * capture.screens if it.window {
NSWindow.orderOut(it.window, null);
NSWindow.close(it.window);
release(it.window);
it.window, it.view = null;
}
objc_msgSend_typed(objc_msgSend_typed(objc_getClass("NSCursor"), selector("arrowCursor")), selector("set"));
}
// The overlay's own event loop: the cursor followed, clicks and keys taken, until it is done.
capture_choose :: () {
application := NSApplication.sharedApplication();
crosshair := objc_msgSend_typed(objc_getClass("NSCursor"), selector("crosshairCursor"));
while !capture.done {
objc_msgSend_typed(crosshair, selector("set")); // another app's cursor rects may have set theirs
func_date :: #type (*void, Selector, float64) -> *NSObject #c_call;
until := (cast(func_date) objc_msgSend)(objc_getClass("NSDate"), selector("dateWithTimeIntervalSinceNow:"), 1.0 / 120);
event := NSApplication.nextEventMatchingMask(application, NSEventMaskAny, xx until, NSDefaultRunLoopMode, YES);
if event {
type := NSEvent.type(event);
if type == {
case NSEventTypeLeftMouseDown;
capture_follow_cursor();
capture.dragging = true;
capture.anchor_screen = capture.cursor_screen;
capture.anchor = capture.cursor;
capture_update();
case NSEventTypeLeftMouseUp;
if capture.dragging { capture_follow_cursor(); capture_take(); }
case NSEventTypeRightMouseDown;
if capture.dragging { capture.dragging = false; capture_update(); } // let go, and start again
else capture_cancel();
case NSEventTypeKeyDown;
capture_key(NSEvent.keyCode(event), (NSEvent.modifierFlags(event) & NSEventModifierFlagShift) != 0);
case NSEventTypeLeftMouseDragged; #through;
case NSEventTypeMouseMoved; #through;
case NSEventTypeKeyUp; #through;
case NSEventTypeFlagsChanged; #through;
case NSEventTypeRightMouseUp;
// Taken here, not handed on: the windows have nothing to do with them.
case;
NSApplication.sendEvent(application, event);
}
}
if capture_follow_cursor() capture_update();
}
}
capture_cancel :: () {
capture.cancelled = true;
capture.done = true;
}
// Escape cancels, Return takes the drag (or the screen), the arrows move the cursor a pixel.
capture_key :: (key_code: u16, shift: bool) {
KEY_RETURN :: 36; KEY_ENTER :: 76; KEY_ESCAPE :: 53;
KEY_LEFT :: 123; KEY_RIGHT :: 124; KEY_DOWN :: 125; KEY_UP :: 126;
step: s32 = 1;
if shift step = 10;
dx, dy: s32;
if key_code == {
case KEY_ESCAPE; capture_cancel(); return;
case KEY_RETURN; #through;
case KEY_ENTER; capture_take(); return;
case KEY_LEFT; dx = -step;
case KEY_RIGHT; dx = step;
case KEY_UP; dy = -step;
case KEY_DOWN; dy = step;
case; return;
}
// The cursor moved to the middle of the pixel; following it does the rest.
screen := *capture.screens[capture.cursor_screen];
x := clamp(capture.cursor.x + dx, 0, screen.pixel_width - 1);
y := clamp(capture.cursor.y + dy, 0, screen.pixel_height - 1);
CGWarpMouseCursorPosition(.{screen.left + (x + 0.5) / screen.scale, screen.top + (y + 0.5) / screen.scale});
CGAssociateMouseAndMouseCursorPosition(1);
}
// Where the cursor is now, in points from the main screen's top left.
capture_cursor_points :: () -> float64, float64 {
location := NSEvent.mouseLocation();
return location.x, capture.screens[0].height - location.y;
}
capture_screen_at :: (x: float64, y: float64) -> s64 {
for capture.screens if x >= it.left && x < it.left + it.width && y >= it.top && y < it.top + it.height return it_index;
return 0;
}
// The cursor's screen and pixel, from where it is; true when either changed.
capture_follow_cursor :: () -> bool {
x, y := capture_cursor_points();
index := capture_screen_at(x, y);
screen := *capture.screens[index];
pixel := Pixel.{clamp(cast(s32) floor((x - screen.left) * screen.scale), 0, screen.pixel_width - 1),
clamp(cast(s32) floor((y - screen.top) * screen.scale), 0, screen.pixel_height - 1)};
changed := index != capture.cursor_screen || pixel.x != capture.cursor.x || pixel.y != capture.cursor.y;
capture.cursor_screen, capture.cursor = index, pixel;
return changed;
}
// From the anchor to the cursor, both pixels in it, on the anchor's screen.
capture_selection :: () -> Pixel_Rect {
screen := *capture.screens[capture.anchor_screen];
c := capture.cursor;
if capture.cursor_screen != capture.anchor_screen {
// The cursor on another screen: where it would be on this one, kept to its edges.
other := *capture.screens[capture.cursor_screen];
gx := other.left + capture.cursor.x / other.scale;
gy := other.top + capture.cursor.y / other.scale;
c = .{cast(s32) floor((gx - screen.left) * screen.scale), cast(s32) floor((gy - screen.top) * screen.scale)};
}
c.x = clamp(c.x, 0, screen.pixel_width - 1);
c.y = clamp(c.y, 0, screen.pixel_height - 1);
a := capture.anchor;
return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1};
}
// The selection, or on a click (or Return without a drag) the screen under the cursor.
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;
capture.done = true;
return;
}
}
capture.dragging = false;
capture.result_screen = capture.cursor_screen;
screen := capture.screens[capture.cursor_screen];
capture.result = .{0, 0, screen.pixel_width, screen.pixel_height};
capture.whole_screen = true;
capture.done = true;
}
// What each screen draws now beyond the dimmed copy, and which parts of it to draw again: where they
// were and where they are.
capture_update :: () {
for * screen, index: capture.screens {
if !screen.view continue;
now: [..] CGRect;
now.allocator = context.default_allocator;
for capture_parts(index) array_add(*now, it);
for screen.dirty NSView_setNeedsDisplayInRect(screen.view, it);
for now NSView_setNeedsDisplayInRect(screen.view, it);
array_free(screen.dirty);
screen.dirty = now;
}
}
// The parts of screen index that differ from the dimmed copy, in its points: the selection and its
// outline, or the crosshair; and the magnifier and its label.
capture_parts :: (index: s64) -> [] CGRect {
parts: [..] CGRect;
parts.allocator = temp;
screen := *capture.screens[index];
if capture.dragging && index == capture.anchor_screen {
s := points_of(screen, capture_selection());
array_add(*parts, .{.{s.origin.x - BORDER, s.origin.y - BORDER}, .{s.size.width + 2 * BORDER, s.size.height + 2 * BORDER}});
}
if index == capture.cursor_screen {
if !capture.dragging {
x, y := capture.cursor.x / screen.scale, capture.cursor.y / screen.scale;
array_add(*parts, .{.{0, y}, .{screen.width, 1 / screen.scale}});
array_add(*parts, .{.{x, 0}, .{1 / screen.scale, screen.height}});
}
magnifier, label := magnifier_layout(screen);
array_add(*parts, .{.{magnifier.origin.x - 1, magnifier.origin.y - 1}, .{magnifier.size.width + 2, magnifier.size.height + 2}});
array_add(*parts, label);
}
return parts;
}
points_of :: (screen: *Capture_Screen, r: Pixel_Rect) -> CGRect {
return .{.{r.left / screen.scale, r.top / screen.scale}, .{(r.right - r.left) / screen.scale, (r.bottom - r.top) / screen.scale}};
}
// The magnifier below and right of the cursor, or on the other side where the screen ends, and the
// label under it.
magnifier_layout :: (screen: *Capture_Screen) -> magnifier: CGRect, label: CGRect {
side: float64 = MAGNIFIER_PIXELS * MAGNIFIER_ZOOM + 2;
label_height: float64 = 22;
label_width := max(side, text_size(label_text(screen)).width + 12);
x := capture.cursor.x / screen.scale + MAGNIFIER_OFFSET;
y := capture.cursor.y / screen.scale + MAGNIFIER_OFFSET;
if x + max(side, label_width) > screen.width x = capture.cursor.x / screen.scale - MAGNIFIER_OFFSET - max(side, label_width);
if y + side + label_height > screen.height y = capture.cursor.y / screen.scale - MAGNIFIER_OFFSET - side - label_height;
return .{.{x, y}, .{side, side}}, .{.{x, y + side}, .{label_width, label_height}};
}
// The position (in the screen's pixels), or the selection's size.
label_text :: (screen: *Capture_Screen) -> string {
if capture.dragging {
s := capture_selection();
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);
CGContextFillRect(cg, whole);
fill :: (cg: CGContextRef, r: CGRect, color: Vector4) {
CGContextSetRGBFillColor(cg, color.x, color.y, color.z, color.w);
CGContextFillRect(cg, r);
}
if capture.dragging && index == capture.anchor_screen {
s := points_of(screen, capture_selection());
CGContextSaveGState(cg);
CGContextClipToRect(cg, s);
draw_image(cg, screen.image, whole);
CGContextRestoreGState(cg);
// 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);
}
// The magnifier: the pixels around the cursor, as copied, each MAGNIFIER_ZOOM points wide; past
// the screen's edges it is black.
magnifier, label := magnifier_layout(screen);
fill(cg, magnifier, .{1, 1, 1, 1});
inner := CGRect.{.{magnifier.origin.x + 1, magnifier.origin.y + 1}, .{magnifier.size.width - 2, magnifier.size.height - 2}};
fill(cg, inner, .{0, 0, 0, 1});
half :: MAGNIFIER_PIXELS / 2;
left := max(capture.cursor.x - half, 0);
top := max(capture.cursor.y - half, 0);
right := min(capture.cursor.x + half + 1, screen.pixel_width);
bottom := min(capture.cursor.y + half + 1, screen.pixel_height);
if right > left && bottom > top {
part := CGImageCreateWithImageInRect(screen.image, .{.{xx left, xx top}, .{xx (right - left), xx (bottom - top)}});
if part {
CGContextSetInterpolationQuality(cg, kCGInterpolationNone);
draw_image(cg, part, .{.{inner.origin.x + (left - (capture.cursor.x - half)) * MAGNIFIER_ZOOM, inner.origin.y + (top - (capture.cursor.y - half)) * MAGNIFIER_ZOOM},
.{(right - left) * MAGNIFIER_ZOOM, (bottom - top) * MAGNIFIER_ZOOM}});
CGImageRelease(part);
}
}
// 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);
// Under it, the position, or the selection's size.
fill(cg, label, LABEL);
draw_text(label_text(screen), label.origin.x + 6, label.origin.y + 3);
}
// image upright in rect, in a flipped context.
draw_image :: (cg: CGContextRef, image: CGImageRef, rect: CGRect) {
CGContextSaveGState(cg);
CGContextTranslateCTM(cg, rect.origin.x, rect.origin.y + rect.size.height);
CGContextScaleCTM(cg, 1, -1);
CGContextDrawImage(cg, .{.{0, 0}, rect.size}, image);
CGContextRestoreGState(cg);
}
// The label's font: the system's, semibold, 13 points, white.
text_attributes :: () -> *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;
color := (cast(func_color) objc_msgSend)(objc_getClass("NSColor"), selector("colorWithSRGBRed:green:blue:alpha:"), TEXT.x, TEXT.y, TEXT.z, TEXT.w);
keys := *void.[NSFontAttributeName, NSForegroundColorAttributeName];
values := *void.[font, 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());
}
draw_text :: (text: string, x: float64, y: float64) {
func :: #type (*void, Selector, NSPoint, *NSObject) -> void #c_call;
(cast(func) objc_msgSend)(NSString.getTempString(text), selector("drawAtPoint:withAttributes:"), NSMakePoint(x, y), text_attributes());
}
// ---------------------------------------------------------------- the window it came from
// The front-most window, other than mcap's, whose bounds hold the point; its title and app. Else the
// app that was in front.
window_info :: (shot: *Screen_Shot, x: float64, y: float64, front_name: string, front_path: string) {
push_allocator(context.default_allocator);
windows := CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, 0);
own := getpid();
found := false;
if windows {
defer CFRelease(windows);
for 0..CFArrayGetCount(windows) - 1 {
info := CFArrayGetValueAtIndex(windows, it);
layer := cf_integer(CFDictionaryGetValue(info, kCGWindowLayer));
pid := cf_integer(CFDictionaryGetValue(info, kCGWindowOwnerPID));
if layer != 0 || pid == own continue; // the menu bar, the Dock, overlays; and mcap's own
bounds: CGRect;
if !CGRectMakeWithDictionaryRepresentation(CFDictionaryGetValue(info, kCGWindowBounds), *bounds) continue;
if x < bounds.origin.x || y < bounds.origin.y || x >= bounds.origin.x + bounds.size.width || y >= bounds.origin.y + bounds.size.height continue;
shot.window_title = copy_string(cf_string(CFDictionaryGetValue(info, kCGWindowName)));
name, path := app_of(cast(s32) pid);
if !name name = cf_string(CFDictionaryGetValue(info, kCGWindowOwnerName));
shot.app_name = copy_string(name);
shot.app_path = copy_string(path);
found = true;
break;
}
}
if !found {
shot.app_name = copy_string(front_name);
shot.app_path = copy_string(front_path);
}
}
// The app in front: its name and bundle. Temp.
frontmost_app :: () -> name: string, path: string {
workspace := objc_msgSend_typed(objc_getClass("NSWorkspace"), selector("sharedWorkspace"));
app := objc_msgSend_typed(workspace, selector("frontmostApplication"));
name, path := app_name_and_path(app);
return name, path;
}
app_of :: (pid: s32) -> name: string, path: string {
func :: #type (*void, Selector, s32) -> *NSObject #c_call;
app := (cast(func) objc_msgSend)(objc_getClass("NSRunningApplication"), selector("runningApplicationWithProcessIdentifier:"), pid);
name, path := app_name_and_path(app);
return name, path;
}
app_name_and_path :: (app: *void) -> name: string, path: string {
if !app return "", "";
name := ns_string(objc_msgSend_typed(app, selector("localizedName")));
url := objc_msgSend_typed(app, selector("bundleURL"));
if !url url = objc_msgSend_typed(app, selector("executableURL"));
path := ifx url then ns_string(objc_msgSend_typed(url, selector("path"))) else "";
return name, path;
}
ns_string :: (s: *void) -> string {
if !s return "";
return copy_string(to_string(cast(*NSString) s),, temp);
}
cf_integer :: (number: *void) -> s64 {
value: s64;
if number CFNumberGetValue(number, kCFNumberSInt64Type, *value);
return value;
}
cf_string :: (s: *void) -> string {
if !s return "";
buffer: [1024] u8;
if !CFStringGetCString(s, buffer.data, buffer.count, kCFStringEncodingUTF8) return "";
return copy_string(to_string(buffer.data),, temp);
}
// ---------------------------------------------------------------- the classes, and messages
// McapCaptureView, an NSView whose drawRect: draws its screen and whose y goes down, its extra bytes
// its screen's index; and McapCapturePanel, an NSPanel that can be the key window with no title bar.
capture_classes :: () -> view: Class, panel: Class {
if view_class && panel_class return view_class, panel_class;
view_class = objc_getClass("McapCaptureView");
if !view_class {
view_class = objc_allocateClassPair(objc_getClass("NSView"), "McapCaptureView", size_of(s64));
if !view_class return null, null;
draw_rect :: (self: id, _sel: Selector, dirty: NSRect) #c_call {
push_context capture_context {
index := (cast(*s64) object_getIndexedIvars(self)).*;
if index < 0 || index >= capture.screens.count return;
context_object := objc_msgSend_typed(objc_getClass("NSGraphicsContext"), selector("currentContext"));
cg := ifx context_object then cast(CGContextRef) objc_msgSend_typed(context_object, selector("CGContext")) else null;
if cg capture_draw(index, cg);
}
}
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("isFlipped"), xx yes, "c@:");
class_addMethod(view_class, selector("isOpaque"), xx yes, "c@:");
class_addMethod(view_class, selector("acceptsFirstMouse:"), xx yes_for_event, "c@:@");
objc_registerClassPair(view_class);
}
panel_class = objc_getClass("McapCapturePanel");
if !panel_class {
panel_class = objc_allocateClassPair(objc_getClass("NSPanel"), "McapCapturePanel", 0);
if !panel_class return null, null;
yes :: (self: id, _sel: Selector) -> BOOL #c_call { return YES; }
no :: (self: id, _sel: Selector) -> BOOL #c_call { return NO; }
class_addMethod(panel_class, selector("canBecomeKeyWindow"), xx yes, "c@:");
class_addMethod(panel_class, selector("canBecomeMainWindow"), xx no, "c@:");
objc_registerClassPair(panel_class);
}
capture_context = context;
return view_class, panel_class;
}
view_class, panel_class: Class;
capture_context: #Context; // drawRect: runs on the main thread, in the capture's own event loop
msg_object_at :: (array: *void, index: s64) -> *void {
func :: #type (*void, Selector, u64) -> *void #c_call;
return (cast(func) objc_msgSend)(array, selector("objectAtIndex:"), xx index);
}
msg_object_for_key :: (dictionary: *void, key: string) -> *void {
func :: #type (*void, Selector, *NSString) -> *void #c_call;
return (cast(func) objc_msgSend)(dictionary, selector("objectForKey:"), NSString.getTempString(key));
}
msg_rect :: (object: *void, name: string) -> NSRect {
func :: #type (*void, Selector) -> NSRect #c_call;
return (cast(func) objc_msgSend)(object, selector(name));
}
msg_float :: (object: *void, name: string) -> float64 {
func :: #type (*void, Selector) -> float64 #c_call;
return (cast(func) objc_msgSend)(object, selector(name));
}
msg_integer :: (object: *void, name: string) -> s64 {
func :: #type (*void, Selector) -> s64 #c_call;
return (cast(func) objc_msgSend)(object, selector(name));
}
msg_integer_set :: (object: *void, name: string, value: s64) {
func :: #type (*void, Selector, s64) -> void #c_call;
(cast(func) objc_msgSend)(object, selector(name), value);
}
msg_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);
}
objc_init_with_frame :: (object: *void, frame: NSRect) -> *void {
func :: #type (*void, Selector, NSRect) -> *void #c_call;
return (cast(func) objc_msgSend)(object, selector("initWithFrame:"), frame);
}
NSView_setNeedsDisplayInRect :: (view: *NSView, rect: CGRect) {
func :: #type (*void, Selector, NSRect) -> void #c_call;
(cast(func) objc_msgSend)(view, selector("setNeedsDisplayInRect:"), NSMakeRect(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height));
}
// ---------------------------------------------------------------- foreign
Mcap_Sck_Shot :: struct {
display_id: u32;
width, height: s32;
pixels: *u8;
}
mcap_capture :: #library,no_dll "../../modules/Screen_Record/macos/capture";
mcap_sck_screenshots :: (shots: *Mcap_Sck_Shot, count: s32, error: *u8, error_size: s32) -> s32 #foreign mcap_capture;
mcap_sck_free :: (pointer: *void) #foreign mcap_capture;
ScreenCaptureKit_Framework :: #library,system,link_always "ScreenCaptureKit";
CGContextRef :: *void;
CGImageRef :: *void;
CGPoint :: struct { x, y: float64; }
CGSize :: struct { width, height: float64; }
CGRect :: struct { origin: CGPoint; size: CGSize; }
kCGImageAlphaNoneSkipLast :: 5;
kCGBitmapByteOrder32Big :: 4 << 12;
kCGRenderingIntentDefault :: 0;
kCGInterpolationNone :: 1;
kCGWindowListOptionOnScreenOnly :: 1 << 0;
kCGWindowListExcludeDesktopElements :: 1 << 4;
kCFNumberSInt64Type :: 4;
kCFStringEncodingUTF8 :: 0x0800_0100;
CoreGraphics_Framework :: #library,system "CoreGraphics";
CoreFoundation_Framework :: #library,system "CoreFoundation";
kCGColorSpaceSRGB: *void #elsewhere CoreGraphics_Framework;
kCGWindowLayer: *void #elsewhere CoreGraphics_Framework;
kCGWindowBounds: *void #elsewhere CoreGraphics_Framework;
kCGWindowName: *void #elsewhere CoreGraphics_Framework;
kCGWindowOwnerName: *void #elsewhere CoreGraphics_Framework;
kCGWindowOwnerPID: *void #elsewhere CoreGraphics_Framework;
AppKit_Framework :: #library,system "AppKit"; // the Objective_C module keeps its own to itself
NSFontAttributeName: *NSString #elsewhere AppKit_Framework;
NSForegroundColorAttributeName: *NSString #elsewhere AppKit_Framework;
CGPreflightScreenCaptureAccess :: () -> bool #foreign CoreGraphics_Framework;
CGRequestScreenCaptureAccess :: () -> bool #foreign CoreGraphics_Framework;
CGWarpMouseCursorPosition :: (point: CGPoint) -> s32 #foreign CoreGraphics_Framework;
CGAssociateMouseAndMouseCursorPosition :: (connected: s32) -> s32 #foreign CoreGraphics_Framework;
CGColorSpaceCreateWithName :: (name: *void) -> *void #foreign CoreGraphics_Framework;
CGColorSpaceRelease :: (space: *void) #foreign CoreGraphics_Framework;
CGDataProviderCreateWithData :: (info: *void, data: *void, size: u64, release: *void) -> *void #foreign CoreGraphics_Framework;
CGDataProviderRelease :: (provider: *void) #foreign CoreGraphics_Framework;
CGImageCreate :: (width: u64, height: u64, bits_per_component: u64, bits_per_pixel: u64, bytes_per_row: u64, space: *void, bitmap_info: u32, provider: *void, decode: *float64, interpolate: bool, intent: s32) -> CGImageRef #foreign CoreGraphics_Framework;
CGImageCreateWithImageInRect :: (image: CGImageRef, rect: CGRect) -> CGImageRef #foreign CoreGraphics_Framework;
CGImageRelease :: (image: CGImageRef) #foreign CoreGraphics_Framework;
CGContextDrawImage :: (c: CGContextRef, rect: CGRect, image: CGImageRef) #foreign CoreGraphics_Framework;
CGContextSaveGState :: (c: CGContextRef) #foreign CoreGraphics_Framework;
CGContextRestoreGState :: (c: CGContextRef) #foreign CoreGraphics_Framework;
CGContextTranslateCTM :: (c: CGContextRef, x: float64, y: float64) #foreign CoreGraphics_Framework;
CGContextScaleCTM :: (c: CGContextRef, x: float64, y: float64) #foreign CoreGraphics_Framework;
CGContextClipToRect :: (c: CGContextRef, rect: CGRect) #foreign CoreGraphics_Framework;
CGContextSetRGBFillColor :: (c: CGContextRef, r: float64, g: float64, b: float64, a: float64) #foreign CoreGraphics_Framework;
CGContextFillRect :: (c: CGContextRef, rect: CGRect) #foreign CoreGraphics_Framework;
CGContextSetInterpolationQuality :: (c: CGContextRef, quality: s32) #foreign CoreGraphics_Framework;
CGWindowListCopyWindowInfo :: (option: u32, relative_to: u32) -> *void #foreign CoreGraphics_Framework;
CGRectMakeWithDictionaryRepresentation :: (dictionary: *void, rect: *CGRect) -> bool #foreign CoreGraphics_Framework;
CFArrayGetCount :: (array: *void) -> s64 #foreign CoreFoundation_Framework;
CFArrayGetValueAtIndex :: (array: *void, index: s64) -> *void #foreign CoreFoundation_Framework;
CFDictionaryGetValue :: (dictionary: *void, key: *void) -> *void #foreign CoreFoundation_Framework;
CFNumberGetValue :: (number: *void, type: s64, value: *void) -> bool #foreign CoreFoundation_Framework;
CFStringGetCString :: (s: *void, buffer: *u8, size: s64, encoding: u32) -> bool #foreign CoreFoundation_Framework;
CFRelease :: (object: *void) #foreign CoreFoundation_Framework;
getpid :: () -> s32 #foreign libc;
libc :: #library,system "libc";
#import "Objective_C";
#import "Objective_C/AppKit";
#import "Process";

View File

@ -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);
@ -190,21 +193,22 @@ screen_record_leftover_date :: (path: string) -> string {
}
screen_record_folder :: () -> string {
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
else return tprint("/tmp/%", APP_FOLDER_NAME);
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); }
@ -221,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);
@ -254,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.
@ -412,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";
}
}

View File

@ -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";

View File

@ -114,7 +114,7 @@ segment_colour_set :: (class: string, rgb: [3] u8, default := false, save := tru
}
segment_models_folder :: () -> string {
return tprint("%segment-models", path_strip_filename(get_path_of_running_executable()));
return tprint("%segment-models", app_resource_directory());
}
// ---------------------------------------------------------------- startup and shutdown
@ -198,7 +198,7 @@ segment_ui_scan_task :: () {
return;
}
segment_ui.engine = engine;
log("[Segment] % on % in % ms.%", SEGMENT_MODEL, engine.provider, to_milliseconds(current_time_monotonic() - start), ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
log("[Segment] % on % in % ms.%", SEGMENT_MODEL, segment_provider_name(engine), to_milliseconds(current_time_monotonic() - start), ifx engine.gpu_explanation then tprint(" %", engine.gpu_explanation) else "");
}
engine := segment_ui.engine;
// Cancelling (or closing the app) stops it between batches of the engine's, rather than waiting the
@ -335,7 +335,7 @@ segment_window_contents :: () { using ImGui;
// The model.
model_file := tprint("%/%.onnx", segment_models_folder(), SEGMENT_MODEL);
if !busy && segment_ui.engine {
Text("% on %", SEGMENT_MODEL, ifx segment_ui.engine.provider == .CUDA then "the GPU" else "the CPU");
Text("% on %", SEGMENT_MODEL, ifx segment_ui.engine.provider != .CPU then tprint("the GPU (%)", segment_provider_name(segment_ui.engine)) else "the CPU");
if segment_ui.engine.gpu_explanation TextColored(app_theme.palette.dim, "%", segment_ui.engine.gpu_explanation);
} else if !busy && segment_ui.engine_error {
TextColored(app_theme.palette.warning, "% could not start: %", SEGMENT_MODEL, segment_ui.engine_error);

View File

@ -16,10 +16,43 @@
// appends the new ones. The library is watched (viewer_watch_frame), so files added or deleted
// outside the app come and go on their own; Refresh is still there for a watcher that missed some.
VIEWER_FOLDER :: "D:/McapIndex/mcap"; // hardcoded for now; -library FOLDER shows another
// The library when [library] folder in settings.config names none; -library FOLDER overrides both.
#if OS == .WINDOWS LIBRARY_FOLDER_DEFAULT :: "D:/McapIndex/mcap";
else LIBRARY_FOLDER_DEFAULT :: "~/Pictures/mcap";
// Set once at startup (library_folder_choose), so the library never changes under a running app.
viewer_folder :: () -> string {
return ifx app.library_folder then app.library_folder else VIEWER_FOLDER;
return app.library_folder;
}
// -library, else the setting, else the default. Called once settings.config is read.
library_folder_choose :: () {
if app.library_folder return;
folder := ifx app_user_settings.library_folder.count then app_user_settings.library_folder else LIBRARY_FOLDER_DEFAULT;
app.library_folder = library_folder_normalize(folder);
// The default is made on first use, so the catalog cache and imports have somewhere to go. Any
// other folder is left to the user: a mistyped one is not made.
default := library_folder_normalize(LIBRARY_FOLDER_DEFAULT);
defer free(default,, context.default_allocator);
if app.library_folder == default && !file_exists(app.library_folder) {
if make_directory_if_it_does_not_exist(app.library_folder, recursive = true) log("[library] Made the library folder %.", app.library_folder);
else log_error("[library] Could not make the library folder %.", app.library_folder);
}
}
// With / between its folders and none at the end, as the paths found in it are made, and a leading
// ~/ for the home folder. Heap-allocated.
library_folder_normalize :: (folder: string) -> string {
expanded := folder;
#if OS != .WINDOWS if begins_with(folder, "~/") || folder == "~" {
home := to_string(POSIX.getenv("HOME"));
if home.count expanded = tprint("%1%2", trim_right(home, "/"), advance(folder, 1));
}
result := copy_string(expanded,, context.default_allocator);
path_overwrite_separators(result, #char "/");
while result.count > 3 && result[result.count - 1] == #char "/" result.count -= 1;
return result;
}
VIEWER_WORKER_COUNT :: 4;
VIEWER_MAX_TEXTURES :: 1200; // previews on the GPU at once, about 200 KB each
@ -620,13 +653,13 @@ viewer_list_files :: () -> [..] Listed_File {
}
// Whether path, a file or folder, is where the library keeps captures: under it, and not hidden or
// in the deleted folder viewer_delete_capture moves files to off Windows.
// in the deleted folder viewer_delete_capture moves files to on Linux.
viewer_is_library_path :: (path: string) -> bool {
folder := viewer_folder();
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
relative := advance(path, folder.count + 1);
if begins_with(relative, ".") || contains(relative, "/.") return false;
#if OS != .WINDOWS if relative == "deleted" || begins_with(relative, "deleted/") return false;
#if OS == .LINUX if relative == "deleted" || begins_with(relative, "deleted/") return false;
return true;
}
@ -804,13 +837,13 @@ viewer_mark_removed :: (index: s32) {
}
Viewer_Delete_Mode :: enum u8 {
RECYCLE; // to the Recycle Bin, or the deleted folder elsewhere: can be undone
RECYCLE; // to the Recycle Bin, the Trash, or the deleted folder on Linux: can be undone
OVERWRITE; // overwritten with random bytes, renamed, then deleted for good
}
// Deletes the capture's file, then takes it out of the grid. RECYCLE: to the Recycle Bin on Windows
// (Windows asks first when the drive has none and it would be deleted for good), elsewhere into a
// deleted folder beside it. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
// (Windows asks first when the drive has none and it would be deleted for good), the Trash on macOS,
// and on Linux into a deleted folder in the library. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
// is still there.
viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string {
capture := *viewer.captures[index];
@ -827,6 +860,9 @@ viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, err
result := SHFileOperationW(*operation);
if operation.fAnyOperationsAborted return false, "cancelled";
if result != 0 return false, tprint("the shell could not delete it (error 0x%)", formatInt(result, base = 16));
} else #if OS == .MACOS {
ok, error := macos_move_to_trash(capture.path);
if !ok return false, tprint("it could not be moved to the Trash: %", error);
} else {
folder := tprint("%/deleted", viewer_folder());
make_directory_if_it_does_not_exist(folder);
@ -887,8 +923,9 @@ viewer_delete_now :: (index: s32) {
else toast_show(.Success, "Overwrote and deleted %", name);
}
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else VIEWER_TRASH_NAME :: "deleted folder";
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else #if OS == .MACOS VIEWER_TRASH_NAME :: "Trash";
else VIEWER_TRASH_NAME :: "deleted folder";
// The confirmation for viewer.delete_index, as a modal popup.
viewer_delete_popup :: () { using ImGui;
@ -1115,6 +1152,9 @@ viewer_ui :: () { using ImGui;
if Button("Segments") segment_ui.window_open = !segment_ui.window_open; // app/segment_ui.jai
if IsItemHovered() SetTooltip("Segmentation: what is where in the images of the library folders you choose");
SameLine();
if Button("GPU Bench") gpu_bench_ui.window_open = !gpu_bench_ui.window_open; // app/gpu_bench_ui.jai
if IsItemHovered() SetTooltip("Time every GPU model over a fixed corpus, and compare machines");
SameLine();
if Button("Import") importer.window_open = !importer.window_open; // app/import.jai
if IsItemHovered() SetTooltip("Bring in a folder of screenshots and photos");
SameLine();
@ -1126,7 +1166,7 @@ viewer_ui :: () { using ImGui;
if IsItemHovered() {
engine: *Ocr_Engine;
if !ocr_ui.thread_busy engine = ocr_ui.engine; // the background thread owns it while busy
if engine SetTooltip("OCR with %, on %", engine.model.name, ifx engine.provider == .CUDA then "the GPU" else "the CPU");
if engine SetTooltip("OCR with %, on %", engine.model.name, ifx ocr_gpu_status(engine) then tprint("the GPU (%)", ocr_provider_name(engine)) else "the CPU");
else SetTooltip("OCR is not loaded, is loading, or could not start; the OCR window says which");
}
@ -1241,9 +1281,7 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
exporting := viewer.open_folder.count > 0;
folder := viewer.open_folder;
if !exporting {
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%");
else base := "/tmp";
folder = tprint("%/%", base, APP_FOLDER_NAME);
folder = tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
}
make_directory_if_it_does_not_exist(folder, recursive = true);
if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; }
@ -1366,10 +1404,10 @@ viewer_export_popup :: () { using ImGui;
if custom {
SetNextItemWidth(GetFontSize() * 30);
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
#if OS == .WINDOWS {
#if OS == .WINDOWS || OS == .MACOS {
SameLine();
if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to export to");
chosen := platform_choose_folder("Choose a folder to export to");
if chosen viewer_export_set_buffer(chosen);
}
}
@ -1431,7 +1469,7 @@ viewer_copy_image :: () {
index := viewer_selected();
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
capture := *viewer.captures[index];
#if OS != .WINDOWS {
#if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet");
return;
}

View File

@ -9,6 +9,13 @@ platform_get_path_config_data :: inline () -> string {
return XDG.get_path(.CONFIG_HOME);
}
// $TMPDIR, else /tmp, without a separator at the end. Temp.
platform_temporary_folder :: () -> string {
folder := posix_getenv("TMPDIR");
if !folder return "/tmp";
return trim_right(copy_string(folder,, temp), "/");
}
// A tag such as ja-JP from the locale variables, or "". Temp.
platform_preferred_language :: () -> string {
for string.["LC_ALL", "LC_MESSAGES", "LANG"] {

View File

@ -70,6 +70,93 @@ platform_cjk_font_candidates :: () -> [] Cjk_Font {
return list;
}
// ---------------------------------------------------------------- folders, the Trash and the clipboard
// The user's own temporary folder, NSTemporaryDirectory() (under /var/folders, cleared by macOS),
// not /tmp, which every user shares. No separator at the end. Temp.
platform_temporary_folder :: () -> string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
folder := copy_string(to_string(NSTemporaryDirectory()),, temp); // the NSString goes with the pool
return trim_right(folder, "/");
}
// Asks for a folder with the system's open panel, over the app's window: its path (temp), or ""
// when none was chosen. Main thread; it runs modally until the panel is closed.
platform_choose_folder :: (title: string) -> string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
panel := NSOpenPanel.openPanel();
if !panel return "";
func_bool :: #type (*void, Selector, BOOL) -> void #c_call;
func_string :: #type (*void, Selector, *NSString) -> void #c_call;
(cast(func_bool) objc_msgSend)(panel, selector("setCanChooseDirectories:"), YES);
(cast(func_bool) objc_msgSend)(panel, selector("setCanChooseFiles:"), NO);
(cast(func_bool) objc_msgSend)(panel, selector("setAllowsMultipleSelection:"), NO);
(cast(func_bool) objc_msgSend)(panel, selector("setCanCreateDirectories:"), YES);
(cast(func_string) objc_msgSend)(panel, selector("setMessage:"), NSString.getTempString(title));
(cast(func_string) objc_msgSend)(panel, selector("setPrompt:"), NSString.getTempString("Choose"));
save_panel := cast(*NSSavePanel) panel;
if NSSavePanel.runModal(save_panel) != NSModalResponseOK return "";
url := NSSavePanel.URL(save_panel);
if !url return "";
return copy_string(to_string(NSURL.path(url)),, temp);
}
// Moves a file to the Trash, where Finder's Put Back returns it to where it was. Error is "" or why not.
macos_move_to_trash :: (path: string) -> ok: bool, error: string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
url := NSURL.fileURLWithPath(NSString.getTempString(path));
error: *NSError;
func :: #type (*void, Selector, *NSURL, **NSURL, **NSError) -> BOOL #c_call;
if (cast(func) objc_msgSend)(NSFileManager.defaultManager(), selector("trashItemAtURL:resultingItemURL:error:"), url, null, *error) == YES return true, "";
why := ifx error then copy_string(to_string(NSError.localizedDescription(error)),, temp) else "no reason given";
return false, why;
}
// Puts RGBA pixels, top row first, on the clipboard as PNG and TIFF, in sRGB and opaque, as the
// Windows version makes them. Every app that pastes images takes one of the two.
platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool {
if width <= 0 || height <= 0 || pixels.count < cast(s64) width * height * 4 return false;
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
// AppKit allocates the pixels (planes null) and frees them with the rep.
rep := NSBitmapImageRep.initWithBitmapDataPlanes(objc_alloc(NSBitmapImageRep), null, width, height, 8, 4, YES, NO, NSDeviceRGBColorSpace, cast(s64) width * 4, 32);
if !rep return false;
autorelease(rep);
out := NSBitmapImageRep.bitmapData(rep);
if !out return false;
memcpy(out, pixels.data, cast(s64) width * height * 4);
for 0..cast(s64) width * height - 1 out[it * 4 + 3] = 255;
// Tagged sRGB, as the pixels are, so a paste doesn't shift their colors on a wide-gamut screen.
func_object :: #type (*void, Selector) -> *NSObject #c_call;
func_retag :: #type (*void, Selector, *NSObject) -> *NSBitmapImageRep #c_call;
srgb := (cast(func_object) objc_msgSend)(objc_getClass("NSColorSpace"), selector("sRGBColorSpace"));
tagged := (cast(func_retag) objc_msgSend)(rep, selector("bitmapImageRepByRetaggingWithColorSpace:"), srgb);
if tagged rep = tagged;
NSBitmapImageFileTypePNG :: 4;
func_png :: #type (*void, Selector, NSUInteger, *NSObject) -> *NSData #c_call;
properties := (cast(func_object) objc_msgSend)(objc_getClass("NSDictionary"), selector("dictionary"));
png := (cast(func_png) objc_msgSend)(rep, selector("representationUsingType:properties:"), NSBitmapImageFileTypePNG, properties);
tiff := cast(*NSData) (cast(func_object) objc_msgSend)(rep, selector("TIFFRepresentation"));
if !png && !tiff return false;
pasteboard := NSPasteboard.generalPasteboard();
NSPasteboard.clearContents(pasteboard);
func_set :: #type (*void, Selector, *NSData, NSPasteboardType) -> BOOL #c_call;
set := false;
if png set = (cast(func_set) objc_msgSend)(pasteboard, selector("setData:forType:"), png, NSPasteboardTypePNG) == YES || set;
if tiff set = (cast(func_set) objc_msgSend)(pasteboard, selector("setData:forType:"), tiff, NSPasteboardTypeTIFF) == YES || set;
return set;
}
NSTemporaryDirectory :: () -> *NSString #foreign macos_foundation;
macos_foundation :: #library,system "Foundation"; // the Objective_C module keeps its own to itself
Mach :: #import "macOS"; // task_info
#import "Objective_C";

View File

@ -846,6 +846,13 @@ os_log_error :: () {
error_code := GetLastError();
log_error(" > GetLastError code: %, %", error_code, get_error_string(error_code));
}
// %TEMP%, without a separator at the end. Temp.
platform_temporary_folder :: () -> string {
return trim_right(windows_expand_environment("%TEMP%"), "/\\");
}
platform_choose_folder :: (title: string) -> string { return windows_choose_folder(title); }
// Asks for a folder with Explorer's own picker, over the app's window: its path (temp), or "" when
// none was chosen.
windows_choose_folder :: (title: string) -> string {

View File

@ -152,6 +152,39 @@ exif_tests :: () {
expect(exif_read(cast([] u8) "\xFF\xD8\xFF\xDA").orientation == 1, "a JPEG with none is upright");
}
// heif_info: a HEIF's primary item's size, rotation and mirroring, from a meta box made here.
heif_tests :: () {
group("heif");
box :: (type: string, contents: [] u8) -> [] u8 {
out: [..] u8;
out.allocator = temp;
size := contents.count + 8;
array_add(*out, cast(u8) (size >> 24), cast(u8) (size >> 16), cast(u8) (size >> 8), cast(u8) size);
array_add(*out, ..cast([] u8) type);
array_add(*out, ..contents);
return out;
}
join :: (parts: ..[] u8) -> [] u8 {
out: [..] u8;
out.allocator = temp;
for parts array_add(*out, ..it);
return out;
}
for turns: 0..3 {
ispe := box("ispe", u8.[0,0,0,0, 0,0,0,64, 0,0,0,48]);
irot := box("irot", u8.[cast(u8) turns]);
imir := box("imir", u8.[1]);
ipco := box("ipco", join(ispe, irot, imir));
ipma := box("ipma", u8.[0,0,0,0, 0,0,0,1, 0,1, 3, 0x81, 0x82, 0x03]); // item 1: properties 1-3
pitm := box("pitm", u8.[0,0,0,0, 0,1]);
meta := box("meta", join(u8.[0,0,0,0], pitm, box("iprp", join(ipco, ipma))));
heif := heif_info(join(box("ftyp", cast([] u8) "heic\0\0\0\0mif1heic"), meta));
expect(heif.width == 64 && heif.height == 48, "irot %: the size, % x %", turns, heif.width, heif.height);
expect(heif.rotation == turns * 90, "irot %: % degrees (expected %)", turns, heif.rotation, turns * 90);
expect(heif.mirror == 1, "irot %: imir read", turns);
}
}
orientation_tests :: () {
group("orientation");
// 3 x 2, pixels numbered by where they are: 0 1 2 / 3 4 5. Each orientation's result, row by row.
@ -861,6 +894,7 @@ run_import_tests :: (builder: *String_Builder = null) -> bool {
push_allocator(context.default_allocator);
kind_tests();
exif_tests();
heif_tests();
orientation_tests();
date_tests();
folder_tests();

View File

@ -83,7 +83,34 @@ labels_tests :: () {
expect(created, "the label engine starts: %", error);
if !created return;
defer labels_destroy(engine);
emit(" images on %; % dimensions\n", ifx engine.provider == .CUDA then "CUDA" else "the CPU", engine.dimensions);
emit(" images on %; % dimensions\n", labels_provider_name(engine), engine.dimensions);
#if OS == .MACOS if engine.provider == .COREML {
// Core ML's vectors against onnxruntime's on the CPU, for an image of bands and a gradient: the
// float16 model on the GPU is within cosine 0.99999 of the CPU's on real images.
group("core ml");
cpu, cpu_created, cpu_error := labels_create(folder, LABELS_MODEL, prefer_gpu = false);
expect(cpu_created && cpu.provider == .CPU, "a CPU engine to compare with starts: %", cpu_error);
if cpu_created {
defer labels_destroy(cpu);
W :: 320; H :: 200;
pixels := NewArray(W * H * 4, u8,, temp);
for y: 0..H - 1 for x: 0..W - 1 {
p := pixels.data + (y * W + x) * 4;
p[0] = cast,trunc(u8) (x * 255 / W);
p[1] = cast(u8) (ifx (y / 25) % 2 then 220 else 40);
p[2] = cast,trunc(u8) (y * 255 / H);
p[3] = 255;
}
image := Labels_Image.{pixels.data, W, H, W * 4};
on_gpu, gpu_error := labels_embed_images(engine, .[image],, temp);
on_cpu, cpu_run_error := labels_embed_images(cpu, .[image],, temp);
expect(!gpu_error && !cpu_run_error, "both make the image's vector (% / %)", gpu_error, cpu_run_error);
cosine: float;
for 0..engine.dimensions - 1 cosine += on_gpu[it] * on_cpu[it];
expect(cosine > 0.999, "Core ML's vector is the CPU's: cosine %", cosine);
}
}
group("tokenizer");
for KNOWN {

View File

@ -169,7 +169,7 @@ turn_tests :: () {
if !created return;
defer ocr_destroy(engine);
expect(engine.orientation.session != null, "% has an orientation classifier", OCR_DEFAULT_MODEL);
emit(" running on %; classifier input % x %\n", ifx engine.provider == .CUDA then "CUDA" else "the CPU", engine.orientation_width, engine.orientation_height);
emit(" running on %; classifier input % x %\n", ocr_provider_name(engine), engine.orientation_width, engine.orientation_height);
level, drawn := drawn_lines();
expect(drawn, "the test text is drawn");

View File

@ -349,8 +349,8 @@ 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, true}); // app/screen_capture.jai
array_add(out, .{"record_region", *record_region_hotkey, true}); // app/screen_record.jai
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, OS == .LINUX}); // app/screen_record.jai: Windows and macOS
}
global_hotkey_actions: [..] Hotkey_Action;

View File

@ -296,6 +296,9 @@ App_User_Settings :: struct {
// [export] folder, one line each: the folders Export offers for a capture's original, the first
// the default. None means the Downloads folder. Heap-allocated.
export_folders: [..] string;
// [library] folder: the library, set by hand and read at startup; "" means LIBRARY_FOLDER_DEFAULT.
// Heap-allocated.
library_folder: string;
// Of the monitor the window first opens on.
window_width_fraction: float = 0.80;
@ -328,6 +331,7 @@ settings_free :: (s: *App_User_Settings) {
free(s.ocr_model,, context.default_allocator);
for s.export_folders free(it,, context.default_allocator);
array_free(s.export_folders);
free(s.library_folder,, context.default_allocator);
free(s.video_capture,, context.default_allocator);
array_free(s.hotkey_bindings);
array_free(s.global_hotkey_bindings);
@ -387,11 +391,11 @@ app_load_user_settings_file :: () -> write_back: bool {
if hotkeys_missing.count || global_missing.count return true;
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.export_folder && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone);
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.export_folder && seen.library_folder && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone);
}
Settings_Seen :: struct {
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model, export_folder: bool;
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model, export_folder, library_folder: bool;
video_fps, video_cursor, video_capture, video_system_audio, video_microphone: bool;
}
@ -522,6 +526,10 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) ->
folder := settings_unquote(value); // an empty line says the list is empty
if folder.count array_add(*out.export_folders, copy_string(folder,, context.default_allocator));
seen.export_folder = true;
} else if section == "[library]" && key == "folder" {
free(out.library_folder,, context.default_allocator);
out.library_folder = copy_string(settings_unquote(value),, context.default_allocator);
seen.library_folder = true;
} else if section == "[ui]" && key == "theme" {
free(out.theme,, context.default_allocator);
out.theme = copy_string(settings_unquote(value),, context.default_allocator);
@ -686,6 +694,10 @@ app_save_user_settings_file :: () -> bool {
if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction;
if seen.height app_user_settings.window_height_fraction = on_disk.window_height_fraction;
if seen.event_log app_user_settings.event_log_capacity = on_disk.event_log_capacity;
if seen.library_folder {
free(app_user_settings.library_folder,, context.default_allocator);
app_user_settings.library_folder = copy_string(on_disk.library_folder,, context.default_allocator);
}
if seen.video_fps app_user_settings.video_fps = on_disk.video_fps;
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
// video_system_audio and video_microphone are the app's: the recording toolbar's buttons set them.
@ -719,9 +731,13 @@ app_save_user_settings_file :: () -> bool {
for export_folders print_to_builder(*builder, "folder: %\n", settings_quote_if_needed(it));
if !export_folders append(*builder, "folder:\n");
append(*builder, "\n");
append(*builder, "[library]\n");
append(*builder, "# The folder of .mcap files mcap shows and imports into; ~/ is the home folder. Read when mcap\n");
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);
@ -853,6 +869,10 @@ app_reload_user_settings_if_changed :: () {
if fresh.event_log_capacity != old.event_log_capacity logger_set_event_log_capacity(fresh.event_log_capacity);
if fresh.ocr_model != old.ocr_model ocr_ui_settings_changed(); // app/ocr_ui.jai
if fresh.library_folder != old.library_folder {
log("[settings] The library folder is now %; mcap shows it from its next start.", ifx fresh.library_folder.count then fresh.library_folder else LIBRARY_FOLDER_DEFAULT);
toast_show(.Success, "The new library folder is shown when mcap starts again");
}
bindings: [] Hotkey_Binding = hotkey_defaults();
if seen.hotkeys bindings = app_user_settings.hotkey_bindings;
@ -881,7 +901,8 @@ settings_watch: struct {
app_settings_watch_start :: () {
dir := app_program_data_dir();
if !Watcher.init(*settings_watch.watcher, settings_watch_callback, watch_recursively = false) {
// macOS's FSEvents always watches recursively, and the module logs an error when asked not to.
if !Watcher.init(*settings_watch.watcher, settings_watch_callback, watch_recursively = OS == .MACOS) {
log_error("[settings] Could not watch %. Changes to % apply at the next start.", dir, APP_USER_SETTINGS_FILE_NAME);
return;
}

View File

@ -0,0 +1,173 @@
# Converts Mask2Former (facebook/mask2former-swin-base-coco-panoptic) to a Core ML model for
# modules/Segment on macOS, then checks it against the ONNX model on a folder of images, segment by
# segment. Run once, on a Mac, after tools/export_mask2former.py; mcap only uses the .mlpackage it writes.
#
# python3 -m venv ~/mcap-coreml
# ~/mcap-coreml/bin/pip install torch==2.7.0 transformers coremltools==9.0 onnxruntime pillow scipy
# ~/mcap-coreml/bin/pip install --no-deps timm
# ~/mcap-coreml/bin/python tools/export_mask2former_coreml.py --check FOLDER_OF_IMAGES
#
# The model as tools/export_mask2former.py exports it (pixel_values [1, 3, 384, 384] to
# class_queries_logits [1, 100, 134] and masks_queries_logits [1, 100, 96, 96]), as an ML Program in
# float32, one image a run (a batch of 4 did not convert). Three things coremltools 9.0 cannot take
# are worked around for the conversion only, none changing what the model computes:
# - it casts a one-element tensor with int(array), which NumPy 2 refuses;
# - its meshgrid refuses the inputs Mask2Former's traced linspaces come out as;
# - Core ML takes tensors of at most 5 dimensions, and the multi-scale deformable attention makes 6
# (batch, queries, heads, levels, points, xy): it is run a level at a time instead, which gives
# the same output in PyTorch (checked below) with at most 5.
# Measured on an M5 Pro: 42 ms an image on the GPU against 166 ms for onnxruntime on the CPU, with
# the same segments (IoU 1.0) on every test image.
import argparse, glob, os, sys, time
import numpy as np
import torch
from torch import nn
from PIL import Image
import coremltools as ct
from coremltools.converters.mil import Builder as mb
from coremltools.converters.mil.frontend.torch import ops as ct_ops
from coremltools.converters.mil.frontend.torch.torch_op_registry import register_torch_op
from transformers import AutoImageProcessor, Mask2FormerForUniversalSegmentation
from transformers.utils import logging
import transformers.models.mask2former.modeling_mask2former as modeling
logging.set_verbosity_error()
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from export_mask2former import Logits
CHECKPOINT = "facebook/mask2former-swin-base-coco-panoptic"
MODELS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "modules", "Segment", "models")
ONNX = os.path.join(MODELS, "mask2former-swin-base-coco-panoptic.onnx")
OUTPUT = os.path.join(MODELS, "mask2former-swin-base-coco-panoptic.mlpackage")
SIZE = 384
_original_cast = ct_ops._cast
def _cast_one_element(context, node, dtype, dtype_name):
x = ct_ops._get_inputs(context, node, expected=1)[0]
if x.can_be_folded_to_const() and np.ndim(x.val) > 0 and np.size(x.val) == 1:
context.add(ct_ops.mb.const(val=dtype(np.asarray(x.val).item()), name=node.name), node.name)
return
_original_cast(context, node, dtype, dtype_name)
ct_ops._cast = _cast_one_element
@register_torch_op(override=True)
def meshgrid(context, node):
"""torch.meshgrid, from its inputs flattened to 1-d first."""
inputs = ct_ops._get_inputs(context, node, expected=[1, 2])
indexing = inputs[1].val if len(inputs) > 1 else "ij"
indexing = ct_ops._get_kwinputs(context, node, "indexing", default=[indexing])[0]
if hasattr(indexing, "val"):
indexing = indexing.val
tensors = [mb.reshape(x=t, shape=[-1], name=f"{node.name}_flat_{i}") for i, t in enumerate(inputs[0])]
sizes = [t.shape[0] for t in tensors]
grids = []
for i, t in enumerate(tensors):
view = [1] * len(tensors); view[i] = -1
reps = list(sizes); reps[i] = 1
grid = mb.tile(x=mb.reshape(x=t, shape=view, name=f"{node.name}_view_{i}"), reps=reps, name=f"{node.name}_grid_{i}")
if indexing == "xy":
grid = mb.transpose(x=grid, perm=[1, 0] + list(range(2, len(tensors))), name=f"{node.name}_xy_{i}")
grids.append(grid)
context.add(tuple(grids), node.name)
def deformable_attention_rank5(self, hidden_states, attention_mask=None, encoder_hidden_states=None, encoder_attention_mask=None,
position_embeddings=None, reference_points=None, spatial_shapes_list=None, level_start_index=None,
output_attentions=False):
"""Mask2FormerPixelDecoderEncoderMultiscaleDeformableAttention.forward, a level at a time."""
if position_embeddings is not None:
hidden_states = self.with_pos_embed(hidden_states, position_embeddings)
if reference_points.shape[-1] != 2:
raise ValueError("only 2-d reference points are rewritten")
B, Q, _ = hidden_states.shape
_, S, _ = encoder_hidden_states.shape
H, L, P, D = self.n_heads, self.n_levels, self.n_points, self.d_model // self.n_heads
value = self.value_proj(encoder_hidden_states)
if attention_mask is not None:
value = value.masked_fill(attention_mask[..., None], float(0))
value = value.view(B, S, H, D)
offsets = self.sampling_offsets(hidden_states).view(B, Q, H, L, P * 2)
weights = nn.functional.softmax(self.attention_weights(hidden_states).view(B, Q, H, L * P), -1)
weights = weights.transpose(1, 2).reshape(B * H, 1, Q, L * P)
values = value.split([h * w for h, w in spatial_shapes_list], dim=1)
sampled = []
for level, (h, w) in enumerate(spatial_shapes_list):
value_l = values[level].flatten(2).transpose(1, 2).reshape(B * H, D, h, w)
offset_l = offsets[:, :, :, level].reshape(B, Q, H, P, 2)
location = reference_points[:, :, None, level, None, :] + offset_l / torch.tensor([w, h], dtype=offset_l.dtype)
grid = (2 * location - 1).transpose(1, 2).flatten(0, 1)
sampled.append(nn.functional.grid_sample(value_l, grid, mode="bilinear", padding_mode="zeros", align_corners=False))
output = (torch.stack(sampled, dim=-2).flatten(-2) * weights).sum(-1).view(B, H * D, Q).transpose(1, 2).contiguous()
return self.output_proj(output), None
def segments_of(processor, class_logits, mask_logits):
holder = type("Outputs", (), {"class_queries_logits": torch.from_numpy(class_logits), "masks_queries_logits": torch.from_numpy(mask_logits)})()
return processor.post_process_panoptic_segmentation(holder, target_sizes=[(SIZE, SIZE)])[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check", metavar="FOLDER", help="images to check the Core ML model against the ONNX one on")
args = parser.parse_args()
processor = AutoImageProcessor.from_pretrained(CHECKPOINT)
model = Mask2FormerForUniversalSegmentation.from_pretrained(CHECKPOINT).eval()
example = torch.zeros(1, 3, SIZE, SIZE)
with torch.no_grad():
original = Logits(model)(torch.randn(1, 3, SIZE, SIZE, generator=torch.Generator().manual_seed(0)))
modeling.Mask2FormerPixelDecoderEncoderMultiscaleDeformableAttention.forward = deformable_attention_rank5
rewritten = Logits(model)(torch.randn(1, 3, SIZE, SIZE, generator=torch.Generator().manual_seed(0)))
difference = max((a - b).abs().max().item() for a, b in zip(original, rewritten))
print(f"The attention a level at a time, against the original in PyTorch: max difference {difference:.2e}")
if difference > 1e-4:
sys.exit("The rewritten attention does not give the same output.")
traced = torch.jit.trace(Logits(model).eval(), example)
start = time.perf_counter()
ml = ct.convert(traced, inputs=[ct.TensorType(name="pixel_values", shape=(1, 3, SIZE, SIZE))],
outputs=[ct.TensorType(name="class_queries_logits"), ct.TensorType(name="masks_queries_logits")],
convert_to="mlprogram", compute_precision=ct.precision.FLOAT32, minimum_deployment_target=ct.target.macOS14)
ml.short_description = f"{CHECKPOINT}, class and mask logits, float32, for mcap"
ml.save(OUTPUT)
print(f"Wrote {OUTPUT} in {time.perf_counter() - start:.0f} s.")
if not args.check:
return
import onnxruntime as ort
paths = sorted(p for p in glob.glob(os.path.join(args.check, "**", "*"), recursive=True)
if p.lower().endswith((".png", ".jpg", ".jpeg", ".webp")))
if not paths:
sys.exit(f"No images in {args.check}.")
session = ort.InferenceSession(ONNX, providers=["CPUExecutionProvider"])
coreml = ct.models.MLModel(OUTPUT, compute_units=ct.ComputeUnit.CPU_AND_GPU)
total, worst_iou, unmatched, times = 0, 1.0, 0, []
for path in paths:
pixels = processor(images=Image.open(path).convert("RGB"), return_tensors="np")["pixel_values"].astype(np.float32)
ref = session.run(["class_queries_logits", "masks_queries_logits"], {"pixel_values": pixels})
start = time.perf_counter()
got = coreml.predict({"pixel_values": pixels})
times.append(time.perf_counter() - start)
a = segments_of(processor, ref[0], ref[1])
b = segments_of(processor, got["class_queries_logits"].astype(np.float32), got["masks_queries_logits"].astype(np.float32))
for sa in a["segments_info"]:
total += 1
best = 0.0
for sb in b["segments_info"]:
if sb["label_id"] == sa["label_id"]:
ma, mbk = (a["segmentation"] == sa["id"]).numpy(), (b["segmentation"] == sb["id"]).numpy()
best = max(best, (ma & mbk).sum() / max((ma | mbk).sum(), 1))
if best == 0:
unmatched += 1
else:
worst_iou = min(worst_iou, best)
print(f"{len(paths)} images, {total} segments: worst IoU against the ONNX model {worst_iou:.4f}, unmatched {unmatched}; "
f"{np.median(times[1:] or times) * 1000:.0f} ms an image on the GPU")
if unmatched or worst_iou < 0.98:
sys.exit("The Core ML model segments differently from the ONNX one.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,207 @@
# Converts a PP-OCRv5 detector and recognizer to Core ML models for modules/OCR on macOS, then checks
# each against the ONNX model on the CPU. Run once, on a Mac, after modules/OCR/fetch_models.jai;
# mcap only uses the .mlpackage files it writes (into modules/OCR/models, beside the ONNX ones).
#
# python3 -m venv ~/mcap-coreml
# ~/mcap-coreml/bin/pip install torch==2.7.0 coremltools==9.0 onnx onnxruntime pillow
# ~/mcap-coreml/bin/pip install --no-deps onnx2torch torchvision==0.22.0
# ~/mcap-coreml/bin/python tools/export_ppocr_coreml.py --model mobile
# ~/mcap-coreml/bin/python tools/export_ppocr_coreml.py --model server
#
# There is no PyTorch source for PP-OCR (it is PaddlePaddle's), and coremltools no longer reads ONNX, so
# each model goes ONNX -> PyTorch (onnx2torch) -> Core ML, as an ML Program in float32: float16 moves
# the detector's text pixels and changes recognized text, and the Neural Engine gets it wrong.
#
# The shapes. Core ML on the GPU cannot take a flexible height and width for the detector (its
# depthwise convolutions fail to compile: "invalid axis" in mps.expand_dims), so both models take a
# list of sizes (at most 128 a model), which mcap pads its inputs up to; models.config's coreml_ keys
# say the same steps, and must be kept in step with these:
# detector: 1 x 3 x H x W, H a multiple of 128 up to 1536 (det_tile_height), W of 256 up to 2560 (det_max_side)
# recognizer: 16 x 3 x 48 x W, W a multiple of 64 up to 4096 (REC_MAX_WIDTH); fewer lines are padded to 16
# The server models pad some 2 x 2 convolutions and max pools with auto_pad SAME_UPPER, which onnx2torch
# does not take; at stride 1 it is a fixed pixel after, written out as pads first.
# Reshapes that compute their target shape from tensor sizes (the recognizers', the server detector's)
# are frozen by tracing at the traced size; they are rewritten first as constant shapes of 0 (copy the
# dimension) and -1 (infer it), worked out from the shapes onnxruntime gives at several sizes, and
# checked to give the same output.
#
# Measured on an M5 Pro against onnxruntime on the CPU, each at the same (padded) size: the detector
# about 5 times as fast, the recognizer about 4 times for 16 lines, with the same text pixels and text.
# Whole images through mcap (ocr-bench, 8 text pages): mobile 323 ms an image against 536, server 940
# against 2030.
import argparse, itertools, os, sys, time
import numpy as np
import onnx, onnxruntime as ort
from onnx import helper, numpy_helper
import torch, onnx2torch
import coremltools as ct
HERE = os.path.dirname(os.path.abspath(__file__))
MODELS = os.path.join(HERE, "..", "modules", "OCR", "models")
DET_HEIGHTS = list(range(128, 1536 + 1, 128))
DET_WIDTHS = list(range(256, 2560 + 1, 256))
REC_BATCH = 16
REC_HEIGHT = 48
REC_WIDTHS = list(range(64, 4096 + 1, 64))
# coremltools 9.0 casts a folded one-element tensor with int(array), which NumPy 2 refuses ("only
# 0-dimensional arrays can be converted"); the same cast, with the element taken out first.
from coremltools.converters.mil.frontend.torch import ops as ct_ops
_original_cast = ct_ops._cast
def _cast_one_element(context, node, dtype, dtype_name):
x = ct_ops._get_inputs(context, node, expected=1)[0]
if x.can_be_folded_to_const() and np.ndim(x.val) > 0 and np.size(x.val) == 1:
context.add(ct_ops.mb.const(val=dtype(np.asarray(x.val).item()), name=node.name), node.name)
return
_original_cast(context, node, dtype, dtype_name)
ct_ops._cast = _cast_one_element
def constant_reshapes(model, input_shapes):
"""The model with each computed Reshape target made a constant of 0s and -1s."""
graph = model.graph
initializers = {i.name for i in graph.initializer}
producers = {o: n for n in graph.node for o in n.output}
computed = [n for n in graph.node if n.op_type == "Reshape" and n.input[1] not in initializers
and not (n.input[1] in producers and producers[n.input[1]].op_type == "Constant")]
if not computed:
return model
probe = onnx.ModelProto(); probe.CopyFrom(model)
names = []
for n in computed:
for t in (n.input[0], n.output[0]):
if t not in names:
names.append(t)
probe.graph.output.append(helper.make_tensor_value_info(t, onnx.TensorProto.FLOAT, None))
session = ort.InferenceSession(probe.SerializeToString(), providers=["CPUExecutionProvider"])
name = session.get_inputs()[0].name
shapes = []
for shape in input_shapes:
outs = session.run(names, {name: np.zeros(shape, np.float32)})
shapes.append({t: o.shape for t, o in zip(names, outs)})
for n in computed:
ins = [s[n.input[0]] for s in shapes]
outs = [s[n.output[0]] for s in shapes]
target = []
for d in range(len(outs[0])):
sizes = {o[d] for o in outs}
if len(sizes) == 1:
target.append(sizes.pop())
elif all(len(i) > d and i[d] == o[d] for i, o in zip(ins, outs)):
target.append(0)
else:
target.append(-1)
if target.count(-1) > 1:
sys.exit(f"{n.name}: more than one dimension to infer ({target})")
constant = n.name + "_constant_shape"
graph.initializer.append(numpy_helper.from_array(np.array(target, dtype=np.int64), constant))
n.input[1] = constant
print(f" {n.name}: target shape {target}")
return model
def explicit_padding(model):
"""The model with auto_pad SAME_UPPER and SAME_LOWER, which onnx2torch does not take, written out as
pads. Only stride 1 is rewritten: there the padding does not depend on the input's size (the server
models' 2 x 2 convolutions and max pools: one pixel after, none before)."""
for n in model.graph.node:
attrs = {a.name: a for a in n.attribute}
mode = attrs["auto_pad"].s.decode() if "auto_pad" in attrs else "NOTSET"
if mode not in ("SAME_UPPER", "SAME_LOWER"):
continue
kernel = list(attrs["kernel_shape"].ints) if "kernel_shape" in attrs else None
if kernel is None:
sys.exit(f"{n.name}: auto_pad {mode} with no kernel_shape")
strides = list(attrs["strides"].ints) if "strides" in attrs else [1] * len(kernel)
dilations = list(attrs["dilations"].ints) if "dilations" in attrs else [1] * len(kernel)
if any(s != 1 for s in strides):
sys.exit(f"{n.name}: auto_pad {mode} with strides {strides}; only stride 1 is rewritten")
totals = [(k - 1) * d for k, d in zip(kernel, dilations)]
small = [t // 2 for t in totals]
begins = small if mode == "SAME_UPPER" else [t - b for t, b in zip(totals, small)]
ends = [t - b for t, b in zip(totals, begins)]
n.attribute.remove(attrs["auto_pad"])
if "pads" in attrs:
n.attribute.remove(attrs["pads"])
n.attribute.append(helper.make_attribute("pads", begins + ends))
print(f" {n.name} ({n.op_type}): auto_pad {mode} -> pads {begins + ends}")
return model
def convert(onnx_model, example, shape, path):
module = onnx2torch.convert(onnx_model).eval()
with torch.no_grad():
traced = torch.jit.trace(module, torch.from_numpy(example))
ml = ct.convert(traced, inputs=[ct.TensorType(name="x", shape=shape)], outputs=[ct.TensorType(name="y")],
convert_to="mlprogram", compute_precision=ct.precision.FLOAT32,
minimum_deployment_target=ct.target.macOS14)
ml.save(path)
return ct.models.MLModel(path, compute_units=ct.ComputeUnit.CPU_AND_GPU)
def ctc(logits):
texts = []
for row in logits.argmax(-1):
seq, last = [], -1
for i in row:
if i != last and i != 0:
seq.append(int(i))
last = i
texts.append(seq)
return texts
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", choices=["mobile", "server"], default="mobile")
args = parser.parse_args()
rng = np.random.default_rng(0)
# Detector.
stem = f"ch_PP-OCRv5_det_{args.model}"
onnx_path = os.path.join(MODELS, stem + ".onnx")
out_path = os.path.join(MODELS, stem + ".mlpackage")
shapes = [(1, 3, h, w) for h in DET_HEIGHTS for w in DET_WIDTHS]
start = time.perf_counter()
model = constant_reshapes(explicit_padding(onnx.load(onnx_path)), [(1, 3, h, w) for h in (256, 512) for w in (512, 768)])
det = convert(model, np.zeros((1, 3, 768, 1280), np.float32),
ct.EnumeratedShapes(shapes=shapes, default=(1, 3, 768, 1280)), out_path)
print(f"Wrote {out_path} ({len(shapes)} sizes) in {time.perf_counter() - start:.1f} s.")
reference = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
worst = 0.0
for (h, w) in [(768, 1280), (1536, 2560), (128, 256)]:
x = rng.standard_normal((1, 3, h, w)).astype(np.float32)
a = reference.run(None, {"x": x})[0]
b = det.predict({"x": x})["y"]
flips = float(np.mean((a > 0.3) != (b > 0.3)))
worst = max(worst, flips)
print(f" {h} x {w}: max difference {np.abs(a - b).max():.2e}, pixels across 0.3: {flips * 100:.4f}%")
if worst > 0.0001:
sys.exit("The Core ML detector is too far from the ONNX one.")
# Recognizer.
stem = f"ch_PP-OCRv5_rec_{args.model}"
onnx_path = os.path.join(MODELS, stem + ".onnx")
out_path = os.path.join(MODELS, stem + ".mlpackage")
print("Recognizer: making its reshapes constant")
model = constant_reshapes(explicit_padding(onnx.load(onnx_path)), [(n, 3, REC_HEIGHT, w) for n in (1, 2) for w in (320, 640)])
shapes = [(REC_BATCH, 3, REC_HEIGHT, w) for w in REC_WIDTHS]
start = time.perf_counter()
rec = convert(model, np.zeros((REC_BATCH, 3, REC_HEIGHT, 640), np.float32),
ct.EnumeratedShapes(shapes=shapes, default=(REC_BATCH, 3, REC_HEIGHT, 640)), out_path)
print(f"Wrote {out_path} ({len(shapes)} widths) in {time.perf_counter() - start:.1f} s.")
reference = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
for w in (64, 640, 4096):
x = rng.standard_normal((REC_BATCH, 3, REC_HEIGHT, w)).astype(np.float32) * 0.5
a = reference.run(None, {"x": x})[0]
b = rec.predict({"x": x})["y"]
same = np.mean([p == q for p, q in zip(ctc(a), ctc(b))])
print(f" width {w}: max difference {np.abs(a - b).max():.2e}, decoded the same {same * 100:.0f}%")
if same < 1:
sys.exit("The Core ML recognizer reads differently from the ONNX one.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,115 @@
# Converts SigLIP 2's vision encoder to a Core ML model for modules/Labels on macOS, then checks it
# against the ONNX model mcap runs elsewhere, on a folder of images. Run once, on a Mac; mcap only uses
# the .mlpackage it writes (into modules/Labels/models, where the build copies it from).
#
# python3 -m venv ~/mcap-coreml
# ~/mcap-coreml/bin/pip install torch==2.7.0 transformers coremltools==9.0 onnxruntime pillow sentencepiece protobuf
# ~/mcap-coreml/bin/python tools/export_siglip_coreml.py --check FOLDER_OF_IMAGES
#
# torch 2.7.0 is the newest coremltools 9.0 was tested with. The model is the vision tower of
# google/siglip2-base-patch16-384, from pixel_values [1, 3, 384, 384] (RGB resized to 384 x 384,
# scaled to -1..1) to pooler_output [1, 768], as the ONNX one; an ML Program in float16, for the GPU.
# Measured on an M5 Pro: 6.7 ms an image on the GPU, against 67 ms for onnxruntime on the CPU, with
# vectors within cosine 0.99994 of the CPU's. The Neural Engine gets float16 badly wrong for this
# model (cosine 0.72), so mcap runs it on the CPU and GPU only.
import argparse, glob, os, sys, time
import numpy as np
import torch
from PIL import Image
import coremltools as ct
from transformers import AutoModel
from transformers.utils import logging
logging.set_verbosity_error()
CHECKPOINT = "google/siglip2-base-patch16-384"
SIZE = 384
HERE = os.path.dirname(os.path.abspath(__file__))
MODELS = os.path.join(HERE, "..", "modules", "Labels", "models")
OUTPUT = os.path.join(MODELS, "siglip2-base-patch16-384-vision.mlpackage")
ONNX = os.path.join(MODELS, "siglip2-base-patch16-384-vision.onnx")
# coremltools 9.0 casts a folded one-element tensor with int(array), which NumPy 2 refuses ("only
# 0-dimensional arrays can be converted"); the same cast, with the element taken out first.
from coremltools.converters.mil.frontend.torch import ops as ct_ops
_original_cast = ct_ops._cast
def _cast_one_element(context, node, dtype, dtype_name):
x = ct_ops._get_inputs(context, node, expected=1)[0]
if x.can_be_folded_to_const() and np.ndim(x.val) > 0 and np.size(x.val) == 1:
context.add(ct_ops.mb.const(val=dtype(np.asarray(x.val).item()), name=node.name), node.name)
return
_original_cast(context, node, dtype, dtype_name)
ct_ops._cast = _cast_one_element
class Pooled(torch.nn.Module):
"""The vision tower, from pixel values to the pooled vector, as the ONNX graph has it."""
def __init__(self, model):
super().__init__()
self.model = model
def forward(self, pixel_values):
return self.model(pixel_values=pixel_values).pooler_output
def load_images(folder, limit):
paths = sorted(p for p in glob.glob(os.path.join(folder, "**", "*"), recursive=True)
if p.lower().endswith((".png", ".jpg", ".jpeg", ".webp")))[:limit]
images = []
for p in paths:
image = Image.open(p).convert("RGB").resize((SIZE, SIZE), Image.BICUBIC)
a = np.asarray(image, dtype=np.float32) / 255.0
images.append(((a - 0.5) / 0.5).transpose(2, 0, 1))
return np.stack(images).astype(np.float32)
def worst_cosine(a, b):
a = a / np.linalg.norm(a, axis=-1, keepdims=True)
b = b / np.linalg.norm(b, axis=-1, keepdims=True)
return float(np.min(np.sum(a * b, axis=-1)))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check", metavar="FOLDER", help="images to check the Core ML model against the ONNX one on")
parser.add_argument("--limit", type=int, default=32)
args = parser.parse_args()
model = Pooled(AutoModel.from_pretrained(CHECKPOINT, torch_dtype=torch.float32).vision_model.eval()).eval()
example = torch.zeros(1, 3, SIZE, SIZE)
with torch.no_grad():
traced = torch.jit.trace(model, example)
start = time.perf_counter()
ml = ct.convert(traced, inputs=[ct.TensorType(name="pixel_values", shape=(1, 3, SIZE, SIZE))],
outputs=[ct.TensorType(name="pooler_output")],
convert_to="mlprogram", compute_precision=ct.precision.FLOAT16,
minimum_deployment_target=ct.target.macOS14)
ml.short_description = f"{CHECKPOINT} vision encoder (pooler_output), float16, for mcap"
ml.save(OUTPUT)
print(f"Wrote {OUTPUT} in {time.perf_counter() - start:.1f} s.")
if not args.check:
return
import onnxruntime as ort
images = load_images(args.check, args.limit)
if not len(images):
sys.exit(f"No images in {args.check}.")
session = ort.InferenceSession(ONNX, providers=["CPUExecutionProvider"])
name = session.get_inputs()[0].name
reference = np.concatenate([session.run(["pooler_output"], {name: images[i:i + 1]})[0] for i in range(len(images))])
coreml = ct.models.MLModel(OUTPUT, compute_units=ct.ComputeUnit.CPU_AND_GPU)
coreml.predict({"pixel_values": images[:1]})
start = time.perf_counter()
got = np.concatenate([coreml.predict({"pixel_values": images[i:i + 1]})["pooler_output"] for i in range(len(images))])
per = (time.perf_counter() - start) / len(images)
cosine = worst_cosine(got.astype(np.float32), reference)
print(f"{len(images)} images: {per * 1000:.1f} ms an image on the GPU, worst cosine to the ONNX model on the CPU {cosine:.5f}.")
if cosine < 0.999:
sys.exit("The Core ML model is too far from the ONNX one.")
if __name__ == "__main__":
main()

50
tools/gpu_bench/README.md Normal file
View File

@ -0,0 +1,50 @@
# gpu-bench (the `gpu-bench` branch, not for master)
Times every model mcap runs on the GPU over a fixed corpus, so machines can be compared like for
like:
| Task | What runs |
| --- | --- |
| `ocr/ppocr-v5-mobile`, `ocr/ppocr-v5-server` | detection, orientation and recognition, 8 images per call, as a scan does |
| `labels/siglip2-base-patch16-384` | the label model's image encoder |
| `segment/mask2former-swin-base-coco-panoptic` | panoptic segmentation |
| `labels-text/...` (with `-cpu`) | the text encoder, one phrase at a time as a `like:` search runs it (always on the CPU) |
## Keeping it consistent
- **The corpus** (`corpus/`, 30 images: 16 COCO val2017 photos, 2 of them scaled to 12 MP, and 12
synthetic screenshots from 720p to 4K made by `make_corpus.py`) is in git. The build copies it to
`bin/gpu-bench-corpus`. Before anything runs, every file is checked against its SHA-256 in
`corpus.txt`, and a run stops if one differs. The corpus fingerprint, a hash of those name and hash
pairs, goes in the results. `.gitattributes` marks the images binary, so git never changes them.
- **The models:** each task's fingerprint covers the hash of each of its files and its
`models.config` entry (comments and spacing left out). It also says whether every file is the one
the fetch scripts pin. The segmentation `.onnx` is exported on each machine, so it has no pin:
copy it from one machine to the others.
- **The machine:** each result records the GPU, its memory, enforced power limit and clocks, the
driver, the CUDA runtime and cuDNN actually loaded (with their paths), onnxruntime, the CPU, RAM,
and AC or battery.
- **The run:** each model gets one untimed run on the first image, one untimed warm-up pass over
everything, then N timed passes (default 3). The median is reported, with the best and worst
passes. While the timed passes run, NVML samples the GPU every 100 ms: peak memory over the
device's use before the model loaded, utilisation, power, SM clock and temperature.
- **The output:** what the models found (OCR lines and characters, segments, best phrases) and a
digest of it. Different GPUs can round differently, so a different digest is a hint, not an error.
## Running
`jai first.jai - release` builds `bin/gpu-bench.exe` with mcap. Then either:
- **In mcap:** click **GPU Bench** above the grid, choose what to run, and click **Run the benchmark**.
It runs gpu-bench in a separate process with no console and shows its progress. Close mcap and use
the terminal for the cleanest numbers, as mcap's own models stay loaded while it runs.
- **In a terminal:** `bin\gpu-bench.exe [-only ocr,labels,segment] [-models a,b] [-repeats N] [-warmup N] [-cpu | -cpu-only] [-note TEXT]`
Results go to `bin/gpu-bench-results/<machine>_<date>.txt` (plain text, `results.jai`). To compare
machines, copy their files into that folder: the window puts the results side by side. Each result
shows its speed against the baseline (the oldest file, or the one you pick). Its corpus row turns red
when the images differ, and a `*` marks a model whose files or settings differ. Hover a time for its
details.
The server OCR model can take a lot of GPU and system memory and a long time (see the main README's
known issue). Leave it out with `-models ppocr-v5-mobile,siglip2-base-patch16-384,mask2former-swin-base-coco-panoptic`.

View File

@ -0,0 +1,35 @@
# The GPU benchmark's corpus: every image in corpus/, with its SHA-256. gpu-bench checks each file
# against this before it runs, and its corpus fingerprint is the SHA-256 of these name and hash
# pairs, so two results are comparable only when the fingerprints match. coco-*: COCO val2017
# (images.cocodataset.org/val2017, CC licences); screen-* and photo-*: make_corpus.py.
ffe0f0cec3b2e27aab1967229cdf0a0d7751dcdd5800322f0b8ac0dffb3b8a8d coco-000000000139.jpg
f3a2974ce3686332609124c70e3e6a2e3aca43fccf1cd1bd7c5c03820977f57d coco-000000000285.jpg
a4cd7f45ac1ce27eaafb254b23af7c0b18a064be08870ceaaf03b2147f2ce550 coco-000000000632.jpg
5c0e559c75d3969c8e3e297b61f61063f78045c9d4802b526ba616361f3823fd coco-000000000724.jpg
1dd31e9059c491992be2f562624eb4093e17aee08b4f7baf5ff9ea24543b0a33 coco-000000000776.jpg
83981537a7baeafbeb9c8cb67b3484dc26433f574b3685d021fa537e277e4726 coco-000000000785.jpg
d5b79e7fa716f85ca86f46e5f518da9b6c5e26414925624d76e6476861aec495 coco-000000000802.jpg
c2aa138ee3a59b057a7ba6fc5a6a18e62af531aa7dab78a7bfd33c1cd7e55eb6 coco-000000000872.jpg
1c67b783f78b18ddb5dd20aaba4d4aacaf98e5277051688a96da096afa9cdf1f coco-000000000885.jpg
24bb77a31928404e45a0454b06f6a0bd54a8db103590c9d7917288a1e0269f05 coco-000000001000.jpg
8e85a2e71ee512fe748a3a3be9bb40b8591e8c501c0f3896efcb5be5999ec236 coco-000000001268.jpg
34a98da7c11bc8811e6eec445145bb16f3d5f4338945ba116edcc1fe554d181b coco-000000001296.jpg
1cccef217a1e5eec2acc74d7c08a105da3ef6fdb8514e221cdea9b0b373b60ea coco-000000001353.jpg
a044804a0b0186a1267acae34975932f14ede96c4a2b78fcee74e6c2fbd8414d coco-000000001490.jpg
974f2af6d81f65627a680ea9f42bdee1067a0ddcdb9f8e7a945bacd21d4dc2ce coco-000000001532.jpg
77131e2f74aa7e374f951a69157cf7f51e00201f81bacfa8a9ab7309b245553f coco-000000001584.jpg
3c34557e033b8022567bf41d1b9f6da6aa3547305b77a922f1f351c197040111 photo-12mp-livingroom.jpg
769a74c85bb0b03322864cffc252738a7858c6947b4a45bf643708a766ea0f73 photo-12mp-tennis.jpg
c989015b2ecf47630acfa1c5afe828e406accc5f0345814e4d5a5cf53cdb4bc7 screen-chat-phone.png
41e445c52f991a659b65e435a5e08173817a53d150da3f7b5380b0a697e50ecc screen-dialog-1080p.png
76224a64b1460169bb0684c3a8eefc977b1cbbd556a170fe7559928b246012ad screen-document-1080p.png
96f00112b373d990162a13e85d524f5acf6a3b4bb671883742127879f3b39246 screen-document-4k.png
2112042a1f22349805af2136a2b9aee346d47387d3d31ca698d0d570e3215c00 screen-document-strip.png
973ba96240163e51c9e92e0bf7d927ca07dab78e2236b1ecf4e902ef4e170187 screen-editor-dark-1080p.png
51f62f081a73d05bbb2dac313ca5e716628961dcf2760c073c6f365090959258 screen-editor-dark-1440p.png
9d763d163ada6d85e3e4d0415295ccffac1d9b5eaa60b6a6209c7af6c6b06d43 screen-editor-light-1080p.png
35bdf92f31eee19599f2f20a17c8cedb536289e3605b9f91f32c1e7dc0a5169a screen-spreadsheet-1080p.png
84b5074a25e1ea13ca17be3476dff40609fe11c0f70ecdcbf6920c59c99272b1 screen-spreadsheet-1440p.png
54f30b7745bdba5890c25c9ae90e417263fa6126ffe41925f98e981920986448 screen-terminal-1080p.png
0f64a278adb72e482f9526af9037ce4fa8ae6826a1fa9b94c8d0d4102bb6e22e screen-terminal-720p.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Some files were not shown because too many files have changed in this diff Show More