Compare commits
10 Commits
8c8b2c6e38
...
74b273f0e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b273f0e0 | |||
| 63a0cdb64e | |||
| 91f6cf768f | |||
| a1ab9f3a96 | |||
| 30cd00b6f2 | |||
| 2d0fb855b1 | |||
| 3e353c77d8 | |||
| a66928b223 | |||
| 5571c21d0b | |||
| 12d0f30fc4 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -27,5 +27,10 @@ Thumbs.db
|
|||||||
/modules/Labels/models/
|
/modules/Labels/models/
|
||||||
# Segmentation: exported by tools/export_mask2former.py, not committed
|
# Segmentation: exported by tools/export_mask2former.py, not committed
|
||||||
/modules/Segment/models/
|
/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
|
# Releases: made by release.jai
|
||||||
/release/
|
/release/
|
||||||
|
|||||||
78
README.md
78
README.md
@ -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,
|
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
|
AVIF, WebP, GIF, BMP, TIFF, camera RAW), videos (MP4, MOV, MKV, WebM, AVI, WMV, MPEG) and Snagit
|
||||||
`.snagx` captures, or from `snagit-converter`
|
`.snagx` captures, or from `snagit-converter`
|
||||||
(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. On
|
(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. **PrintScreen**
|
||||||
Windows, **PrintScreen** captures a region of the screen straight into the library, as Snagit does;
|
on Windows, **Cmd+Shift+2** on macOS, captures a region of the screen straight into the library, as
|
||||||
see [Screen capture](#screen-capture).
|
Snagit does; see [Screen capture](#screen-capture).
|
||||||
|
|
||||||
## Getting started
|
## 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
|
place (`C:\Program Files\NVIDIA\CUDNN`) is found without changing PATH. The OCR window says
|
||||||
what, if anything, is missing.
|
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
|
3. **Build and run:** `jai first.jai - release`, then `bin\mcap.exe`. The build copies onnxruntime
|
||||||
and the models beside the program.
|
and the models beside the program.
|
||||||
|
|
||||||
4. **The library** is `D:\McapIndex\mcap`, a folder for each place imported from
|
4. **The library** is `D:\McapIndex\mcap` on Windows and `~/Pictures/mcap` on macOS and Linux, a
|
||||||
(`SnagitLibraryPersonal`, `SnagitLibraryWork`, ...); `-library FOLDER` shows another folder.
|
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).
|
**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**,
|
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**
|
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.
|
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
|
- **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
|
first; "Don't ask again" keeps the choice until the app restarts. Only the `.mcap` goes: the
|
||||||
`.snagx` it was converted from is untouched.
|
`.snagx` it was converted from is untouched.
|
||||||
- **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then
|
- **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
|
**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
|
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
|
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config` (**Cmd** for Ctrl on macOS).
|
||||||
only so far.
|
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]`).
|
- **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
|
- **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.
|
or leave it on their own, folders added, renamed or deleted too: the library is watched.
|
||||||
@ -170,8 +182,8 @@ of them are segmented, it also shows **What is in the images**: how many images
|
|||||||
|
|
||||||
## Screen capture
|
## Screen capture
|
||||||
|
|
||||||
On Windows, **PrintScreen** captures any part of the screen, from any app, even while mcap is hidden
|
**PrintScreen** on Windows, **Cmd+Shift+2** on macOS, captures any part of the screen, from any app,
|
||||||
in the tray:
|
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
|
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.
|
darkened over them, so menus, tooltips and video hold still while you choose.
|
||||||
@ -194,9 +206,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
|
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`.
|
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
|
### 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.
|
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:
|
2. **Drag a region**, or click (or press Enter) for the whole screen under the cursor:
|
||||||
@ -214,14 +238,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
|
and **Mic** (each on or off), **Pause** (the time paused is left out of the video), **Stop**, and **Discard**, which asks
|
||||||
first.
|
first.
|
||||||
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
|
- **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.
|
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.
|
OCR and labels read a frame from it.
|
||||||
|
|
||||||
- **The screen** is taken with DXGI Desktop Duplication, or with Windows.Graphics.Capture where that
|
- **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
|
- **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:**
|
- **Losing nothing:**
|
||||||
- A recording that stops by itself keeps what it has, and so does quitting while recording.
|
- 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
|
- When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the
|
||||||
@ -230,11 +255,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.
|
keeps all but its last two seconds or so.
|
||||||
- **Settings:** `[video]` in the settings file:
|
- **Settings:** `[video]` in the settings file:
|
||||||
- `fps` (30) and `cursor` (true).
|
- `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_audio` (true): what the computer plays, from the default output device. The toolbar's
|
||||||
System sound button sets it.
|
System sound button sets it.
|
||||||
- `microphone` (false): the default input device. The toolbar's Mic 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.
|
- 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
|
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
|
||||||
the screens, which would show in the recording.
|
the screens, which would show in the recording.
|
||||||
- **The key** is `record_region` in `[global_hotkeys]`.
|
- **The key** is `record_region` in `[global_hotkeys]`.
|
||||||
@ -243,7 +270,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:
|
**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`).
|
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
|
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
|
`Phone`. It starts as the name of the folder imported from, or the library folder that folder went
|
||||||
@ -259,13 +286,14 @@ What becomes of each file:
|
|||||||
way up (by its EXIF orientation), and where it came from: its file name (searched by `title:`), the
|
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
|
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
|
(`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
|
`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 Windows' own codecs: the HEIF, AV1, WebP and Raw Image Extensions from the Microsoft
|
are read by the system's own codecs: on Windows the HEIF, AV1, WebP and Raw Image Extensions from
|
||||||
Store.
|
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
|
- **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.
|
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
|
- **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
|
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.
|
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
|
The date is the phone's (Apple's creation date), else one in its name, else the video's own
|
||||||
@ -394,7 +422,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). |
|
| `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. |
|
| `-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. |
|
| `-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. |
|
| `-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`. |
|
| `-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 +480,8 @@ converted copy.
|
|||||||
library index up to date (hashing the image in any `.mcap` it has not seen), then imports on
|
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.
|
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,
|
- `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
|
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 -
|
(`video.jai`), and how many frames a GIF has. `bin\image-probe FILE...` (`jai first.jai -
|
||||||
image_probe`) shows what it makes of files.
|
image_probe`) shows what it makes of files.
|
||||||
@ -517,10 +546,11 @@ Binaries are written to `bin/`, with `assets/` copied beside them.
|
|||||||
| Platform | Output | Notes |
|
| Platform | Output | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Windows | `bin/mcap-debug.exe`, `bin/mcap.exe` | The release build gets the icon and a manifest, and no console. |
|
| 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`). |
|
| 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.
|
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),
|
Run any build with `run_tests` to run the test suites (debug builds include them),
|
||||||
|
|||||||
@ -5,5 +5,5 @@
|
|||||||
# Changes apply as soon as the file is saved.
|
# Changes apply as soon as the file is saved.
|
||||||
|
|
||||||
none bring_to_foreground
|
none bring_to_foreground
|
||||||
none capture_region
|
Cmd-Shift-2 capture_region
|
||||||
none record_region
|
Opt-Cmd-Shift-2 record_region
|
||||||
|
|||||||
@ -8,8 +8,10 @@
|
|||||||
#run,stallable {
|
#run,stallable {
|
||||||
set_build_options_dc(.{do_output = false});
|
set_build_options_dc(.{do_output = false});
|
||||||
args := get_build_options().compile_time_command_line;
|
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;
|
target := Operating_System_Tag.LINUX;
|
||||||
cpu := CPU_Tag.X64;
|
cpu := CPU;
|
||||||
if array_find(args, "macos") { target = .MACOS; cpu = .ARM64; }
|
if array_find(args, "macos") { target = .MACOS; cpu = .ARM64; }
|
||||||
|
|
||||||
options := get_build_options();
|
options := get_build_options();
|
||||||
@ -38,6 +40,9 @@ SHIM, jai_modules));
|
|||||||
options.import_path = paths;
|
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");
|
w := compiler_create_workspace("check");
|
||||||
set_build_options(options, w);
|
set_build_options(options, w);
|
||||||
compiler_begin_intercept(w);
|
compiler_begin_intercept(w);
|
||||||
@ -47,7 +52,7 @@ APP_NAME :: "mcap";
|
|||||||
APP_FOLDER_NAME :: "mcap";
|
APP_FOLDER_NAME :: "mcap";
|
||||||
GIT_BRANCH :: "check";
|
GIT_BRANCH :: "check";
|
||||||
GIT_HASH :: "-";
|
GIT_HASH :: "-";
|
||||||
VERSION :: "0.1.24";
|
VERSION :: "0.1.34";
|
||||||
BUILD_DATE :: "-";
|
BUILD_DATE :: "-";
|
||||||
DEBUG :: true;
|
DEBUG :: true;
|
||||||
COMPILE_TESTS :: true;
|
COMPILE_TESTS :: true;
|
||||||
@ -65,6 +70,31 @@ S, w);
|
|||||||
compiler_end_intercept(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 "Basic";
|
||||||
#import "Compiler";
|
#import "Compiler";
|
||||||
#import "File";
|
#import "File";
|
||||||
|
|||||||
164
first.jai
164
first.jai
@ -8,7 +8,7 @@ APP_NAME :: "mcap";
|
|||||||
APP_FOLDER_NAME :: "mcap";
|
APP_FOLDER_NAME :: "mcap";
|
||||||
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
|
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
|
||||||
|
|
||||||
VERSION :: "0.1.24";
|
VERSION :: "0.1.34";
|
||||||
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
|
// 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.
|
// builds never write over the ones you run by hand.
|
||||||
build_directory := "bin";
|
build_directory := "bin";
|
||||||
@ -54,6 +54,7 @@ build_project :: () {
|
|||||||
|
|
||||||
set_working_directory(#filepath); // so that we load fonts correctly.
|
set_working_directory(#filepath); // so that we load fonts correctly.
|
||||||
make_directory_if_it_does_not_exist(build_directory);
|
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
|
// Workspaces compile concurrently once source is added, so every build is
|
||||||
// started before any is waited on.
|
// started before any is waited on.
|
||||||
@ -125,6 +126,7 @@ start_main_build :: (optimized: bool) -> Pending_Build {
|
|||||||
#if CPU == .X64 options.backend = .X64; // the fastest to compile; LLVM elsewhere
|
#if CPU == .X64 options.backend = .X64; // the fastest to compile; LLVM elsewhere
|
||||||
options.output_executable_name = tprint("%-debug", output_executable_name_temp);
|
options.output_executable_name = tprint("%-debug", output_executable_name_temp);
|
||||||
}
|
}
|
||||||
|
#if OS == .MACOS options.minimum_os_version = MACOS_MINIMUM_VERSION;
|
||||||
|
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS {
|
||||||
file_delete(tprint("%/%.pdb", build_directory, options.output_executable_name));
|
file_delete(tprint("%/%.pdb", build_directory, options.output_executable_name));
|
||||||
@ -166,6 +168,7 @@ start_tool_build :: (name: string, file: string, optimized: bool) -> Pending_Bui
|
|||||||
options.output_executable_name = tprint("%-debug", name);
|
options.output_executable_name = tprint("%-debug", name);
|
||||||
}
|
}
|
||||||
options.output_path = build_directory;
|
options.output_path = build_directory;
|
||||||
|
#if OS == .MACOS options.minimum_os_version = MACOS_MINIMUM_VERSION;
|
||||||
|
|
||||||
w := compiler_create_workspace(options.output_executable_name);
|
w := compiler_create_workspace(options.output_executable_name);
|
||||||
set_build_options(options, w);
|
set_build_options(options, w);
|
||||||
@ -194,52 +197,163 @@ finish_main_build :: (build: Pending_Build) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else #if OS == .MACOS {
|
} 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);
|
log("Built %/%", build_directory, build.executable_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if OS == .MACOS {
|
#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"];
|
MACOS_BUNDLE_DYLIBS :: string.["libSDL3.0.dylib"];
|
||||||
|
|
||||||
// The .app holds only the executable, the dylibs it loads from beside itself, the icon and
|
// mcap.app for the release build, mcap-debug.app for the debug one, with an identifier of its own
|
||||||
// Info.plist. The app finds data/, logs/ and images/ in the folder holding the bundle; see
|
// so that its permissions are granted apart. Each holds the executable, SDL3 and FFmpeg beside it,
|
||||||
// app_install_directory.
|
// onnxruntime in Contents/Frameworks, and the icon, Info.plist and models in Contents/Resources.
|
||||||
create_macos_app_bundle :: (executable_name: string) {
|
// The app finds data/, logs/ and images/ in the folder holding the bundle (see
|
||||||
app := tprint("%/%", build_directory, MACOS_APP_BUNDLE);
|
// app_install_directory), and the models in Contents/Resources (app_resource_directory).
|
||||||
run_command("rm", "-rf", app);
|
//
|
||||||
|
// 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);
|
executable_folder := tprint("%/Contents/MacOS", app);
|
||||||
|
frameworks_folder := tprint("%/Contents/Frameworks", app);
|
||||||
resources_folder := tprint("%/Contents/Resources", 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(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);
|
make_directory_if_it_does_not_exist(resources_folder, recursive = true);
|
||||||
|
|
||||||
copied := copy_file(tprint("%/%", build_directory, executable_name), tprint("%/%", executable_folder, executable_name));
|
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));
|
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 {
|
if !copied {
|
||||||
log_error("ERROR: Couldn't copy the executable and its dylibs into %\n", app);
|
log_error("ERROR: Couldn't copy the executable and its dylibs into %\n", app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run_command("chmod", "+x", tprint("%/%", executable_folder, executable_name));
|
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.
|
// onnxruntime, and the models, as copy_assets puts them beside the executable.
|
||||||
resized := tprint("%/icon_1024.png", build_directory);
|
ORT_LIBRARY :: "modules/onnxruntime/macos/libonnxruntime.dylib";
|
||||||
result := run_command("sips", "-z", "1024", "1024", "assets/images/icon.png", "--out", resized);
|
if file_exists(ORT_LIBRARY) clone_if_changed(ORT_LIBRARY, tprint("%/libonnxruntime.dylib", frameworks_folder));
|
||||||
if result.exit_code == 0 result = run_command("sips", "-s", "format", "icns", resized, "--out", tprint("%/%.icns", resources_folder, APP_FOLDER_NAME));
|
Model_Folder :: struct { module: string; folder: string; }
|
||||||
file_delete(resized);
|
for Model_Folder.[.{"OCR", "ocr-models"}, .{"Labels", "label-models"}, .{"Segment", "segment-models"}] {
|
||||||
if result.exit_code != 0 log_error("ERROR: Couldn't make the icon for %; the Dock will show the generic one.\n", app);
|
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);
|
log_error("ERROR: Couldn't write %/Contents/Info.plist\n", app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ad hoc: a signature over the whole bundle, which macOS expects, not a Developer ID.
|
log("Built % (signed %)", app, sign_macos_app_bundle(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);
|
|
||||||
|
|
||||||
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
|
MACOS_INFO_PLIST :: #string PLIST
|
||||||
@ -257,7 +371,9 @@ finish_main_build :: (build: Pending_Build) {
|
|||||||
<key>CFBundlePackageType</key> <string>APPL</string>
|
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key> <string>%</string>
|
<key>CFBundleShortVersionString</key> <string>%</string>
|
||||||
<key>CFBundleVersion</key> <string>%</string>
|
<key>CFBundleVersion</key> <string>%</string>
|
||||||
|
<key>LSMinimumSystemVersion</key> <string>%</string>
|
||||||
<key>NSHighResolutionCapable</key> <true/>
|
<key>NSHighResolutionCapable</key> <true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key> <string>mcap records the microphone into a screen recording when its Mic button is on.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
@ -286,13 +402,17 @@ copy_assets :: () {
|
|||||||
copy_if_changed("modules/OCR/models.config", tprint("%/ocr-models/models.config", build_directory));
|
copy_if_changed("modules/OCR/models.config", tprint("%/ocr-models/models.config", build_directory));
|
||||||
if file_exists("modules/OCR/models") {
|
if file_exists("modules/OCR/models") {
|
||||||
for file_list("modules/OCR/models") copy_if_changed(it, tprint("%/ocr-models/%", build_directory, path_filename(it)));
|
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 {
|
} else {
|
||||||
log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR.");
|
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 {
|
#if OS == .WINDOWS {
|
||||||
for file_list("modules/ffmpeg/windows") if ends_with(it, ".dll") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it)));
|
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.
|
// Labels: its models in bin/label-models, the same way.
|
||||||
@ -300,6 +420,7 @@ copy_assets :: () {
|
|||||||
copy_if_changed("modules/Labels/models.config", tprint("%/label-models/models.config", build_directory));
|
copy_if_changed("modules/Labels/models.config", tprint("%/label-models/models.config", build_directory));
|
||||||
if file_exists("modules/Labels/models") {
|
if file_exists("modules/Labels/models") {
|
||||||
for file_list("modules/Labels/models") copy_if_changed(it, tprint("%/label-models/%", build_directory, path_filename(it)));
|
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 {
|
} else {
|
||||||
log("Labels: the models have not been fetched; run jai fetch_models.jai in modules/Labels for labels.");
|
log("Labels: the models have not been fetched; run jai fetch_models.jai in modules/Labels for labels.");
|
||||||
}
|
}
|
||||||
@ -309,6 +430,7 @@ copy_assets :: () {
|
|||||||
copy_if_changed("modules/Segment/models.config", tprint("%/segment-models/models.config", build_directory));
|
copy_if_changed("modules/Segment/models.config", tprint("%/segment-models/models.config", build_directory));
|
||||||
if file_exists("modules/Segment/models") {
|
if file_exists("modules/Segment/models") {
|
||||||
for file_list("modules/Segment/models") copy_if_changed(it, tprint("%/segment-models/%", build_directory, path_filename(it)));
|
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 {
|
} else {
|
||||||
log("Segmentation: the model has not been made; see modules/Segment/README.md.");
|
log("Segmentation: the model has not been made; see modules/Segment/README.md.");
|
||||||
}
|
}
|
||||||
|
|||||||
335
macos-plan.html
335
macos-plan.html
@ -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 & 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
264
modules/Core_ML/module.jai
Normal 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";
|
||||||
@ -258,7 +258,7 @@ heif_info :: (bytes: [] u8) -> Heif_Info {
|
|||||||
box := properties[index - 1];
|
box := properties[index - 1];
|
||||||
type := cast(string) slice_bytes(box, 4, 4);
|
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 == "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;
|
if type == "imir" && box.count >= 9 info.mirror = box[8] & 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
modules/Image_Decode/imageio.jai
Normal file
149
modules/Image_Decode/imageio.jai
Normal 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;
|
||||||
@ -1,21 +1,23 @@
|
|||||||
// Image_Decode: any image file's bytes to RGBA pixels, the right way up, and the PNG previews .mcap
|
// 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,
|
// 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
|
// TIFF, camera RAW) goes to the system's own codecs: on Windows WIC, which reads what the installed
|
||||||
// extensions (HEIF, WebP, Raw Image) can. A photo's EXIF orientation is applied, so a phone's portrait
|
// 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.
|
// photo comes out standing up.
|
||||||
//
|
//
|
||||||
// What the bytes are comes from their first bytes (image_kind), never from a file name, except for
|
// 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.
|
// 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
|
// 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 {
|
Image_Kind :: enum u8 {
|
||||||
UNKNOWN;
|
UNKNOWN;
|
||||||
PNG; JPEG; GIF; BMP; PSD; TGA; // stb_image
|
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
|
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());
|
error = to_string(stbi_failure_reason());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
container_orientation := 0; // ImageIO's, for HEIF and AVIF on macOS; 0 when not known
|
||||||
if !decoded {
|
if !decoded {
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS {
|
||||||
wic_ok: bool;
|
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);
|
if error return image, false, tprint("% (stb_image: %)", wic_error, error);
|
||||||
return image, false, wic_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 {
|
} 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 {
|
if orient {
|
||||||
orientation := 1;
|
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
|
// 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.
|
// them (HEIF requires a reader to); a quarter turn it left out shows as the sides not swapped.
|
||||||
heif := heif_info(bytes);
|
heif := heif_info(bytes);
|
||||||
@ -251,7 +265,8 @@ png_size :: (png: [] u8) -> width: s64, height: s64, ok: bool {
|
|||||||
#load "exif.jai";
|
#load "exif.jai";
|
||||||
#load "video.jai";
|
#load "video.jai";
|
||||||
#if OS == .WINDOWS #load "wic.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
|
#scope_module
|
||||||
|
|
||||||
|
|||||||
@ -24,9 +24,9 @@ Video_Info :: struct {
|
|||||||
// start with; the first frame of a clip shorter than a second.
|
// start with; the first frame of a clip shorter than a second.
|
||||||
VIDEO_POSTER_MAX_SECONDS :: 3.0;
|
VIDEO_POSTER_MAX_SECONDS :: 3.0;
|
||||||
|
|
||||||
#if OS != .WINDOWS {
|
#if OS == .LINUX {
|
||||||
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
|
||||||
image: Image;
|
image: Image;
|
||||||
|
|||||||
@ -52,7 +52,8 @@ sha256_of :: (path: string) -> string {
|
|||||||
if lines.count < 2 return "";
|
if lines.count < 2 return "";
|
||||||
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
||||||
} else {
|
} 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);
|
result, output := run_command(..command, capture_and_return_output = true);
|
||||||
if result.exit_code != 0 return "";
|
if result.exit_code != 0 return "";
|
||||||
return to_lower_copy(trim(split(output, " ")[0]));
|
return to_lower_copy(trim(split(output, " ")[0]));
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
# logit_scale, logit_bias a phrase's score for an image is sigmoid(e^logit_scale * cosine + logit_bias),
|
# 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)
|
# from the trained model (they are not in the ONNX encoders)
|
||||||
# image_batch images run through the vision model together
|
# 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]
|
[model siglip2-base-patch16-384]
|
||||||
title: "SigLIP 2 base, 384 px"
|
title: "SigLIP 2 base, 384 px"
|
||||||
@ -21,3 +23,4 @@ max_tokens: 64
|
|||||||
logit_scale: 4.726024
|
logit_scale: 4.726024
|
||||||
logit_bias: -16.772243
|
logit_bias: -16.772243
|
||||||
image_batch: 8
|
image_batch: 8
|
||||||
|
vision_coreml: "siglip2-base-patch16-384-vision.mlpackage"
|
||||||
|
|||||||
@ -8,9 +8,11 @@
|
|||||||
//
|
//
|
||||||
// The models are listed in models_folder/models.config (fetch_models.jai downloads them) and run
|
// 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
|
// 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.
|
// Tightly packed or not: stride is the bytes from one row to the next.
|
||||||
Labels_Image :: struct {
|
Labels_Image :: struct {
|
||||||
@ -22,6 +24,7 @@ Labels_Model_Info :: struct {
|
|||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
vision, text, tokenizer: string; // file names in the models folder
|
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;
|
image_size: s32 = 384;
|
||||||
max_tokens: s32 = 64;
|
max_tokens: s32 = 64;
|
||||||
logit_scale: float = 0;
|
logit_scale: float = 0;
|
||||||
@ -36,6 +39,7 @@ Labels_Engine :: struct {
|
|||||||
env: *OrtEnv;
|
env: *OrtEnv;
|
||||||
memory_info: *OrtMemoryInfo;
|
memory_info: *OrtMemoryInfo;
|
||||||
vision, text: Ort_Session;
|
vision, text: Ort_Session;
|
||||||
|
#if OS == .MACOS coreml: *Coreml_Model; // the vision model, when it runs through Core ML
|
||||||
tokenizer: *Gemma_Tokenizer;
|
tokenizer: *Gemma_Tokenizer;
|
||||||
dimensions: s32; // of every vector
|
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 "vision"; model.vision = copy_string(value);
|
||||||
case "text"; model.text = copy_string(value);
|
case "text"; model.text = copy_string(value);
|
||||||
case "tokenizer"; model.tokenizer = 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 "image_size"; if number_ok model.image_size = cast(s32) number;
|
||||||
case "max_tokens"; if number_ok model.max_tokens = 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;
|
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);
|
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1);
|
||||||
vision_path := tprint("%/%", models_folder, model.vision);
|
vision_path := tprint("%/%", models_folder, model.vision);
|
||||||
use_gpu := false;
|
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");
|
available, explanation := ort_cuda_check("Labelling");
|
||||||
use_gpu = available;
|
use_gpu = available;
|
||||||
if !available engine.gpu_explanation = copy_string(explanation);
|
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.");
|
engine.gpu_explanation = copy_string("Labelling runs on the CPU, as it was asked to.");
|
||||||
}
|
}
|
||||||
if use_gpu {
|
if use_gpu {
|
||||||
@ -133,7 +150,7 @@ labels_create :: (models_folder: string, model_name: string, prefer_gpu := true)
|
|||||||
use_gpu = false;
|
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);
|
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; }
|
if !ok { labels_destroy(engine); return null, false, error; }
|
||||||
engine.provider = .CPU;
|
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.
|
// The vectors' size, from a run of the text model on an empty text.
|
||||||
probe := labels_embed_texts(engine, .[""],, temp);
|
probe := labels_embed_texts(engine, .[""],, temp);
|
||||||
if !engine.dimensions { labels_destroy(engine); return null, false, "The text model gave no vector."; }
|
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, "";
|
return engine, true, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +182,7 @@ labels_destroy :: (engine: *Labels_Engine) {
|
|||||||
if engine.provider == .CUDA ort_gpu_lock();
|
if engine.provider == .CUDA ort_gpu_lock();
|
||||||
ort_release_session(*engine.vision);
|
ort_release_session(*engine.vision);
|
||||||
if engine.provider == .CUDA ort_gpu_unlock();
|
if engine.provider == .CUDA ort_gpu_unlock();
|
||||||
|
#if OS == .MACOS coreml_free(engine.coreml);
|
||||||
ort_release_session(*engine.text);
|
ort_release_session(*engine.text);
|
||||||
tokenizer_free(engine.tokenizer,, context.default_allocator);
|
tokenizer_free(engine.tokenizer,, context.default_allocator);
|
||||||
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
|
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 {
|
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
|
// 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);
|
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
|
||||||
defer array_free(input,, 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;
|
first := 0;
|
||||||
while first < images.count {
|
while first < images.count {
|
||||||
count := min(batch, images.count - first);
|
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;
|
size := engine.model.image_size;
|
||||||
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
|
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
|
||||||
defer array_free(input,, 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]);
|
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);
|
if !ok return false, tprint("The first run on the GPU failed: %", error);
|
||||||
ort_release_output(*out);
|
ort_release_output(*out);
|
||||||
@ -374,8 +433,10 @@ labels_strip_comment :: (line: string) -> string {
|
|||||||
#import "Basic";
|
#import "Basic";
|
||||||
#import "String";
|
#import "String";
|
||||||
#import "File";
|
#import "File";
|
||||||
|
#import "File_Utilities";
|
||||||
#import "Math";
|
#import "Math";
|
||||||
#import "System";
|
#import "System";
|
||||||
#import "Hash_Table";
|
#import "Hash_Table";
|
||||||
#import "Float16";
|
#import "Float16";
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
|
#if OS == .MACOS #import "Core_ML";
|
||||||
|
|||||||
@ -63,8 +63,14 @@ ocr_detect_tile :: (engine: *Ocr_Engine, image: Ocr_Image, scale: float, scaled_
|
|||||||
valid_h := tile_bottom - tile_top;
|
valid_h := tile_bottom - tile_top;
|
||||||
w := align_up_32(valid_w);
|
w := align_up_32(valid_w);
|
||||||
h := align_up_32(valid_h);
|
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;
|
plane := cast(s64) w * h;
|
||||||
input := NewArray(plane * 3, float, initialized = true,, context.default_allocator);
|
input := NewArray(plane * 3, float, initialized = true,, context.default_allocator);
|
||||||
defer array_free(input,, context.default_allocator);
|
defer array_free(input,, context.default_allocator);
|
||||||
@ -83,14 +89,31 @@ ocr_detect_tile :: (engine: *Ocr_Engine, image: Ocr_Image, scale: float, scaled_
|
|||||||
}
|
}
|
||||||
|
|
||||||
shape := s64.[1, 3, h, w];
|
shape := s64.[1, 3, h, w];
|
||||||
out, ok, error := ocr_run(*engine.det, engine.memory_info, input, shape);
|
probabilities: *float;
|
||||||
if !ok return false, error;
|
dims: [] s64;
|
||||||
|
out: Ocr_Output;
|
||||||
defer ocr_release_output(*out);
|
defer ocr_release_output(*out);
|
||||||
if out.dim_count != 4 || out.dims[2] != h || out.dims[3] != w {
|
#if OS == .MACOS {
|
||||||
return false, tprint("The detector gave an output of shape %, not [1, 1, %, %].", array_view(out.dims, 0, out.dim_count), h, w);
|
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;
|
||||||
|
out, ok, error = ocr_run(*engine.det, engine.memory_info, input, shape);
|
||||||
|
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 {
|
defer {
|
||||||
for regions array_free(it.outline);
|
for regions array_free(it.outline);
|
||||||
array_free(regions,, context.default_allocator);
|
array_free(regions,, context.default_allocator);
|
||||||
@ -254,6 +277,12 @@ align_up_32 :: (n: s32) -> s32 {
|
|||||||
return (n + 31) & cast(s32) ~31;
|
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 {
|
Region :: struct {
|
||||||
pixels: s64;
|
pixels: s64;
|
||||||
probability_sum: float64;
|
probability_sum: float64;
|
||||||
|
|||||||
@ -59,7 +59,8 @@ sha256_of :: (path: string) -> string {
|
|||||||
if lines.count < 2 return "";
|
if lines.count < 2 return "";
|
||||||
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
||||||
} else {
|
} 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);
|
result, output := run_command(..command, capture_and_return_output = true);
|
||||||
if result.exit_code != 0 return "";
|
if result.exit_code != 0 return "";
|
||||||
return to_lower_copy(trim(split(output, " ")[0]));
|
return to_lower_copy(trim(split(output, " ")[0]));
|
||||||
|
|||||||
@ -16,6 +16,12 @@
|
|||||||
# rec_batch_gpu text lines recognized together in one run, on the GPU
|
# 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
|
# 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
|
# 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]
|
[model ppocr-v5-mobile]
|
||||||
title: "PP-OCRv5 mobile (light)"
|
title: "PP-OCRv5 mobile (light)"
|
||||||
@ -31,6 +37,8 @@ unclip_ratio: 1.5
|
|||||||
rec_height: 48
|
rec_height: 48
|
||||||
rec_batch_gpu: 16
|
rec_batch_gpu: 16
|
||||||
rec_batch_cpu: 1
|
rec_batch_cpu: 1
|
||||||
|
detector_coreml: "ch_PP-OCRv5_det_mobile.mlpackage"
|
||||||
|
recognizer_coreml: "ch_PP-OCRv5_rec_mobile.mlpackage"
|
||||||
|
|
||||||
[model ppocr-v5-server]
|
[model ppocr-v5-server]
|
||||||
title: "PP-OCRv5 server (heavy)"
|
title: "PP-OCRv5 server (heavy)"
|
||||||
@ -46,3 +54,5 @@ unclip_ratio: 1.5
|
|||||||
rec_height: 48
|
rec_height: 48
|
||||||
rec_batch_gpu: 8
|
rec_batch_gpu: 8
|
||||||
rec_batch_cpu: 1
|
rec_batch_cpu: 1
|
||||||
|
detector_coreml: "ch_PP-OCRv5_det_server.mlpackage"
|
||||||
|
recognizer_coreml: "ch_PP-OCRv5_rec_server.mlpackage"
|
||||||
|
|||||||
@ -14,6 +14,15 @@ Ocr_Model_Info :: struct {
|
|||||||
rec_height: s32 = 48;
|
rec_height: s32 = 48;
|
||||||
rec_batch_gpu: s32 = 16;
|
rec_batch_gpu: s32 = 16;
|
||||||
rec_batch_cpu: s32 = 1;
|
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";
|
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_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_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 "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);
|
case; log_error("[OCR] %:%: unknown key %. Ignored.", path, line_number, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,13 +113,66 @@ ocr_create_sessions :: (engine: *Ocr_Engine, models_folder: string, gpu: bool, i
|
|||||||
if model.textline_ori {
|
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);
|
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;
|
if !ok return false, error;
|
||||||
|
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.
|
// 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;
|
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_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;
|
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, "";
|
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_sessions :: (engine: *Ocr_Engine) {
|
||||||
ocr_release_session(*engine.det);
|
ocr_release_session(*engine.det);
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
// then recognition (recognize.jai) reads it level. When the model lists an orientation classifier,
|
// 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.
|
// 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.
|
// Tightly packed or not: stride is the bytes from one row to the next.
|
||||||
Ocr_Image :: struct {
|
Ocr_Image :: struct {
|
||||||
@ -40,6 +40,7 @@ Ocr_Engine :: struct {
|
|||||||
env: *OrtEnv;
|
env: *OrtEnv;
|
||||||
memory_info: *OrtMemoryInfo;
|
memory_info: *OrtMemoryInfo;
|
||||||
det, rec: Ocr_Session;
|
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: Ocr_Session; // the text line orientation classifier; none when its session is null
|
||||||
orientation_width, orientation_height: s32; // its input size
|
orientation_width, orientation_height: s32; // its input size
|
||||||
dictionary: [] string; // class n is dictionary[n - 1]
|
dictionary: [] string; // class n is dictionary[n - 1]
|
||||||
@ -117,11 +118,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
|
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1); // efficiency cores included
|
||||||
|
|
||||||
use_gpu := false;
|
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
|
available, explanation := ort_cuda_check("OCR"); // modules/onnxruntime/cuda.jai
|
||||||
use_gpu = available;
|
use_gpu = available;
|
||||||
if !available engine.gpu_explanation = copy_string(explanation);
|
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.");
|
engine.gpu_explanation = copy_string("OCR runs on the CPU, as it was asked to.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +174,16 @@ 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.
|
// 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 {
|
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) {
|
ocr_destroy :: (engine: *Ocr_Engine) {
|
||||||
@ -169,6 +192,7 @@ ocr_destroy :: (engine: *Ocr_Engine) {
|
|||||||
if engine.provider == .CUDA ort_gpu_lock();
|
if engine.provider == .CUDA ort_gpu_lock();
|
||||||
ocr_release_sessions(engine);
|
ocr_release_sessions(engine);
|
||||||
if engine.provider == .CUDA ort_gpu_unlock();
|
if engine.provider == .CUDA ort_gpu_unlock();
|
||||||
|
ocr_release_coreml(engine);
|
||||||
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
|
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
|
||||||
if engine.env ort.api.ReleaseEnv(engine.env);
|
if engine.env ort.api.ReleaseEnv(engine.env);
|
||||||
free(engine.gpu_explanation,, context.default_allocator);
|
free(engine.gpu_explanation,, context.default_allocator);
|
||||||
@ -458,6 +482,7 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void
|
|||||||
#import "Atomics";
|
#import "Atomics";
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
Default_Allocator :: #import "Default_Allocator"; // rpmalloc (modules/Default_Allocator), for thread_idle where the workers wait
|
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 {
|
#if OS == .WINDOWS {
|
||||||
#import "Windows";
|
#import "Windows";
|
||||||
#import "Windows_Utf8";
|
#import "Windows_Utf8";
|
||||||
|
|||||||
@ -29,21 +29,44 @@ ocr_recognize_batch :: (engine: *Ocr_Engine, jobs: [] *Line_Job) -> ok: bool, er
|
|||||||
step := max(engine.options.rec_width_step, 1);
|
step := max(engine.options.rec_width_step, 1);
|
||||||
width = (width + step - 1) / step * step;
|
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;
|
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);
|
defer array_free(input,, context.default_allocator);
|
||||||
for job, n: jobs {
|
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);
|
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];
|
shape := s64.[rows, 3, height, width];
|
||||||
out, ok, error := ocr_run(*engine.rec, engine.memory_info, input, shape);
|
scores: *float;
|
||||||
if !ok return false, error;
|
dims: [] s64;
|
||||||
|
out: Ocr_Output;
|
||||||
defer ocr_release_output(*out);
|
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));
|
#if OS == .MACOS {
|
||||||
steps := out.dims[1];
|
coreml_out: Coreml_Output;
|
||||||
classes := out.dims[2];
|
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;
|
||||||
|
out, ok, error = ocr_run(*engine.rec, engine.memory_info, input, shape);
|
||||||
|
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 {
|
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);
|
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 +78,7 @@ ocr_recognize_batch :: (engine: *Ocr_Engine, jobs: [] *Line_Job) -> ok: bool, er
|
|||||||
probability_sum: float64;
|
probability_sum: float64;
|
||||||
characters := 0;
|
characters := 0;
|
||||||
for t: 0..steps - 1 {
|
for t: 0..steps - 1 {
|
||||||
row := out.data + (n * steps + t) * classes;
|
row := scores + (n * steps + t) * classes;
|
||||||
best := 0;
|
best := 0;
|
||||||
best_p := row[0];
|
best_p := row[0];
|
||||||
for c: 1..classes - 1 if row[c] > best_p { best = c; best_p = row[c]; }
|
for c: 1..classes - 1 if row[c] > best_p { best = c; best_p = row[c]; }
|
||||||
|
|||||||
77
modules/Screen_Record/audio_mix.jai
Normal file
77
modules/Screen_Record/audio_mix.jai
Normal 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, "";
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// The encoder thread's half: frames of BGRA pixels to H.264 in a fragmented MP4, with FFmpeg
|
// 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:
|
// (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
|
// 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).
|
// 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
|
// 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.
|
// 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_KEYFRAME_SECONDS :: 2;
|
||||||
RECORD_AUDIO_BIT_RATE :: 160_000;
|
RECORD_AUDIO_BIT_RATE :: 160_000;
|
||||||
|
|
||||||
@ -170,6 +172,7 @@ encoder_close :: (e: *Encoder) -> ok: bool, error: string {
|
|||||||
#scope_file
|
#scope_file
|
||||||
|
|
||||||
RECORD_TIME_BASE :: 90000; // pts ticks a second: MPEG's
|
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 {
|
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));
|
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_qsv"; set("global_quality", "23"); set("preset", "medium");
|
||||||
case "h264_mf"; set("rate_control", "quality"); set("quality", "70"); set("scenario", "display_remoting");
|
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 "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);
|
result := avcodec_open2(c, codec, *options);
|
||||||
av_dict_free(*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.
|
// 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 {
|
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;
|
if !codec codec, stream, packet = e.codec, e.stream, e.packet;
|
||||||
while true {
|
while true {
|
||||||
result := avcodec_receive_packet(codec, packet);
|
result := avcodec_receive_packet(codec, packet);
|
||||||
|
|||||||
526
modules/Screen_Record/macos.jai
Normal file
526
modules/Screen_Record/macos.jai
Normal 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";
|
||||||
53
modules/Screen_Record/macos/capture.h
Normal file
53
modules/Screen_Record/macos/capture.h
Normal 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);
|
||||||
392
modules/Screen_Record/macos/capture.m
Normal file
392
modules/Screen_Record/macos/capture.m
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,33 +1,14 @@
|
|||||||
// Screen_Record: a region of one screen recorded to H.264 in an MP4, for the mcap app's screen
|
// 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.
|
// 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
|
||||||
// Two threads. The capture thread takes the screen with DXGI Desktop Duplication (dxgi_source.jai),
|
// ScreenCaptureKit, its frames and sound alike, through modules/Screen_Record/macos/capture.m
|
||||||
// or with Windows.Graphics.Capture (wgc_source.jai) where duplication cannot run, each on a D3D11
|
// (macos.jai). Either way to FFmpeg (encoder.jai), the sound mixed onto the video's timeline
|
||||||
// device of its own; copies the region on the GPU; reads it back through a ring of staging textures,
|
// (audio_mix.jai), and made a regular MP4 at the end (remux.jai).
|
||||||
// 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;
|
// 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);
|
// 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_WIDTH :: 240; // a smaller region is refused
|
||||||
RECORD_MIN_HEIGHT :: 180;
|
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;
|
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
Record_Screen :: struct {
|
#if OS == .WINDOWS #load "windows.jai";
|
||||||
adapter_index, output_index: u32;
|
#if OS == .MACOS #load "macos.jai";
|
||||||
adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
|
#load "audio_mix.jai";
|
||||||
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";
|
|
||||||
#load "encoder.jai";
|
#load "encoder.jai";
|
||||||
#load "remux.jai";
|
#load "remux.jai";
|
||||||
#load "audio_source.jai";
|
|
||||||
#load "cursor.jai";
|
|
||||||
|
|
||||||
#import "Basic";
|
#import "Basic";
|
||||||
#import "String";
|
#import "String";
|
||||||
#import "Atomics";
|
#import "Atomics";
|
||||||
#import "Math";
|
#import "Math";
|
||||||
#import "Thread";
|
#import "Thread";
|
||||||
#import "Windows";
|
|
||||||
#import "Windows_Utf8";
|
|
||||||
#import "dxgi";
|
|
||||||
#import "d3d11";
|
|
||||||
#import "ffmpeg";
|
#import "ffmpeg";
|
||||||
|
|||||||
701
modules/Screen_Record/windows.jai
Normal file
701
modules/Screen_Record/windows.jai
Normal 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";
|
||||||
@ -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
|
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.
|
`bin/segment-models`. The models folder is not in git.
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
[model mask2former-swin-base-coco-panoptic]
|
[model mask2former-swin-base-coco-panoptic]
|
||||||
title: "Mask2Former Swin-B, COCO panoptic (133 classes)"
|
title: "Mask2Former Swin-B, COCO panoptic (133 classes)"
|
||||||
model: "mask2former-swin-base-coco-panoptic.onnx"
|
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"
|
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
|
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
|
mean: 0.485 0.456 0.406
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
// transformers' post_process_panoptic_segmentation does (tools/export_mask2former.py checks that
|
// transformers' post_process_panoptic_segmentation does (tools/export_mask2former.py checks that
|
||||||
// they match).
|
// 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.
|
// Tightly packed or not: stride is the bytes from one row to the next.
|
||||||
Segment_Image :: struct {
|
Segment_Image :: struct {
|
||||||
@ -23,6 +23,7 @@ Segment_Model_Info :: struct {
|
|||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
model: string; // the .onnx, in the models folder
|
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
|
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
|
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
|
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;
|
env: *OrtEnv;
|
||||||
memory_info: *OrtMemoryInfo;
|
memory_info: *OrtMemoryInfo;
|
||||||
session: Ort_Session;
|
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"
|
cancel: *bool; // when set, and true, segment_images stops between batches: the rest fail as "cancelled"
|
||||||
stats: Segment_Stats; // since it was made
|
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 "title"; model.title = copy_string(value);
|
||||||
case "model"; model.model = copy_string(value);
|
case "model"; model.model = copy_string(value);
|
||||||
case "classes"; model.classes = 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 "image_size"; if number_ok model.image_size = cast(s32) number;
|
||||||
case "mean"; segment_parse_three(value, *model.mean, path, line_number);
|
case "mean"; segment_parse_three(value, *model.mean, path, line_number);
|
||||||
case "std"; segment_parse_three(value, *model.std, 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";
|
HINT :: "make it with tools/export_mask2former.py (see modules/Segment/README.md) and rebuild";
|
||||||
path := tprint("%/%", models_folder, model.model);
|
path := tprint("%/%", models_folder, model.model);
|
||||||
use_gpu := false;
|
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");
|
available, explanation := ort_cuda_check("Segmentation");
|
||||||
use_gpu = available;
|
use_gpu = available;
|
||||||
if !available engine.gpu_explanation = copy_string(explanation);
|
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.");
|
engine.gpu_explanation = copy_string("Segmentation runs on the CPU, as it was asked to.");
|
||||||
}
|
}
|
||||||
if use_gpu {
|
if use_gpu {
|
||||||
@ -190,6 +211,7 @@ segment_destroy :: (engine: *Segment_Engine) {
|
|||||||
if engine.provider == .CUDA ort_gpu_lock();
|
if engine.provider == .CUDA ort_gpu_lock();
|
||||||
ort_release_session(*engine.session);
|
ort_release_session(*engine.session);
|
||||||
if engine.provider == .CUDA ort_gpu_unlock();
|
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.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
|
||||||
if engine.env ort.api.ReleaseEnv(engine.env);
|
if engine.env ort.api.ReleaseEnv(engine.env);
|
||||||
for engine.classes { free(it.name); free(it.model_name); }
|
for engine.classes { free(it.name); free(it.model_name); }
|
||||||
@ -198,6 +220,24 @@ segment_destroy :: (engine: *Segment_Engine) {
|
|||||||
free(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.
|
// 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 {
|
segment_map_size :: (width: s32, height: s32, max_side: s32) -> s32, s32 {
|
||||||
scale := min(cast(float64) 1, cast(float64) max_side / max(width, height));
|
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);
|
results := NewArray(images.count, Segment_Result,, allocator);
|
||||||
size := engine.model.image_size;
|
size := engine.model.image_size;
|
||||||
plane := cast(s64) size * 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);
|
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
|
||||||
defer array_free(input,, 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();
|
start := current_time_monotonic();
|
||||||
for 0..count - 1 segment_prepare_image(engine.model, images[first + it], input.data + plane * 3 * it);
|
for 0..count - 1 segment_prepare_image(engine.model, images[first + it], input.data + plane * 3 * it);
|
||||||
shape := s64.[count, 3, size, size];
|
shape := s64.[count, 3, size, size];
|
||||||
outs: [2] Ort_Output;
|
outs: [2] Segment_Output;
|
||||||
ran := current_time_monotonic();
|
ran := current_time_monotonic();
|
||||||
engine.stats.prepare_us += to_microseconds(ran - start);
|
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();
|
posted := current_time_monotonic();
|
||||||
engine.stats.model_us += to_microseconds(posted - ran);
|
engine.stats.model_us += to_microseconds(posted - ran);
|
||||||
engine.stats.images += count;
|
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);
|
cast(s32) queries, cast(s32) labels, cast(s32) mask_w, cast(s32) mask_h, image.width, image.height, allocator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ort_release_output(*outs[0]);
|
segment_release_outputs(engine, outs);
|
||||||
ort_release_output(*outs[1]);
|
|
||||||
}
|
}
|
||||||
first += count;
|
first += count;
|
||||||
}
|
}
|
||||||
@ -524,14 +563,48 @@ segment_warm_up :: (engine: *Segment_Engine) -> ok: bool, error: string {
|
|||||||
size := engine.model.image_size;
|
size := engine.model.image_size;
|
||||||
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
|
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
|
||||||
defer array_free(input,, context.default_allocator);
|
defer array_free(input,, context.default_allocator);
|
||||||
outs: [2] Ort_Output;
|
outs: [2] Segment_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);
|
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);
|
if !ok return false, tprint("The first run on the GPU failed: %", error);
|
||||||
ort_release_output(*outs[0]);
|
segment_release_outputs(engine, outs);
|
||||||
ort_release_output(*outs[1]);
|
|
||||||
return true, "";
|
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.
|
// 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 {
|
segment_load_classes :: (engine: *Segment_Engine, path: string) -> ok: bool, error: string {
|
||||||
text, ok := read_entire_file(path, log_errors = false,, temp);
|
text, ok := read_entire_file(path, log_errors = false,, temp);
|
||||||
@ -597,9 +670,11 @@ segment_strip_comment :: (line: string) -> string {
|
|||||||
#import "Basic";
|
#import "Basic";
|
||||||
#import "String";
|
#import "String";
|
||||||
#import "File";
|
#import "File";
|
||||||
|
#import "File_Utilities";
|
||||||
#import "Math";
|
#import "Math";
|
||||||
#import "System";
|
#import "System";
|
||||||
#import "Hash";
|
#import "Hash";
|
||||||
#import "Sort";
|
#import "Sort";
|
||||||
#import "Capture_File"; // segment_mcap_chunks
|
#import "Capture_File"; // segment_mcap_chunks
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
|
#if OS == .MACOS #import "Core_ML";
|
||||||
|
|||||||
@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
FFmpeg bindings for jai and some examples decoding and play videos.
|
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
|
## Setup
|
||||||
|
|
||||||
The FFmpeg libaries and header files are not included in this repo as I didn't want to have to deal with license
|
The FFmpeg libaries and header files are not included in this repo as I didn't want to have to deal with license
|
||||||
|
|||||||
117
modules/ffmpeg/build_macos.jai
Normal file
117
modules/ffmpeg/build_macos.jai
Normal 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";
|
||||||
@ -46,10 +46,13 @@ FOOTER :: #string DONE
|
|||||||
|
|
||||||
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 {
|
generate_bindings :: () -> bool {
|
||||||
output_filename := "windows.jai";
|
#if OS == .MACOS output_filename := "macos.jai";
|
||||||
|
else output_filename := "windows.jai";
|
||||||
|
|
||||||
opts: Generate_Bindings_Options;
|
opts: Generate_Bindings_Options;
|
||||||
{
|
{
|
||||||
@ -67,9 +70,20 @@ generate_bindings :: () -> bool {
|
|||||||
// doesn't work, just get a load of __UnknownLib dont you :roll:.
|
// doesn't work, just get a load of __UnknownLib dont you :roll:.
|
||||||
// strip_flags &= ~.SYMBOLS_WITH_UNKNOWN_FOREIGN_LIBS;
|
// strip_flags &= ~.SYMBOLS_WITH_UNKNOWN_FOREIGN_LIBS;
|
||||||
|
|
||||||
|
#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");
|
array_add(*libpaths, "windows");
|
||||||
|
library_names :: string.[
|
||||||
libraries := string.[
|
|
||||||
"avcodec-60",
|
"avcodec-60",
|
||||||
"avdevice-60",
|
"avdevice-60",
|
||||||
"avfilter-9",
|
"avfilter-9",
|
||||||
@ -79,7 +93,8 @@ generate_bindings :: () -> bool {
|
|||||||
"swresample-4",
|
"swresample-4",
|
||||||
"swscale-7",
|
"swscale-7",
|
||||||
];
|
];
|
||||||
for libraries array_add(*libnames, it);
|
}
|
||||||
|
for library_names array_add(*libnames, it);
|
||||||
|
|
||||||
array_add(*include_paths, INCLUDE_PATH);
|
array_add(*include_paths, INCLUDE_PATH);
|
||||||
|
|
||||||
@ -89,7 +104,7 @@ generate_bindings :: () -> bool {
|
|||||||
// pick the headers I want!
|
// pick the headers I want!
|
||||||
#if false {
|
#if false {
|
||||||
dirs_to_visit: [..] string;
|
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) {
|
file_visitor :: (info: *File_Visit_Info, source_files : *[..] string) {
|
||||||
assert(info != null, "info (%) != null", info);
|
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("%/libavformat/avformat.h", INCLUDE_PATH));
|
||||||
array_add(*source_files, tprint("%/libswscale/swscale.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;
|
builder: String_Builder;
|
||||||
append(*builder, HEADER);
|
append(*builder, HEADER);
|
||||||
@ -134,6 +149,14 @@ generate_bindings :: () -> bool {
|
|||||||
print("Generating ffmpeg bindings...\n");
|
print("Generating ffmpeg bindings...\n");
|
||||||
result := generate_bindings(opts, output_filename);
|
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;
|
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 longest_name_length = max(longest_name_length, it.name.count);
|
||||||
for info_struct.members {
|
for info_struct.members {
|
||||||
slot := cast (*u8) *dummy + it.offset_in_bytes;
|
slot := cast (*u8) *dummy + it.offset_in_bytes;
|
||||||
value := << cast(*s32) slot;
|
value := (cast(*s32) slot).*;
|
||||||
append(b, it.name);
|
append(b, it.name);
|
||||||
num_spaces := longest_name_length - it.name.count;
|
num_spaces := longest_name_length - it.name.count;
|
||||||
for 1..num_spaces append(b, " ");
|
for 1..num_spaces append(b, " ");
|
||||||
|
|||||||
12947
modules/ffmpeg/macos.jai
Normal file
12947
modules/ffmpeg/macos.jai
Normal file
File diff suppressed because it is too large
Load Diff
23
modules/ffmpeg/macos/LICENSE-BUILD.txt
Normal file
23
modules/ffmpeg/macos/LICENSE-BUILD.txt
Normal 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
|
||||||
BIN
modules/ffmpeg/macos/libavcodec.60.dylib
Normal file
BIN
modules/ffmpeg/macos/libavcodec.60.dylib
Normal file
Binary file not shown.
BIN
modules/ffmpeg/macos/libavformat.60.dylib
Normal file
BIN
modules/ffmpeg/macos/libavformat.60.dylib
Normal file
Binary file not shown.
BIN
modules/ffmpeg/macos/libavutil.58.dylib
Normal file
BIN
modules/ffmpeg/macos/libavutil.58.dylib
Normal file
Binary file not shown.
BIN
modules/ffmpeg/macos/libswresample.4.dylib
Normal file
BIN
modules/ffmpeg/macos/libswresample.4.dylib
Normal file
Binary file not shown.
BIN
modules/ffmpeg/macos/libswscale.7.dylib
Normal file
BIN
modules/ffmpeg/macos/libswscale.7.dylib
Normal file
Binary file not shown.
@ -7,7 +7,9 @@ size_t :: u64;
|
|||||||
|
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS {
|
||||||
#load "windows.jai";
|
#load "windows.jai";
|
||||||
|
} else #if OS == .MACOS {
|
||||||
|
#load "macos.jai"; // generated from build_macos.jai's headers; its libraries are in macos/
|
||||||
} else {
|
} else {
|
||||||
#assert false, "Only windows has bindings atm, sorry bros!";
|
#assert false, "FFmpeg has bindings for Windows and macOS only.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
// 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.
|
// missing, and the steps that fix it, starting "<what> runs on the CPU". Temp.
|
||||||
ort_cuda_check :: (what: string) -> available: bool, explanation: string {
|
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 {
|
#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);
|
provider_path := tprint("%/%", path_strip_filename(ort.library_path), CUDA_PROVIDER_LIBRARY);
|
||||||
|
|||||||
@ -113,7 +113,8 @@ sha256_of :: (path: string) -> string {
|
|||||||
if lines.count < 2 return "";
|
if lines.count < 2 return "";
|
||||||
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
return to_lower_copy(replace(trim(lines[1]), " ", "")); // line 2 is the hash
|
||||||
} else {
|
} 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);
|
result, output := run_command(..command, capture_and_return_output = true);
|
||||||
if result.exit_code != 0 return "";
|
if result.exit_code != 0 return "";
|
||||||
return to_lower_copy(trim(split(output, " ")[0]));
|
return to_lower_copy(trim(split(output, " ")[0]));
|
||||||
|
|||||||
90
modules/onnxruntime/include/coreml_provider_factory.h
Normal file
90
modules/onnxruntime/include/coreml_provider_factory.h
Normal 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
|
||||||
14
modules/onnxruntime/include/webgpu_provider_factory.h
Normal file
14
modules/onnxruntime/include/webgpu_provider_factory.h
Normal 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.
|
||||||
@ -14,15 +14,17 @@ ort: struct {
|
|||||||
log_level := OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING;
|
log_level := OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads onnxruntime from the first folder that has it: beside the running executable, then each
|
// Loads onnxruntime from the first folder that has it: beside the running executable (or in a macOS
|
||||||
// of folders. A full path is always used: Windows keeps an older onnxruntime.dll in System32 for
|
// bundle's Contents/Frameworks), then each of folders. A full path is always used: Windows keeps an
|
||||||
// its own use, which a bare name could find first.
|
// older onnxruntime.dll in System32 for its own use, which a bare name could find first.
|
||||||
ort_load :: (folders: [] string = .[]) -> ok: bool, error: string {
|
ort_load :: (folders: [] string = .[]) -> ok: bool, error: string {
|
||||||
if ort.api return true, "";
|
if ort.api return true, "";
|
||||||
|
|
||||||
candidates: [..] string;
|
candidates: [..] string;
|
||||||
candidates.allocator = temp;
|
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);
|
for folders array_add(*candidates, it);
|
||||||
|
|
||||||
tried: [..] string;
|
tried: [..] string;
|
||||||
|
|||||||
14
src/App.jai
14
src/App.jai
@ -31,7 +31,9 @@ USING_WINDOWS :: OS == .WINDOWS;
|
|||||||
#load "app/screen_capture.jai";
|
#load "app/screen_capture.jai";
|
||||||
#load "app/screen_record.jai";
|
#load "app/screen_record.jai";
|
||||||
#if OS == .WINDOWS #load "app/screen_capture_win32.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 == .WINDOWS #load "app/screen_record_win32.jai";
|
||||||
|
#if OS == .MACOS #load "app/screen_record_macos.jai";
|
||||||
|
|
||||||
#load "base/circle_buffer.jai";
|
#load "base/circle_buffer.jai";
|
||||||
#load "base/debug.jai";
|
#load "base/debug.jai";
|
||||||
@ -78,7 +80,7 @@ Shared_Channel :: #import "Shared_Memory_Channel";
|
|||||||
#import "Capture_Text";
|
#import "Capture_Text";
|
||||||
#import "Base64";
|
#import "Base64";
|
||||||
#import "onnxruntime";
|
#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.
|
// 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);
|
using Secure_Memory :: #import "Secure_Memory"()(LOAD_TESTS=COMPILE_TESTS);
|
||||||
@ -199,7 +201,7 @@ App :: struct {
|
|||||||
run_tests: bool;
|
run_tests: bool;
|
||||||
headless: bool; // -headless (or a no_crash_dialog build): no message boxes on failures, and run_tests exits when done
|
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
|
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)
|
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
|
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)
|
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 +311,14 @@ app_running_from_app_bundle :: () -> bool {
|
|||||||
return app_from_app_bundle;
|
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
|
#scope_file
|
||||||
app_install_directory_path: string;
|
app_install_directory_path: string;
|
||||||
app_from_app_bundle: bool;
|
app_from_app_bundle: bool;
|
||||||
|
|||||||
@ -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.
|
// 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 {
|
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});
|
hash, hashed := import_hash_file(.{path = path, offset = 0, size = size});
|
||||||
if !hashed return .FAILED, "cannot be read", "";
|
if !hashed return .FAILED, "cannot be read", "";
|
||||||
@ -1038,9 +1038,9 @@ import_window :: () { using ImGui;
|
|||||||
import_source_changed();
|
import_source_changed();
|
||||||
}
|
}
|
||||||
SameLine();
|
SameLine();
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
if Button("Browse...") {
|
if Button("Browse...") {
|
||||||
chosen := windows_choose_folder("Choose a folder to import");
|
chosen := platform_choose_folder("Choose a folder to import");
|
||||||
if chosen {
|
if chosen {
|
||||||
count := min(chosen.count, importer.source_buffer.count - 1);
|
count := min(chosen.count, importer.source_buffer.count - 1);
|
||||||
memcpy(importer.source_buffer.data, chosen.data, count);
|
memcpy(importer.source_buffer.data, chosen.data, count);
|
||||||
|
|||||||
@ -53,7 +53,7 @@ labels_ui: struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
labels_models_folder :: () -> string {
|
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 {
|
labels_list_path :: () -> string {
|
||||||
@ -244,7 +244,7 @@ labels_ui_task :: (task: Ocr_Task) {
|
|||||||
engine, ok, error := labels_create(labels_models_folder(), LABELS_MODEL);
|
engine, ok, error := labels_create(labels_models_folder(), LABELS_MODEL);
|
||||||
free(labels_ui.engine_error);
|
free(labels_ui.engine_error);
|
||||||
labels_ui.engine_error = ifx ok then "" else copy_string(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);
|
else log_error("[Labels] Could not start %: %", LABELS_MODEL, error);
|
||||||
labels_ui.engine = engine;
|
labels_ui.engine = engine;
|
||||||
labels_ui.engine_tried = true;
|
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.");
|
TextWrapped("Run `jai fetch_models.jai` in modules/Labels, then rebuild.");
|
||||||
} else if labels_ui.engine {
|
} else if labels_ui.engine {
|
||||||
in_use, explanation := labels_gpu_status(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);
|
if explanation text_with_links(explanation);
|
||||||
} else if !labels_ui.engine_tried {
|
} 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);
|
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);
|
||||||
|
|||||||
@ -18,12 +18,7 @@ main :: () {
|
|||||||
app.headless = array_find(app.args, "-headless") || NO_CRASH_DIALOG;
|
app.headless = array_find(app.args, "-headless") || NO_CRASH_DIALOG;
|
||||||
runtime_support_headless = app.headless;
|
runtime_support_headless = app.headless;
|
||||||
app.allow_multiple_instances = array_find(app.args, "-allow-multiple-instances");
|
app.allow_multiple_instances = array_find(app.args, "-allow-multiple-instances");
|
||||||
for app.args if it == "-library" && it_index + 1 < app.args.count {
|
for app.args if it == "-library" && it_index + 1 < app.args.count app.library_folder = library_folder_normalize(app.args[it_index + 1]);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
app.scan_and_exit = array_find(app.args, "-scan");
|
app.scan_and_exit = array_find(app.args, "-scan");
|
||||||
app.segment_and_exit = array_find(app.args, "-segment");
|
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);
|
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
|
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);
|
logger_set_event_log_capacity(app_user_settings.event_log_capacity);
|
||||||
|
library_folder_choose();
|
||||||
|
log("Library: %", app.library_folder);
|
||||||
|
|
||||||
// #Startup:CreateWindow
|
// #Startup:CreateWindow
|
||||||
#if USING_SDL {
|
#if USING_SDL {
|
||||||
|
|||||||
@ -106,7 +106,7 @@ Scan_Item :: struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ocr_models_folder :: () -> string {
|
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
|
// ---------------------------------------------------------------- startup and shutdown
|
||||||
@ -283,7 +283,7 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
|
|||||||
ocr_ui.engine = engine;
|
ocr_ui.engine = engine;
|
||||||
free(ocr_ui.engine_error);
|
free(ocr_ui.engine_error);
|
||||||
ocr_ui.engine_error = ifx ok then "" else copy_string(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);
|
else log_error("[OCR] Could not start %: %", task.model, error);
|
||||||
ocr_ui.engine_tried = true;
|
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);
|
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);
|
if label_error for label_of if !errors[it] errors[it] = sprint("labelling failed: %", label_error);
|
||||||
dims := labels.dimensions;
|
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 {
|
for n: 0..items.count - 1 {
|
||||||
if errors[n] continue;
|
if errors[n] continue;
|
||||||
vector: [] float;
|
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);
|
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 {
|
for n: 0..items.count - 1 {
|
||||||
if errors[n] continue;
|
if errors[n] continue;
|
||||||
section: Mcap_Ocr_Section;
|
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)));
|
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);
|
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 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 scan.paused {
|
||||||
if Button("Resume") { scan.paused_seconds += to_float64_seconds(current_time_monotonic() - scan.pause_start); atomic_swap(*scan.paused, false); }
|
if Button("Resume") { scan.paused_seconds += to_float64_seconds(current_time_monotonic() - scan.pause_start); atomic_swap(*scan.paused, false); }
|
||||||
} else {
|
} else {
|
||||||
@ -811,7 +811,7 @@ ocr_ui_frame :: () { using ImGui;
|
|||||||
ocr_scan_ui();
|
ocr_scan_ui();
|
||||||
} else {
|
} else {
|
||||||
in_use, explanation := ocr_gpu_status(ocr_ui.engine);
|
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();
|
SameLine();
|
||||||
TextColored(app_theme.palette.dim, "onnxruntime %, % worker threads", ort.version, ocr_ui.engine.workers);
|
TextColored(app_theme.palette.dim, "onnxruntime %, % worker threads", ort.version, ocr_ui.engine.workers);
|
||||||
Separator();
|
Separator();
|
||||||
|
|||||||
@ -183,7 +183,7 @@ open_file_list_folders :: () {
|
|||||||
array_reset_keeping_memory(*open_file.folders);
|
array_reset_keeping_memory(*open_file.folders);
|
||||||
visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) {
|
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 !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));
|
array_add(folders, copy_string(info.short_name,, context.default_allocator));
|
||||||
}, visit_files = false, visit_directories = true);
|
}, visit_files = false, visit_directories = true);
|
||||||
quick_sort(open_file.folders, compare);
|
quick_sort(open_file.folders, compare);
|
||||||
@ -254,7 +254,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .VIDEO;
|
case .VIDEO;
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
image, video, ok, poster_error := video_poster(path);
|
image, video, ok, poster_error := video_poster(path);
|
||||||
if !ok { error = poster_error; }
|
if !ok { error = poster_error; }
|
||||||
else {
|
else {
|
||||||
@ -265,7 +265,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
|
|||||||
}
|
}
|
||||||
to_hash = .{path = path, offset = 0, size = open_file.size};
|
to_hash = .{path = path, offset = 0, size = open_file.size};
|
||||||
} else {
|
} else {
|
||||||
error = "videos are read on Windows only";
|
error = "videos are read on Windows and macOS only";
|
||||||
}
|
}
|
||||||
|
|
||||||
case;
|
case;
|
||||||
@ -359,7 +359,7 @@ open_file_copy_path :: () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
open_file_copy_image :: () {
|
open_file_copy_image :: () {
|
||||||
#if OS != .WINDOWS {
|
#if OS == .LINUX {
|
||||||
toast_show(.Error, "Copying images is not available on this system yet");
|
toast_show(.Error, "Copying images is not available on this system yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -492,7 +492,7 @@ open_file_library_ui :: (stage: Open_File_Stage) { using ImGui;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); 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 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));
|
if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total));
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Screen capture, as Snagit does it: PrintScreen (the capture_region global hotkey, from any app,
|
// Screen capture, as Snagit does it: PrintScreen on Windows, Cmd+Shift+2 on macOS (the capture_region
|
||||||
// even with mcap hidden in the tray) freezes every screen, and a rectangle dragged over them is taken
|
// global hotkey, from any app, even with mcap hidden in the tray) freezes every screen, and a rectangle
|
||||||
// (app/screen_capture_win32.jai; Windows only so far). What is taken goes onto the clipboard at once,
|
// 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
|
// 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,
|
// 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
|
// 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, "";
|
return path, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
#if OS != .WINDOWS {
|
#if OS == .LINUX {
|
||||||
Screen_Shot :: struct {
|
Screen_Shot :: struct {
|
||||||
pixels: [] u8;
|
pixels: [] u8;
|
||||||
width, height: s32;
|
width, height: s32;
|
||||||
|
|||||||
827
src/app/screen_capture_macos.jai
Normal file
827
src/app/screen_capture_macos.jai
Normal 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";
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// Screen recording (video-capture-plan.html): Ctrl+PrintScreen (the record_region global hotkey, from
|
// Screen recording (video-capture-plan.html, macos-plan phase 7): Ctrl+PrintScreen on Windows,
|
||||||
// any app, even with mcap hidden in the tray) freezes the screens as PrintScreen does, in the
|
// Opt+Cmd+Shift+2 on macOS (the record_region global hotkey, from any app, even with mcap hidden in the
|
||||||
// overlay's video mode (app/screen_capture_win32.jai): a region of one screen, at least 240 x 180, or
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// [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
|
// 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
|
// 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.
|
// 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
|
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
|
shot: Screen_Shot; // the region and the window it is of; no pixels
|
||||||
date: string; // when it started, as CaptureDate; heap
|
date: string; // when it started, as CaptureDate; heap
|
||||||
path: string; // the MP4 being written; 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.
|
// 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.
|
// Every frame (from screen_capture_frame): a recording that stopped by itself is finished and kept.
|
||||||
screen_record_frame :: () {
|
screen_record_frame :: () {
|
||||||
if app.record_and_exit > 0 screen_record_flag_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 !screen_record.recording return;
|
||||||
|
#if OS == .MACOS record_ui_tick();
|
||||||
failed, why := record_failed(*screen_record.recorder);
|
failed, why := record_failed(*screen_record.recorder);
|
||||||
if failed {
|
if failed {
|
||||||
toast_show(.Error, "The recording stopped: %", why);
|
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).
|
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
|
||||||
screen_record_startup :: () {
|
screen_record_startup :: () {
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
screen_record.leftovers.allocator = context.default_allocator;
|
screen_record.leftovers.allocator = context.default_allocator;
|
||||||
for file_list(screen_record_folder(),, temp) {
|
for file_list(screen_record_folder(),, temp) {
|
||||||
name := path_filename(it);
|
name := path_filename(it);
|
||||||
@ -190,22 +193,23 @@ screen_record_leftover_date :: (path: string) -> string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
screen_record_folder :: () -> string {
|
screen_record_folder :: () -> string {
|
||||||
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
|
return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
|
||||||
else return tprint("/tmp/%", APP_FOLDER_NAME);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
|
||||||
// immediate: the screen under the cursor, without the overlay (-record).
|
// immediate: the screen under the cursor, without the overlay (-record).
|
||||||
screen_record_start :: (immediate := false) {
|
screen_record_start :: (immediate := false) {
|
||||||
if app_exit_shutdown_started() return;
|
if app_exit_shutdown_started() return;
|
||||||
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||||
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
||||||
|
#if OS == .WINDOWS {
|
||||||
r := app.record_region;
|
r := app.record_region;
|
||||||
if ok && immediate && r.right > r.left { // -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.left, shot.top, shot.width, shot.height = r.left, r.top, r.right - r.left, r.bottom - r.top;
|
||||||
shot.whole_screen = false;
|
shot.whole_screen = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
||||||
else log("[record] Cancelled.");
|
else log("[record] Cancelled.");
|
||||||
@ -221,18 +225,28 @@ screen_record_start :: (immediate := false) {
|
|||||||
|
|
||||||
options: Record_Options;
|
options: Record_Options;
|
||||||
options.path = path;
|
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.fps = cast(s32) app_user_settings.video_fps;
|
||||||
options.cursor = app_user_settings.video_cursor;
|
options.cursor = app_user_settings.video_cursor;
|
||||||
options.system_audio = app_user_settings.video_system_audio;
|
options.system_audio = app_user_settings.video_system_audio;
|
||||||
options.microphone = app_user_settings.video_microphone;
|
options.microphone = app_user_settings.video_microphone;
|
||||||
options.audio_track = true; // so the toolbar can turn a source on mid-recording
|
options.audio_track = true; // so the toolbar can turn a source on mid-recording
|
||||||
|
#if OS == .MACOS {
|
||||||
|
// The display's region, and the border and toolbar first: ScreenCaptureKit leaves them out of the
|
||||||
|
// recording by their window numbers.
|
||||||
|
options.display_id = shot.display_id;
|
||||||
|
options.x, options.y, options.w, options.h = shot.local_x, shot.local_y, shot.local_width, shot.local_height;
|
||||||
|
options.width, options.height = shot.width, shot.height;
|
||||||
|
options.exclude_windows = record_ui_open(shot, shot.width & cast(s32) ~1, shot.height & cast(s32) ~1);
|
||||||
|
} else {
|
||||||
|
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
|
||||||
if app_user_settings.video_capture == {
|
if app_user_settings.video_capture == {
|
||||||
case "dxgi"; options.capture = .DXGI;
|
case "dxgi"; options.capture = .DXGI;
|
||||||
case "wgc"; options.capture = .WGC;
|
case "wgc"; options.capture = .WGC;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
started, start_error := record_start(*screen_record.recorder, options);
|
started, start_error := record_start(*screen_record.recorder, options);
|
||||||
if !started {
|
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);
|
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);
|
toast_show(.Error, "Could not record: %", start_error);
|
||||||
screen_shot_free(*shot);
|
screen_shot_free(*shot);
|
||||||
@ -254,7 +268,7 @@ screen_record_start :: (immediate := false) {
|
|||||||
} else {
|
} else {
|
||||||
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
|
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.
|
// 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) {
|
screen_record_start :: (immediate := false) {
|
||||||
toast_show(.Error, "Screen recording is not available on this system yet");
|
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_stop :: (shutdown := false) {}
|
||||||
|
|
||||||
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
365
src/app/screen_record_macos.jai
Normal file
365
src/app/screen_record_macos.jai
Normal 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";
|
||||||
@ -114,7 +114,7 @@ segment_colour_set :: (class: string, rgb: [3] u8, default := false, save := tru
|
|||||||
}
|
}
|
||||||
|
|
||||||
segment_models_folder :: () -> string {
|
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
|
// ---------------------------------------------------------------- startup and shutdown
|
||||||
@ -198,7 +198,7 @@ segment_ui_scan_task :: () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
segment_ui.engine = engine;
|
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;
|
engine := segment_ui.engine;
|
||||||
// Cancelling (or closing the app) stops it between batches of the engine's, rather than waiting the
|
// 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.
|
// The model.
|
||||||
model_file := tprint("%/%.onnx", segment_models_folder(), SEGMENT_MODEL);
|
model_file := tprint("%/%.onnx", segment_models_folder(), SEGMENT_MODEL);
|
||||||
if !busy && segment_ui.engine {
|
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);
|
if segment_ui.engine.gpu_explanation TextColored(app_theme.palette.dim, "%", segment_ui.engine.gpu_explanation);
|
||||||
} else if !busy && segment_ui.engine_error {
|
} else if !busy && segment_ui.engine_error {
|
||||||
TextColored(app_theme.palette.warning, "% could not start: %", SEGMENT_MODEL, segment_ui.engine_error);
|
TextColored(app_theme.palette.warning, "% could not start: %", SEGMENT_MODEL, segment_ui.engine_error);
|
||||||
|
|||||||
@ -16,10 +16,43 @@
|
|||||||
// appends the new ones. The library is watched (viewer_watch_frame), so files added or deleted
|
// 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.
|
// 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 {
|
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_WORKER_COUNT :: 4;
|
||||||
VIEWER_MAX_TEXTURES :: 1200; // previews on the GPU at once, about 200 KB each
|
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
|
// 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 {
|
viewer_is_library_path :: (path: string) -> bool {
|
||||||
folder := viewer_folder();
|
folder := viewer_folder();
|
||||||
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
|
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
|
||||||
relative := advance(path, folder.count + 1);
|
relative := advance(path, folder.count + 1);
|
||||||
if begins_with(relative, ".") || contains(relative, "/.") return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -804,13 +837,13 @@ viewer_mark_removed :: (index: s32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Viewer_Delete_Mode :: enum u8 {
|
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
|
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
|
// 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
|
// (Windows asks first when the drive has none and it would be deleted for good), the Trash on macOS,
|
||||||
// deleted folder beside it. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
|
// and on Linux into a deleted folder in the library. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
|
||||||
// is still there.
|
// is still there.
|
||||||
viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string {
|
viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string {
|
||||||
capture := *viewer.captures[index];
|
capture := *viewer.captures[index];
|
||||||
@ -827,6 +860,9 @@ viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, err
|
|||||||
result := SHFileOperationW(*operation);
|
result := SHFileOperationW(*operation);
|
||||||
if operation.fAnyOperationsAborted return false, "cancelled";
|
if operation.fAnyOperationsAborted return false, "cancelled";
|
||||||
if result != 0 return false, tprint("the shell could not delete it (error 0x%)", formatInt(result, base = 16));
|
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 {
|
} else {
|
||||||
folder := tprint("%/deleted", viewer_folder());
|
folder := tprint("%/deleted", viewer_folder());
|
||||||
make_directory_if_it_does_not_exist(folder);
|
make_directory_if_it_does_not_exist(folder);
|
||||||
@ -888,6 +924,7 @@ viewer_delete_now :: (index: s32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
|
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
|
||||||
|
else #if OS == .MACOS VIEWER_TRASH_NAME :: "Trash";
|
||||||
else VIEWER_TRASH_NAME :: "deleted folder";
|
else VIEWER_TRASH_NAME :: "deleted folder";
|
||||||
|
|
||||||
// The confirmation for viewer.delete_index, as a modal popup.
|
// The confirmation for viewer.delete_index, as a modal popup.
|
||||||
@ -1126,7 +1163,7 @@ viewer_ui :: () { using ImGui;
|
|||||||
if IsItemHovered() {
|
if IsItemHovered() {
|
||||||
engine: *Ocr_Engine;
|
engine: *Ocr_Engine;
|
||||||
if !ocr_ui.thread_busy engine = ocr_ui.engine; // the background thread owns it while busy
|
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");
|
else SetTooltip("OCR is not loaded, is loading, or could not start; the OCR window says which");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1241,9 +1278,7 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
|
|||||||
exporting := viewer.open_folder.count > 0;
|
exporting := viewer.open_folder.count > 0;
|
||||||
folder := viewer.open_folder;
|
folder := viewer.open_folder;
|
||||||
if !exporting {
|
if !exporting {
|
||||||
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%");
|
folder = tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
|
||||||
else base := "/tmp";
|
|
||||||
folder = tprint("%/%", base, APP_FOLDER_NAME);
|
|
||||||
}
|
}
|
||||||
make_directory_if_it_does_not_exist(folder, recursive = true);
|
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; }
|
if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; }
|
||||||
@ -1366,10 +1401,10 @@ viewer_export_popup :: () { using ImGui;
|
|||||||
if custom {
|
if custom {
|
||||||
SetNextItemWidth(GetFontSize() * 30);
|
SetNextItemWidth(GetFontSize() * 30);
|
||||||
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
|
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
SameLine();
|
SameLine();
|
||||||
if Button("Browse...") {
|
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);
|
if chosen viewer_export_set_buffer(chosen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1431,7 +1466,7 @@ viewer_copy_image :: () {
|
|||||||
index := viewer_selected();
|
index := viewer_selected();
|
||||||
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
|
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
|
||||||
capture := *viewer.captures[index];
|
capture := *viewer.captures[index];
|
||||||
#if OS != .WINDOWS {
|
#if OS == .LINUX {
|
||||||
toast_show(.Error, "Copying images is not available on this system yet");
|
toast_show(.Error, "Copying images is not available on this system yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,13 @@ platform_get_path_config_data :: inline () -> string {
|
|||||||
return XDG.get_path(.CONFIG_HOME);
|
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.
|
// A tag such as ja-JP from the locale variables, or "". Temp.
|
||||||
platform_preferred_language :: () -> string {
|
platform_preferred_language :: () -> string {
|
||||||
for string.["LC_ALL", "LC_MESSAGES", "LANG"] {
|
for string.["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||||
|
|||||||
@ -70,6 +70,93 @@ platform_cjk_font_candidates :: () -> [] Cjk_Font {
|
|||||||
return list;
|
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
|
Mach :: #import "macOS"; // task_info
|
||||||
|
|
||||||
#import "Objective_C";
|
#import "Objective_C";
|
||||||
|
|||||||
@ -846,6 +846,13 @@ os_log_error :: () {
|
|||||||
error_code := GetLastError();
|
error_code := GetLastError();
|
||||||
log_error(" > GetLastError code: %, %", error_code, get_error_string(error_code));
|
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
|
// Asks for a folder with Explorer's own picker, over the app's window: its path (temp), or "" when
|
||||||
// none was chosen.
|
// none was chosen.
|
||||||
windows_choose_folder :: (title: string) -> string {
|
windows_choose_folder :: (title: string) -> string {
|
||||||
|
|||||||
@ -152,6 +152,39 @@ exif_tests :: () {
|
|||||||
expect(exif_read(cast([] u8) "\xFF\xD8\xFF\xDA").orientation == 1, "a JPEG with none is upright");
|
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 :: () {
|
orientation_tests :: () {
|
||||||
group("orientation");
|
group("orientation");
|
||||||
// 3 x 2, pixels numbered by where they are: 0 1 2 / 3 4 5. Each orientation's result, row by row.
|
// 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);
|
push_allocator(context.default_allocator);
|
||||||
kind_tests();
|
kind_tests();
|
||||||
exif_tests();
|
exif_tests();
|
||||||
|
heif_tests();
|
||||||
orientation_tests();
|
orientation_tests();
|
||||||
date_tests();
|
date_tests();
|
||||||
folder_tests();
|
folder_tests();
|
||||||
|
|||||||
@ -83,7 +83,34 @@ labels_tests :: () {
|
|||||||
expect(created, "the label engine starts: %", error);
|
expect(created, "the label engine starts: %", error);
|
||||||
if !created return;
|
if !created return;
|
||||||
defer labels_destroy(engine);
|
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");
|
group("tokenizer");
|
||||||
for KNOWN {
|
for KNOWN {
|
||||||
|
|||||||
@ -169,7 +169,7 @@ turn_tests :: () {
|
|||||||
if !created return;
|
if !created return;
|
||||||
defer ocr_destroy(engine);
|
defer ocr_destroy(engine);
|
||||||
expect(engine.orientation.session != null, "% has an orientation classifier", OCR_DEFAULT_MODEL);
|
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();
|
level, drawn := drawn_lines();
|
||||||
expect(drawn, "the test text is drawn");
|
expect(drawn, "the test text is drawn");
|
||||||
|
|||||||
@ -349,8 +349,8 @@ GLOBAL_HOTKEY_ACTION_COUNT :: 3; // sizes the platforms' registration a
|
|||||||
|
|
||||||
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
|
global_hotkey_actions_build :: (out: *[..] Hotkey_Action) {
|
||||||
array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false});
|
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, .{"capture_region", *capture_region_hotkey, OS == .LINUX}); // app/screen_capture.jai: Windows and macOS
|
||||||
array_add(out, .{"record_region", *record_region_hotkey, true}); // app/screen_record.jai
|
array_add(out, .{"record_region", *record_region_hotkey, OS == .LINUX}); // app/screen_record.jai: Windows and macOS
|
||||||
}
|
}
|
||||||
|
|
||||||
global_hotkey_actions: [..] Hotkey_Action;
|
global_hotkey_actions: [..] Hotkey_Action;
|
||||||
|
|||||||
@ -296,6 +296,9 @@ App_User_Settings :: struct {
|
|||||||
// [export] folder, one line each: the folders Export offers for a capture's original, the first
|
// [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.
|
// the default. None means the Downloads folder. Heap-allocated.
|
||||||
export_folders: [..] string;
|
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.
|
// Of the monitor the window first opens on.
|
||||||
window_width_fraction: float = 0.80;
|
window_width_fraction: float = 0.80;
|
||||||
@ -328,6 +331,7 @@ settings_free :: (s: *App_User_Settings) {
|
|||||||
free(s.ocr_model,, context.default_allocator);
|
free(s.ocr_model,, context.default_allocator);
|
||||||
for s.export_folders free(it,, context.default_allocator);
|
for s.export_folders free(it,, context.default_allocator);
|
||||||
array_free(s.export_folders);
|
array_free(s.export_folders);
|
||||||
|
free(s.library_folder,, context.default_allocator);
|
||||||
free(s.video_capture,, context.default_allocator);
|
free(s.video_capture,, context.default_allocator);
|
||||||
array_free(s.hotkey_bindings);
|
array_free(s.hotkey_bindings);
|
||||||
array_free(s.global_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 hotkeys_missing.count || global_missing.count return true;
|
||||||
|
|
||||||
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
|
#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 {
|
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;
|
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
|
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));
|
if folder.count array_add(*out.export_folders, copy_string(folder,, context.default_allocator));
|
||||||
seen.export_folder = true;
|
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" {
|
} else if section == "[ui]" && key == "theme" {
|
||||||
free(out.theme,, context.default_allocator);
|
free(out.theme,, context.default_allocator);
|
||||||
out.theme = copy_string(settings_unquote(value),, 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.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.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.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_fps app_user_settings.video_fps = on_disk.video_fps;
|
||||||
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
|
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.
|
// 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));
|
for export_folders print_to_builder(*builder, "folder: %\n", settings_quote_if_needed(it));
|
||||||
if !export_folders append(*builder, "folder:\n");
|
if !export_folders append(*builder, "folder:\n");
|
||||||
append(*builder, "\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, "[video]\n");
|
||||||
append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\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: auto (Desktop Duplication, else\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, "# 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");
|
append(*builder, "# default input device. Each true or false.\n");
|
||||||
print_to_builder(*builder, "fps: %\n", app_user_settings.video_fps);
|
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.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.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();
|
bindings: [] Hotkey_Binding = hotkey_defaults();
|
||||||
if seen.hotkeys bindings = app_user_settings.hotkey_bindings;
|
if seen.hotkeys bindings = app_user_settings.hotkey_bindings;
|
||||||
@ -881,7 +901,8 @@ settings_watch: struct {
|
|||||||
|
|
||||||
app_settings_watch_start :: () {
|
app_settings_watch_start :: () {
|
||||||
dir := app_program_data_dir();
|
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);
|
log_error("[settings] Could not watch %. Changes to % apply at the next start.", dir, APP_USER_SETTINGS_FILE_NAME);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
173
tools/export_mask2former_coreml.py
Normal file
173
tools/export_mask2former_coreml.py
Normal 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()
|
||||||
207
tools/export_ppocr_coreml.py
Normal file
207
tools/export_ppocr_coreml.py
Normal 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()
|
||||||
115
tools/export_siglip_coreml.py
Normal file
115
tools/export_siglip_coreml.py
Normal 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()
|
||||||
@ -66,7 +66,7 @@ main :: () {
|
|||||||
start := current_time_monotonic();
|
start := current_time_monotonic();
|
||||||
image, decoded, error := image_decode(bytes, kind);
|
image, decoded, error := image_decode(bytes, kind);
|
||||||
if !decoded { print(" cannot decode: %\n", error); failures += 1; continue; }
|
if !decoded { print(" cannot decode: %\n", error); failures += 1; continue; }
|
||||||
print(" decoded: % x % in % ms (%)\n", image.width, image.height, FF(to_float64_seconds(current_time_monotonic() - start) * 1000, 1), ifx image.from_stb then "stb_image" else "WIC or turned");
|
print(" decoded: % x % in % ms (%)\n", image.width, image.height, FF(to_float64_seconds(current_time_monotonic() - start) * 1000, 1), ifx image.from_stb then "stb_image" else "WIC, ImageIO or turned");
|
||||||
if out_folder {
|
if out_folder {
|
||||||
name := path_strip_extension(path_filename(path));
|
name := path_strip_extension(path_filename(path));
|
||||||
full := stbi_write_png(temp_c_string(tprint("%/%.png", out_folder, name)), image.width, image.height, 4, image.pixels, image.width * 4);
|
full := stbi_write_png(temp_c_string(tprint("%/%.png", out_folder, name)), image.width, image.height, 4, image.pixels, image.width * 4);
|
||||||
|
|||||||
@ -33,7 +33,7 @@ main :: () {
|
|||||||
engine, ok, error := segment_create(models_folder, model_name, prefer_gpu);
|
engine, ok, error := segment_create(models_folder, model_name, prefer_gpu);
|
||||||
if !ok { print("Cannot start %: %\n", model_name, error); exit(1); }
|
if !ok { print("Cannot start %: %\n", model_name, error); exit(1); }
|
||||||
defer segment_destroy(engine);
|
defer segment_destroy(engine);
|
||||||
print("% on %, % classes (made in % ms)%\n", model_name, engine.provider, engine.classes.count, ms(start),
|
print("% on %, % classes (made in % ms)%\n", model_name, segment_provider_name(engine), engine.classes.count, ms(start),
|
||||||
ifx engine.gpu_explanation then tprint(": %", engine.gpu_explanation) else "");
|
ifx engine.gpu_explanation then tprint(": %", engine.gpu_explanation) else "");
|
||||||
|
|
||||||
failures := 0;
|
failures := 0;
|
||||||
@ -129,7 +129,7 @@ main :: () {
|
|||||||
|
|
||||||
print("\n% images: decoding % ms each, segmenting % ms each (model and post-processing, batch %)\n", done,
|
print("\n% images: decoding % ms each, segmenting % ms each (model and post-processing, batch %)\n", done,
|
||||||
formatFloat(decode_ms / max(done, 1), trailing_width = 0), formatFloat(model_ms / max(done, 1), trailing_width = 0),
|
formatFloat(decode_ms / max(done, 1), trailing_width = 0), formatFloat(model_ms / max(done, 1), trailing_width = 0),
|
||||||
ifx engine.provider == .CUDA then engine.model.batch_gpu else engine.model.batch_cpu);
|
segment_batch(engine));
|
||||||
per := max(engine.stats.images, 1) * 1000.0;
|
per := max(engine.stats.images, 1) * 1000.0;
|
||||||
print(" of which: preparing % ms, the model % ms, making segments % ms, per image\n", formatFloat(engine.stats.prepare_us / per, trailing_width = 1),
|
print(" of which: preparing % ms, the model % ms, making segments % ms, per image\n", formatFloat(engine.stats.prepare_us / per, trailing_width = 1),
|
||||||
formatFloat(engine.stats.model_us / per, trailing_width = 1), formatFloat(engine.stats.post_us / per, trailing_width = 1));
|
formatFloat(engine.stats.model_us / per, trailing_width = 1), formatFloat(engine.stats.post_us / per, trailing_width = 1));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user