Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33163be488 | |||
| f4595a6fdc | |||
| e66734cfad | |||
| 35b7290e3b | |||
| 5dd62ac201 | |||
| 74b273f0e0 | |||
| 63a0cdb64e | |||
| 91f6cf768f | |||
| a1ab9f3a96 | |||
| 30cd00b6f2 | |||
| 2d0fb855b1 | |||
| 3e353c77d8 | |||
| a66928b223 | |||
| 5571c21d0b | |||
| 12d0f30fc4 | |||
| 8c8b2c6e38 | |||
| c351ca92c0 | |||
| c5ef13f146 | |||
| 7cee21122c | |||
| e12622700d | |||
| c4203cc8cf | |||
| 120eba1e23 | |||
| a88d5da4dd | |||
| be9ad707c4 | |||
| 2bd77e8121 | |||
| 03f23f7122 | |||
| 22cad98500 | |||
| 9ac9a0a4e9 | |||
| 157888d6d8 | |||
| e7229343c7 | |||
| fd21e46a14 | |||
| 693974bedd | |||
| 4acde4d7a6 | |||
| 3f5d1ea124 | |||
| 72f2754226 | |||
| e0eaf5642b | |||
| 59c5b8dd62 | |||
| 93274a2b04 | |||
| 9953ba6f1f | |||
| 06a2aacc9c | |||
| 5758f0a071 | |||
| 7f8bcdd1ad | |||
| 864ead49c9 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
tools/gpu_bench/corpus/** binary
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -27,3 +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
|
||||||
|
/release/
|
||||||
|
|||||||
201
README.md
201
README.md
@ -9,7 +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.
|
(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. **PrintScreen**
|
||||||
|
on Windows, **Cmd+Shift+2** on macOS, captures a region of the screen straight into the library, as
|
||||||
|
Snagit does; see [Screen capture](#screen-capture).
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@ -26,11 +28,22 @@ AVIF, WebP, GIF, BMP, TIFF, camera RAW), videos (MP4, MOV, MKV, WebM, AVI, WMV,
|
|||||||
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**,
|
||||||
@ -53,10 +66,19 @@ AVIF, WebP, GIF, BMP, TIFF, camera RAW), videos (MP4, MOV, MKV, WebM, AVI, WMV,
|
|||||||
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.
|
||||||
- **Refresh** looks at the folder again, after files were added or deleted outside the app.
|
- **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then
|
||||||
|
**Ctrl-C** copies its image, as the Capture window shows it (annotations and all; a video's
|
||||||
|
frame), and **Ctrl-Shift-C** its full path. Neither acts while you type in the search box. They
|
||||||
|
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config` (**Cmd** for Ctrl on macOS).
|
||||||
|
Copying images works on Windows and macOS, not yet on Linux.
|
||||||
|
- **Ctrl-F** puts the keyboard in the search box, its text selected (`focus_search` in `[hotkeys]`).
|
||||||
|
- **Files added or deleted outside the app** (in Explorer, by another program) appear in the grid
|
||||||
|
or leave it on their own, folders added, renamed or deleted too: the library is watched.
|
||||||
|
**Refresh** looks at the whole folder again, should the watcher have missed something.
|
||||||
- **OCR**, **Stats** and **Labels** open their windows (below).
|
- **OCR**, **Stats** and **Labels** open their windows (below).
|
||||||
- A **thin bar** under the search box shows a running scan; hover it for the details.
|
- A **thin bar** under the search box shows a running scan; hover it for the details.
|
||||||
|
|
||||||
@ -89,7 +111,20 @@ grid (in its order, so through a search's matches).
|
|||||||
### The OCR window
|
### The OCR window
|
||||||
|
|
||||||
- **Model:** PP-OCRv5 mobile (light, about 85 ms a capture on the GPU) or server (more accurate,
|
- **Model:** PP-OCRv5 mobile (light, about 85 ms a capture on the GPU) or server (more accurate,
|
||||||
about 0.4 s). The choice is kept in the settings file (`model` under `[ocr]`).
|
about 0.3 s when it runs well; see below). The choice is kept in the settings file (`model` under
|
||||||
|
`[ocr]`).
|
||||||
|
- **GPU memory** (fixed in 0.1.24): OCR used to fill the GPU, 9 GB with the mobile model on
|
||||||
|
gpu-bench's 30 images and past 16 GB with the server model, which Windows then paged into shared
|
||||||
|
system memory, running it at 1.5 to 11 s a capture. onnxruntime's arena keeps every block it takes
|
||||||
|
from CUDA, and OCR's inputs change size every run, so each larger image or wider batch of lines left
|
||||||
|
another block behind. OCR now runs the largest images and widest batches first, has the detector's
|
||||||
|
and recognizer's arenas give back what they hold after each stage of a call, and keeps its 4 worker
|
||||||
|
threads for the engine's life. Since 0.1.35, on CUDA, the runs on the GPU at once are as many as fit in its
|
||||||
|
memory: each engine measures what its runs need when it starts, and each run waits while the runs in
|
||||||
|
flight and the GPU's free memory leave too little for it (`modules/OCR/budget.jai`). On an RTX 5080
|
||||||
|
laptop (16 GB), gpu-bench, 2026-10-17: the mobile model peaks at 5.7 GB and drops to 2 GB between
|
||||||
|
calls, at 140 ms a capture against 126 before; the server model peaks at 13.1 GB, at 335 ms a
|
||||||
|
capture against 10.9 s.
|
||||||
- **Running on:** the GPU or the CPU, and if the CPU, what to install for the GPU.
|
- **Running on:** the GPU or the CPU, and if the CPU, what to install for the GPU.
|
||||||
- **Scan the library** reads every capture the model has not read yet and, with **Labels too**,
|
- **Scan the library** reads every capture the model has not read yet and, with **Labels too**,
|
||||||
labels every capture not yet labelled with the current list. While a search is active the button
|
labels every capture not yet labelled with the current list. While a search is active the button
|
||||||
@ -148,11 +183,97 @@ counted (click one to search for it), and the commonest words (click one to sear
|
|||||||
of them are segmented, it also shows **What is in the images**: how many images hold each class
|
of them are segmented, it also shows **What is in the images**: how many images hold each class
|
||||||
(person, sky, dog ...). Click one to search `has:` for it.
|
(person, sky, dog ...). Click one to search `has:` for it.
|
||||||
|
|
||||||
|
## Screen capture
|
||||||
|
|
||||||
|
**PrintScreen** on Windows, **Cmd+Shift+2** on macOS, captures any part of the screen, from any app,
|
||||||
|
even while mcap is hidden in the tray or the menu bar:
|
||||||
|
|
||||||
|
1. **The screens freeze** the moment it is pressed: everything on every screen is copied and shown
|
||||||
|
darkened over them, so menus, tooltips and video hold still while you choose.
|
||||||
|
2. **Drag a rectangle** over what you want. It shows at full brightness, outlined. A magnifier beside
|
||||||
|
the cursor shows the pixels under it, with the cursor's position, or the selection's size while
|
||||||
|
dragging. The arrow keys move the cursor a pixel at a time, ten with Shift.
|
||||||
|
3. **Let go** to take it. A **click** without dragging, or **Enter**, takes the whole screen the
|
||||||
|
cursor is on. **Escape**, or a right click, cancels; a right click while dragging starts the
|
||||||
|
selection again.
|
||||||
|
|
||||||
|
What is taken is on the clipboard at once, ready to paste. It is also saved into the library, in
|
||||||
|
the folder **Screen Captures**, as a PNG in an `.mcap` with the window it was taken from (the window
|
||||||
|
under the middle of the selection): its title as the window title and its program as the app, so
|
||||||
|
searches find it as they find Snagit's. mcap then comes to the front showing it in the Capture
|
||||||
|
window, and OCR and labels read it once they are free (and segmentation, if the folder is set to
|
||||||
|
segment).
|
||||||
|
|
||||||
|
The key is the `capture_region` line of `[global_hotkeys]` in the settings file. Only one program can
|
||||||
|
have PrintScreen: if Snagit is running it has it, and so does Windows while Settings >
|
||||||
|
Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot
|
||||||
|
register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`.
|
||||||
|
|
||||||
|
On macOS (Cmd+Shift+3, 4 and 5 are the system's own screenshot keys):
|
||||||
|
|
||||||
|
- **Screen Recording** must be allowed for mcap (System Settings > Privacy & Security > Screen
|
||||||
|
Recording). The first capture asks for it and opens that pane; macOS may want mcap restarted after.
|
||||||
|
The permission belongs to the signed `mcap.app`, so start that, not the bare executable, which
|
||||||
|
would have it asked for on behalf of the terminal.
|
||||||
|
- Each screen freezes in a window of its own, and a selection stays on the screen it began on. What
|
||||||
|
is taken is that screen's own pixels, two to a point on a Retina screen, and the magnifier, the
|
||||||
|
position and the size are in them.
|
||||||
|
- The screens are copied with ScreenCaptureKit (`modules/Screen_Record/macos/capture.m`, which the
|
||||||
|
build compiles with clang).
|
||||||
|
|
||||||
|
### Recording the screen
|
||||||
|
|
||||||
|
**Ctrl+PrintScreen** (**Opt+Cmd+Shift+2** on macOS) records a region of one screen as a video:
|
||||||
|
|
||||||
|
1. **The screens freeze**, as for a still, with the outline and magnifier in red.
|
||||||
|
2. **Drag a region**, or click (or press Enter) for the whole screen under the cursor:
|
||||||
|
- The drag stays on the screen it began on: a recording is of one screen.
|
||||||
|
- A region smaller than **240 × 180** is outlined dashed, with the minimum in the label, and letting
|
||||||
|
go of it starts nothing; drag again.
|
||||||
|
- Escape or a right click cancels.
|
||||||
|
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
|
||||||
|
only when the screen changes, so a still screen costs almost nothing. **Sound:** what the
|
||||||
|
computer plays is recorded with it, and the microphone once you turn it on. The toolbar's
|
||||||
|
**System sound** and **Mic** buttons turn each on and off, mid-recording too, and their state is
|
||||||
|
kept for the next recording.
|
||||||
|
- **A red border** is drawn just outside the region.
|
||||||
|
- **A toolbar** beside it shows the time, the size and any frames dropped, with **System sound**
|
||||||
|
and **Mic** (each on or off), **Pause** (the time paused is left out of the video), **Stop**, and **Discard**, which asks
|
||||||
|
first.
|
||||||
|
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
|
||||||
|
4. **The same key again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
|
||||||
|
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
|
||||||
|
OCR and labels read a frame from it.
|
||||||
|
|
||||||
|
- **The screen** is taken with DXGI Desktop Duplication, or with Windows.Graphics.Capture where that
|
||||||
|
cannot run (a laptop with two GPUs, a rotated screen). On macOS, with a ScreenCaptureKit stream
|
||||||
|
(`modules/Screen_Record/macos/capture.m`), which also brings the sound.
|
||||||
|
- **Encoding** uses NVENC where there is an NVIDIA GPU, else AMD's or Intel's encoder, Media
|
||||||
|
Foundation, or libx264. On macOS, VideoToolbox.
|
||||||
|
- **Losing nothing:**
|
||||||
|
- A recording that stops by itself keeps what it has, and so does quitting while recording.
|
||||||
|
- When the screen is lost for a moment (a UAC prompt, the lock screen, a resolution change), the
|
||||||
|
recording carries on once it is back.
|
||||||
|
- A recording a crash cut short is offered at the next start: into the library, or deleted. It
|
||||||
|
keeps all but its last two seconds or so.
|
||||||
|
- **Settings:** `[video]` in the settings file:
|
||||||
|
- `fps` (30) and `cursor` (true).
|
||||||
|
- `capture` (Windows): `auto` (Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
|
||||||
|
- `system_audio` (true): what the computer plays, from the default output device. The toolbar's
|
||||||
|
System sound button sets it.
|
||||||
|
- `microphone` (false): the default input device. The toolbar's Mic button sets it.
|
||||||
|
- Sound is AAC at 160 kb/s. A device unplugged mid-recording is picked up again from the new default.
|
||||||
|
- On macOS the microphone needs macOS 15 or later, and its permission (asked for the first time the
|
||||||
|
Mic button turns it on).
|
||||||
|
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
|
||||||
|
the screens, which would show in the recording.
|
||||||
|
- **The key** is `record_region` in `[global_hotkeys]`.
|
||||||
|
|
||||||
## Importing
|
## Importing
|
||||||
|
|
||||||
**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
|
||||||
@ -168,19 +289,23 @@ 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
|
||||||
creation time, else the file's time; the phone and its location are kept too. The thumbnail shows a
|
creation time, else the file's time; the phone and its location are kept too. The thumbnail shows a
|
||||||
play mark and how long it runs; **Play in the default player** in the Capture window writes the
|
play mark and how long it runs; **Play in the default player** in the Capture window writes the
|
||||||
video out to the temporary folder and opens it.
|
video out to the temporary folder and opens it. **Export...** (any capture) writes the file as it
|
||||||
|
was imported to a folder it asks for: one of the `folder:` lines under `[export]` in the settings
|
||||||
|
file (the first is the default; none: the Downloads folder), or **Custom...**, which can be added to
|
||||||
|
them. A file of that name already there, and different, is not overwritten.
|
||||||
- **Snagit `.snagx`:** the same `.mcap` as `snagit-converter mcap` makes, byte for byte, named as
|
- **Snagit `.snagx`:** the same `.mcap` as `snagit-converter mcap` makes, byte for byte, named as
|
||||||
the `.snagx` is.
|
the `.snagx` is.
|
||||||
- **Duplicates:** an image already in the library, in any of its folders, is not stored again; the
|
- **Duplicates:** an image already in the library, in any of its folders, is not stored again; the
|
||||||
@ -193,6 +318,32 @@ What becomes of each file:
|
|||||||
|
|
||||||
The files imported from are only read: nothing there is changed, moved or deleted.
|
The files imported from are only read: nothing there is changed, moved or deleted.
|
||||||
|
|
||||||
|
## Opening a file
|
||||||
|
|
||||||
|
`mcap.exe FILE`, with FILE first, opens one file: an `.mcap`, or any image, video or `.snagx` that
|
||||||
|
Import takes. This is what Explorer passes to the program a file type opens with (Open with > mcap).
|
||||||
|
|
||||||
|
- **mcap not running:** it starts as usual and shows the file.
|
||||||
|
- **An `.mcap` in the library** is selected and opened in the Capture window.
|
||||||
|
- **Anything else** opens in the **Opened File** window, with its details. If the library has it
|
||||||
|
already, in any folder (found by its hash in `.import-index`), the window says where, with
|
||||||
|
**Show**. If not, **Import** brings it into the library folder typed or chosen from the menu
|
||||||
|
beside the name. That's the Import window's import, of this one file, so the same naming,
|
||||||
|
duplicate check and reading of text and labels apply. Once in, it opens in the Capture window.
|
||||||
|
- **An `.mcap` from outside the library** is copied in as it is, under its own name. (A folder's
|
||||||
|
import skips `.mcap` files.)
|
||||||
|
- **mcap already running:** a second window opens in **view mode**: the file alone, with Fit and
|
||||||
|
Zoom, and the same Import.
|
||||||
|
- **Show in mcap** (for a file the library has) and **Open in mcap** (for any other) hand the file to
|
||||||
|
the running mcap, which comes forward showing it, and close the view.
|
||||||
|
- **Ctrl-C** copies the image, and **Ctrl-Shift-C** the path.
|
||||||
|
- **Escape**, Ctrl-W or the close button ends it.
|
||||||
|
- **What's left out:** the library isn't loaded or watched, and there are no models, OCR, labels or
|
||||||
|
segmentation. There's no tray icon and no PrintScreen. It writes no settings or window layout.
|
||||||
|
- **After an import here:** the running mcap picks the capture up through the library watcher. OCR
|
||||||
|
and labels read it at the next scan, or right away with **Show in mcap** and **OCR** in the
|
||||||
|
Capture window.
|
||||||
|
|
||||||
## Searching
|
## Searching
|
||||||
|
|
||||||
Type in the search box above the grid; the grid shows the matches as you type, with "N of M
|
Type in the search box above the grid; the grid shows the matches as you type, with "N of M
|
||||||
@ -272,17 +423,24 @@ then in that order instead of by date.
|
|||||||
|
|
||||||
| Option | What it does |
|
| Option | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `-library FOLDER` | Shows the `.mcap` files in FOLDER (and its folders) instead of `D:\McapIndex\mcap`. |
|
| `FILE` (first) | Opens FILE: an `.mcap`, image or video; in view mode when mcap is already running. See [Opening a file](#opening-a-file). |
|
||||||
| `-import SOURCE DEST` | Imports the folder SOURCE into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed. |
|
| `-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 the library folder in `settings.config` (`[library] folder`). |
|
||||||
|
| `-import SOURCE DEST` | Imports SOURCE, a folder or a single file, into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed. |
|
||||||
| `-search QUERY` | Starts with QUERY in the search box. |
|
| `-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`. |
|
||||||
|
| `-no-catalog-cache` | Reads every capture from its file, as without the catalog cache (for measuring); the cache is still written. |
|
||||||
|
| `-catalog-threads N` | Loads the catalog on N threads instead of all the background group's (for measuring). |
|
||||||
| `-scan` | Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed. |
|
| `-scan` | Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed. |
|
||||||
| `-stats` | Logs the Stats window's numbers once the catalog has loaded, then exits; with `-search`, for the matches, and the first ten are logged. |
|
| `-stats` | Logs the Stats window's numbers once the catalog has loaded, then exits; with `-search`, for the matches, and the first ten are logged. |
|
||||||
|
| `-record SECONDS` | Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed. `-record-region X Y W H` records that region instead. `-record-quit` quits while still recording, to check the recording is kept. `-record-ui-visible` leaves the border and toolbar in screenshots, to look at them. |
|
||||||
| `-segment` | Segments the images not yet segmented in the library folders set to segment (see the Segmentation window), once the library has loaded, then exits: 0, or 1 if any image failed. |
|
| `-segment` | Segments the images not yet segmented in the library folders set to segment (see the Segmentation window), once the library has loaded, then exits: 0, or 1 if any image failed. |
|
||||||
| `-erase ocr`, `-erase labels`, `-erase segments`, `-erase all` | Erases that from every file in the library, then exits (for testing). |
|
| `-erase ocr`, `-erase labels`, `-erase segments`, `-erase all` | Erases that from every file in the library, then exits (for testing). |
|
||||||
| `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. |
|
| `-allow-multiple-instances` | Runs beside another copy instead of bringing that one forward. |
|
||||||
| `run_tests` | Runs the test suites (debug builds); with `-headless`, exits with 0 if all passed. |
|
| `run_tests` | Runs the test suites (debug builds); with `-headless`, exits with 0 if all passed. |
|
||||||
| `-headless` | No message boxes: a failure is logged and the app exits with 1. |
|
| `-headless` | No message boxes: a failure is logged and the app exits with 1. |
|
||||||
| `-version` | Prints the version. |
|
| `-version` | Prints the version. |
|
||||||
|
| `-h`, `--help` | Opens the Command Line window, this table in the app (also in the Debug Panel). With mcap already running, that one comes forward with it. |
|
||||||
|
|
||||||
`-import`, `-scan`, `-stats` and `-erase` still open the window, to show their progress; they are meant for
|
`-import`, `-scan`, `-stats` and `-erase` still open the window, to show their progress; they are meant for
|
||||||
scripts and for trying things on a copy of the library: `bin\mcap.exe -library D:\copy -scan`.
|
scripts and for trying things on a copy of the library: `bin\mcap.exe -library D:\copy -scan`.
|
||||||
@ -297,6 +455,7 @@ scripts and for trying things on a copy of the library: `bin\mcap.exe -library D
|
|||||||
| Settings (theme, hotkeys, OCR model) | `%APPDATA%\mcap\settings.config` |
|
| Settings (theme, hotkeys, OCR model) | `%APPDATA%\mcap\settings.config` |
|
||||||
| Logs | `logs\<version>\` beside the program |
|
| Logs | `logs\<version>\` beside the program |
|
||||||
| The hash of every capture's image, for finding duplicates | `.import-index` in the library folder |
|
| The hash of every capture's image, for finding duplicates | `.import-index` in the library folder |
|
||||||
|
| The catalog cache: every capture's catalog entry, so a start reads one file instead of every capture (an entry is used while its file's size and modified time are unchanged; deleting it is safe) | `.catalog-cache` in the library folder |
|
||||||
| The folder each library folder was imported from | `.import-sources` in the library folder |
|
| The folder each library folder was imported from | `.import-sources` in the library folder |
|
||||||
| Which library folders are segmented | `.library-folders` in the library folder |
|
| Which library folders are segmented | `.library-folders` in the library folder |
|
||||||
|
|
||||||
@ -324,10 +483,17 @@ 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.
|
||||||
|
- `modules/Screen_Record`: a region of one screen recorded to H.264 in an MP4, with DXGI Desktop
|
||||||
|
Duplication, or Windows.Graphics.Capture where duplication cannot run (two GPUs, a rotated screen),
|
||||||
|
and FFmpeg (NVENC, else AMF, QSV, Media Foundation or libx264), for screen recording
|
||||||
|
(`video-capture-plan.html`). `bin\record-probe` (`jai first.jai - record_probe`) records with it
|
||||||
|
and says how it went; `-list` shows the screens, `-capture auto|dxgi|wgc` picks the way, `-check
|
||||||
|
FILE` reads a recording back.
|
||||||
- `modules/Snagx`: `.snagx` to `.mcap`, copied from SnagitConverter.
|
- `modules/Snagx`: `.snagx` to `.mcap`, copied from SnagitConverter.
|
||||||
- `modules/Capture_File`: reading and writing `.mcap` files, with a video's original left on disk.
|
- `modules/Capture_File`: reading and writing `.mcap` files, with a video's original left on disk.
|
||||||
- `modules/Segment`: panoptic segmentation with Mask2Former, run with onnxruntime (see its README and
|
- `modules/Segment`: panoptic segmentation with Mask2Former, run with onnxruntime (see its README and
|
||||||
@ -383,10 +549,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),
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
[global_hotkeys]
|
[global_hotkeys]
|
||||||
# Registered with the system, so they work from any application, even while the app is hidden in the tray.
|
# Registered with the system, so they work from any application, even while the app is hidden in the tray.
|
||||||
# The format is that of [hotkeys], including "none" to turn an action off; keys are letters,
|
# The format is that of [hotkeys], including "none" to turn an action off; keys are letters,
|
||||||
# digits, Space, F1-F24, Enter, Tab, Escape, the arrows, PageUp, PageDown, Home, End, Insert and
|
# digits, Space, F1-F24, Enter, Tab, Escape, the arrows, PageUp, PageDown, Home, End, Insert,
|
||||||
# Delete. For example: Ctrl-Alt-Space bring_to_foreground
|
# Delete and PrintScreen. For example: Ctrl-Alt-Space bring_to_foreground
|
||||||
# Changes apply as soon as the file is saved.
|
# Changes apply as soon as the file is saved.
|
||||||
|
|
||||||
none bring_to_foreground
|
Ctrl-Shift-M bring_to_foreground
|
||||||
|
|
||||||
|
# Freezes every screen to drag a region over and capture it, into the clipboard and the library.
|
||||||
|
# PrintScreen is Snagit's key too: only one program can have it, so quit Snagit or choose another
|
||||||
|
# key, such as Ctrl-Shift-S. Windows takes it as well while Settings > Accessibility > Keyboard >
|
||||||
|
# "Use the Print screen key to open screen capture" is on.
|
||||||
|
PrintScreen capture_region
|
||||||
|
|
||||||
|
# Records a region of one screen (dragged in the same frozen view, at least 240 x 180, or a click
|
||||||
|
# for the whole screen) until pressed again; it goes into the library as a video.
|
||||||
|
Ctrl-PrintScreen record_region
|
||||||
|
|||||||
@ -5,3 +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
|
||||||
|
Cmd-Shift-2 capture_region
|
||||||
|
Opt-Cmd-Shift-2 record_region
|
||||||
|
|||||||
@ -18,6 +18,13 @@ Ctrl-Equal increase_font_size
|
|||||||
Ctrl-Alt-Space reset_window
|
Ctrl-Alt-Space reset_window
|
||||||
Ctrl-Shift-Alt-R reset_layout
|
Ctrl-Shift-Alt-R reset_layout
|
||||||
|
|
||||||
|
# Library: the selected capture
|
||||||
|
Ctrl-C copy_image
|
||||||
|
Ctrl-Shift-C copy_path
|
||||||
|
|
||||||
|
# Library
|
||||||
|
Ctrl-F focus_search
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
Ctrl-Shift-D toggle_debug_panel
|
Ctrl-Shift-D toggle_debug_panel
|
||||||
Ctrl-Shift-L toggle_logger_panel
|
Ctrl-Shift-L toggle_logger_panel
|
||||||
|
|||||||
@ -18,6 +18,13 @@ Cmd-Equal increase_font_size
|
|||||||
Cmd-Opt-Shift-Space reset_window
|
Cmd-Opt-Shift-Space reset_window
|
||||||
Cmd-Shift-Opt-R reset_layout
|
Cmd-Shift-Opt-R reset_layout
|
||||||
|
|
||||||
|
# Library: the selected capture
|
||||||
|
Cmd-C copy_image
|
||||||
|
Cmd-Shift-C copy_path
|
||||||
|
|
||||||
|
# Library
|
||||||
|
Cmd-F focus_search
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
Cmd-Shift-D toggle_debug_panel
|
Cmd-Shift-D toggle_debug_panel
|
||||||
Cmd-Shift-L toggle_logger_panel
|
Cmd-Shift-L toggle_logger_panel
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 11 KiB |
@ -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();
|
||||||
@ -20,6 +22,27 @@
|
|||||||
options.output_executable_name = "check";
|
options.output_executable_name = "check";
|
||||||
options.output_path = ".build";
|
options.output_path = ".build";
|
||||||
|
|
||||||
|
// rpmalloc (modules/Default_Allocator) also runs at compile time, with the target's OS layer,
|
||||||
|
// which crashes on another OS. A check for another OS uses Jai's own allocator instead, through a
|
||||||
|
// module of the same name ahead of modules/ (with the thread_idle the app calls, doing nothing).
|
||||||
|
if target != OS {
|
||||||
|
jai_modules := trim_right(options.import_path[options.import_path.count - 1], "/");
|
||||||
|
shim := tprint("%/.build/check_modules", get_working_directory());
|
||||||
|
make_directory_if_it_does_not_exist(tprint("%/Default_Allocator", shim), recursive = true);
|
||||||
|
write_entire_file(tprint("%/Default_Allocator/module.jai", shim), tprint(#string SHIM
|
||||||
|
// Written by check_targets.jai: Jai's own allocator, for type-checking another OS.
|
||||||
|
#load "%/Default_Allocator/module.jai";
|
||||||
|
thread_idle :: () {}
|
||||||
|
SHIM, jai_modules));
|
||||||
|
paths: [..] string;
|
||||||
|
array_add(*paths, shim);
|
||||||
|
array_add(*paths, ..options.import_path);
|
||||||
|
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);
|
||||||
@ -29,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.0";
|
VERSION :: "0.1.40";
|
||||||
BUILD_DATE :: "-";
|
BUILD_DATE :: "-";
|
||||||
DEBUG :: true;
|
DEBUG :: true;
|
||||||
COMPILE_TESTS :: true;
|
COMPILE_TESTS :: true;
|
||||||
@ -47,5 +70,32 @@ 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 "String";
|
||||||
|
|||||||
177
first.jai
177
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.0";
|
VERSION :: "0.1.40";
|
||||||
// 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.
|
||||||
@ -67,6 +68,12 @@ build_project :: () {
|
|||||||
if array_find(args, "image_probe") array_add(*pending, start_tool_build("image-probe", "tools/image_probe.jai", optimized = build_release));
|
if array_find(args, "image_probe") array_add(*pending, start_tool_build("image-probe", "tools/image_probe.jai", optimized = build_release));
|
||||||
// jai first.jai - segment_probe: also bin/segment-probe, what modules/Segment makes of image files.
|
// jai first.jai - segment_probe: also bin/segment-probe, what modules/Segment makes of image files.
|
||||||
if array_find(args, "segment_probe") array_add(*pending, start_tool_build("segment-probe", "tools/segment_probe.jai", optimized = build_release));
|
if array_find(args, "segment_probe") array_add(*pending, start_tool_build("segment-probe", "tools/segment_probe.jai", optimized = build_release));
|
||||||
|
// jai first.jai - record_probe: also bin/record-probe, a screen recorded with modules/Screen_Record (video-capture-plan.html, phase 1).
|
||||||
|
if array_find(args, "record_probe") array_add(*pending, start_tool_build("record-probe", "tools/record_probe.jai", optimized = build_release));
|
||||||
|
|
||||||
|
// bin/gpu-bench: every GPU model timed over a fixed corpus (tools/gpu_bench),
|
||||||
|
// always built, as the GPU Benchmark window runs it.
|
||||||
|
array_add(*pending, start_tool_build("gpu-bench", "tools/gpu_bench/gpu_bench.jai", optimized = build_release));
|
||||||
|
|
||||||
wait_for_builds(pending);
|
wait_for_builds(pending);
|
||||||
|
|
||||||
@ -123,6 +130,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));
|
||||||
@ -164,6 +172,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);
|
||||||
@ -192,52 +201,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
|
||||||
@ -255,7 +375,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
|
||||||
@ -284,13 +406,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.
|
||||||
@ -298,6 +424,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.");
|
||||||
}
|
}
|
||||||
@ -307,9 +434,15 @@ 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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gpu-bench's corpus, in bin/gpu-bench-corpus: in git, so every machine has the same images.
|
||||||
|
make_directory_if_it_does_not_exist(tprint("%/gpu-bench-corpus", build_directory));
|
||||||
|
copy_if_changed("tools/gpu_bench/corpus.txt", tprint("%/gpu-bench-corpus/corpus.txt", build_directory));
|
||||||
|
for file_list("tools/gpu_bench/corpus") copy_if_changed(it, tprint("%/gpu-bench-corpus/%", build_directory, path_filename(it)));
|
||||||
}
|
}
|
||||||
|
|
||||||
copy_if_changed :: (from: string, to: string) {
|
copy_if_changed :: (from: string, to: string) {
|
||||||
@ -326,7 +459,7 @@ add_build_constants :: (w: Workspace, optimized: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GIT_HASH :: #run -> string {
|
GIT_HASH :: #run -> string {
|
||||||
result, revision := run_command("git", "describe", "--always", "--dirty", capture_and_return_output = true);
|
result, revision := run_command("git", "describe", "--always", "--dirty", "--exclude=*", capture_and_return_output = true);
|
||||||
if revision return trim(revision);
|
if revision return trim(revision);
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@ then the original and rendered image, then any large chunks. There is no ZIP and
|
|||||||
it; the images and videos keep their own compression.
|
it; the images and videos keep their own compression.
|
||||||
|
|
||||||
A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image
|
A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image
|
||||||
format), or an imported video (the mcap app's Import window).
|
format), an imported video (the mcap app's Import window), or a screen capture or recording (the mcap app's
|
||||||
|
PrintScreen and Ctrl+PrintScreen).
|
||||||
|
|
||||||
The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
|
The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads
|
||||||
the start of each file and never touches the images; a search, which needs no preview, reads less still.
|
the start of each file and never touches the images; a search, which needs no preview, reads less still.
|
||||||
@ -94,6 +95,57 @@ Orientation: 6 and, when the EXIF has them: Camer
|
|||||||
|
|
||||||
So the parts a library reads from Snagit's `metadata.json` (date, title, app) work for photos too.
|
So the parts a library reads from Snagit's `metadata.json` (date, title, app) work for photos too.
|
||||||
|
|
||||||
|
## Screen captures
|
||||||
|
|
||||||
|
A region of the screen taken with the mcap app's PrintScreen is stored as a PNG original with no
|
||||||
|
rendered image (kind 0), and its Capture Text holds two files:
|
||||||
|
|
||||||
|
```
|
||||||
|
[[file "metadata.json"]]
|
||||||
|
AppName: "Google Chrome" the program of the window under the region's middle:
|
||||||
|
its description, else its file's name; "" when unknown
|
||||||
|
CaptureDate: "2026-10-10 14:05:09" when it was taken, local time, as Snagit writes it
|
||||||
|
WindowName: "Assign Footprints - KiCad" that window's title
|
||||||
|
|
||||||
|
[[file "capture.json"]]
|
||||||
|
Region: "selection" or "screen": a click took the whole screen
|
||||||
|
Left: -1920 on the virtual desktop, whose origin is the primary
|
||||||
|
Top: 40 screen's top left, so it can be negative
|
||||||
|
Width: 700
|
||||||
|
Height: 300
|
||||||
|
AppPath: "C:/Program Files/Google/Chrome/Application/chrome.exe"
|
||||||
|
ContentHash: "5c1e9a0d77b2f413" 64-bit FNV-1a of the PNG's bytes
|
||||||
|
Format: "PNG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screen recordings
|
||||||
|
|
||||||
|
A recording made with the mcap app's Ctrl+PrintScreen is a video: its original is the H.264 MP4, and
|
||||||
|
its rendered image (kind 2) a frame from it as a PNG, as for an imported video. It is recorded as a
|
||||||
|
fragmented MP4, so a crash still leaves one that plays, and stored as a regular one, its frames copied
|
||||||
|
as they are: one header first, with its length and the index of every frame. Its
|
||||||
|
metadata.json is a still's. Its capture.json has a still's keys, with Format "MP4", and these:
|
||||||
|
|
||||||
|
```
|
||||||
|
Duration: 12.48 seconds
|
||||||
|
FrameRate: 30 as the container says; frames are written only when the screen changes
|
||||||
|
VideoCodec: "h264"
|
||||||
|
PosterTime: 1.25 seconds into it of the rendered frame
|
||||||
|
Encoder: "h264_nvenc" FFmpeg's encoder: h264_nvenc, h264_amf, h264_qsv, h264_mf or libx264
|
||||||
|
Capture: "DXGI DuplicateOutput1 on NVIDIA GeForce RTX 5080 Laptop GPU"
|
||||||
|
or "Windows.Graphics.Capture on ..., border off"
|
||||||
|
DroppedFrames: 0 frames the encoder fell too far behind for
|
||||||
|
Paused: 10.2 seconds paused, left out of the video; only when it was paused
|
||||||
|
Audio: true whether it has sound (AAC)
|
||||||
|
AudioSources: "SAMSUNG (NVIDIA High Definition Audio); Microphone Array (Realtek(R) Audio)"
|
||||||
|
the devices heard; only when it has sound
|
||||||
|
```
|
||||||
|
|
||||||
|
A recording a crash cut short, put in the library at the next start, has no window, Encoder or
|
||||||
|
Capture: its Width and Height are its frames', and its CaptureDate is from its file's name.
|
||||||
|
|
||||||
|
A reader tells a recording from a still by VideoCodec, as it tells an imported video by its import.json.
|
||||||
|
|
||||||
## Imported videos
|
## Imported videos
|
||||||
|
|
||||||
A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is
|
A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is
|
||||||
|
|||||||
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";
|
||||||
148
modules/Default_Allocator/DIFFERENCES.md
Normal file
148
modules/Default_Allocator/DIFFERENCES.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# How this Default_Allocator differs from Jai's
|
||||||
|
|
||||||
|
This module replaces Jai's `modules/Default_Allocator` with the rpmalloc 2.0.1 port from `my_modules/rpmalloc` (installed with its `install.jai`). Jai finds the default allocator by module name, so the replacement is the directory that comes first on the import path. This document lists everything that behaves differently. Facts about Jai's module refer to Jai beta 0.2.030.
|
||||||
|
|
||||||
|
Everything here is tested by the port's suite, `my_modules/rpmalloc/tests`:
|
||||||
|
- `jai build.jai - swap` installs this module with `install.jai`, builds a program whose only allocator is the installed copy, runs that program, and type-checks it for macOS and Linux.
|
||||||
|
- The port's own suite (89 tests) covers the allocator code itself.
|
||||||
|
|
||||||
|
## 1. Which allocator you get
|
||||||
|
|
||||||
|
| Platform | Jai's Default_Allocator | This module |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows x64 | Stripped-down rpmalloc 1.4.4: one heap shared by every thread behind one lock, and no thread caches | rpmalloc 2.0.1 (port), one heap per thread (**tested**) |
|
||||||
|
| Linux x64 | The same as on Windows x64 | The same as on Windows (**type-checked only**; there is no Linux machine) |
|
||||||
|
| macOS ARM64 | A thin wrapper over the C runtime's `malloc` / `realloc` / `free` | rpmalloc 2.0.1 (port) (**tested**) |
|
||||||
|
| macOS x64, Linux ARM64 | Linux ARM64: C runtime wrapper; macOS x64: stripped-down rpmalloc 1.4.4 | Builds, but untested |
|
||||||
|
| PS5, Switch 2, iOS, Android, WASM, 32-bit | Their own branches (rpmalloc 1.4.4 on PS5 and Switch 2; the C runtime wrapper elsewhere) | **Not supported.** The module stops with an `#assert`. Take `my_modules` off the import path for these targets. |
|
||||||
|
|
||||||
|
The CAPS string tells you which allocator is in use: `"rpmalloc 2.0.1 (Jai port) as Default_Allocator"`. Jai's reports `"stripped-down rpmalloc 1.4.4 intended as Default_Allocator"`, or `"Jai wrapper for C’s malloc/free/realloc functions."`.
|
||||||
|
|
||||||
|
## 2. Exported interface
|
||||||
|
|
||||||
|
| Name | Jai's | This module |
|
||||||
|
|---|---|---|
|
||||||
|
| `allocator` | Yes (rpmalloc branch only) | Yes |
|
||||||
|
| `allocator_proc` | Yes | Yes, same signature |
|
||||||
|
| `unsafe_reset()` | rpmalloc branch: forgets all state and starts over. C runtime branch: does nothing. | Releases every lock and keeps all state (§6) |
|
||||||
|
| `initialize_if_needed() -> bool` | Yes | Yes |
|
||||||
|
| `is_initialized() -> bool` | Yes | Yes |
|
||||||
|
| `finalize()` | Yes | Yes (the port's `finalize`) |
|
||||||
|
| `usable_size(ptr) -> s64` | Yes | Yes |
|
||||||
|
| `is_this_yours(heap, ptr) -> bool` | Walks the heap's span lists. It misses older large blocks (finding F4/M2, test HEAP-05) and the blocks of exited threads (HEAP-10). With a null heap it looks only in the one default heap. | Looks the address up in a bitmap of live spans and never reads memory it does not own. With a null heap it checks every thread heap (not first-class heaps). |
|
||||||
|
| `heap_free_all(heap)` | Yes | Yes |
|
||||||
|
| `global_statistics()` | A placeholder struct of 7 fields, always zero | rpmalloc's `Global_Statistics` (9 fields). It is zero unless `ENABLE_STATISTICS`. The memory committed now is `active`; `committed` and `decommitted` are running totals that only grow, as upstream. The pointer form `global_statistics(*stats)` also exists. |
|
||||||
|
| `heap_statistics(heap)` | `{sizecache, spancache}` | `{allocated_size, committed_size, mapped_size}`, zero unless `HEAP_STATISTICS` |
|
||||||
|
| `allocator_default_heap` | A global `*Heap` | **Not provided.** There is no single default heap; each thread has its own. |
|
||||||
|
| The rest of the rpmalloc API | No | `initialize`, `thread_collect`, `trim`, `rpmalloc`, `rpaligned_alloc`, `heap_acquire`, and the rest (see `my_modules/rpmalloc/module.jai`) |
|
||||||
|
|
||||||
|
**Module parameters.** `ENABLE_ASSERTS` and `ENABLE_VALIDATE_ARGS` have the same names and defaults (both `true`). The port's other parameters are added: statistics, decommit, unmap, first-class heaps, the huge cache and test hooks. `Runtime_Support` imports the module without parameters, so the context allocator always uses the defaults. Importing it with other parameters creates a separate instance with its own state; the context allocator is not affected.
|
||||||
|
|
||||||
|
## 3. Allocator protocol
|
||||||
|
|
||||||
|
| Mode | Jai's | This module |
|
||||||
|
|---|---|---|
|
||||||
|
| ALLOCATE / RESIZE / FREE with `data == null` | rpmalloc branch: the one default heap, behind a global lock. C runtime branch: `malloc`. | The calling thread's own heap, with no lock on the fast path. A block freed by another thread is handed back to its owning heap without blocking. |
|
||||||
|
| Out of memory | **Asserts** with asserts on (N1, tests SIZE-01 and SIZE-07) | Returns null |
|
||||||
|
| Negative or impossibly large size | Not caught (F11, SIZE-02 and SIZE-03). The C runtime wrapper panics in debug builds (N5). | Returns null; a refused RESIZE leaves the block intact |
|
||||||
|
| RESIZE `old_size` | Ignored | When the block moves, `old_size` bytes are copied; 0 means the whole old block. **Passing a positive `old_size` smaller than the live data loses the rest.** |
|
||||||
|
| RESIZE to the same size | Can move the block (I1, RE-02) | Keeps it |
|
||||||
|
| A refused RESIZE | Returns the old pointer (L6, RE-04) | Returns null |
|
||||||
|
| STARTUP | Does nothing; returns null | `initialize()`; returns null. `Process/posix.jai` stores this return value in `context.allocator.data`, where null means "the thread's heap". |
|
||||||
|
| SHUTDOWN | Forgets the default heap; later use fails (F3, LIFE-01) | `finalize()`; the next allocation initialises again |
|
||||||
|
| THREAD_START / THREAD_STOP | Do nothing | Set up / release the thread's heap. Both are optional: a thread gets its heap on its first allocation, and the heap is released when the thread exits. Jai's `Thread` module sends these only on Windows (N4). |
|
||||||
|
| CREATE_HEAP | rpmalloc branch: yes. C runtime branch: **asserts**. | Yes (with `FIRST_CLASS_HEAPS`, the default) |
|
||||||
|
| DESTROY_HEAP | Releases the heap; a new heap can then claim the destroyed heap's blocks (F5, HEAP-01). Blocks still in it leak: 1,970 MiB over 190 heaps (HEAP-11). | Frees every block in the heap, then releases it |
|
||||||
|
| IS_THIS_YOURS | rpmalloc branch: see §2. C runtime branch: **asserts**. | See §2 |
|
||||||
|
| CAPS flags | rpmalloc branch: general heap, threads, CREATE_HEAP, FREE, ACTUALLY_RESIZE, IS_THIS_YOURS. C runtime branch: no heaps, no IS_THIS_YOURS. | The same as the rpmalloc branch, on every platform |
|
||||||
|
|
||||||
|
**First-class heaps.** A heap from CREATE_HEAP is for one thread at a time, as in upstream rpmalloc. Putting it in a Context that several threads copy lets them race on it.
|
||||||
|
|
||||||
|
## 4. Threads
|
||||||
|
|
||||||
|
- **Heap per thread.** Each thread allocates from its own heap, kept in OS thread-local storage (`TlsAlloc`/`FlsAlloc`, or a pthread key). A Context copied to another thread does not share the heap.
|
||||||
|
- **Heaps of exited threads are reused.** A thread's heap is released when the thread exits, even without THREAD_STOP, and the next new thread takes it over. With Jai's module on Windows and Linux, all threads share one heap behind one lock: there is no per-thread state, but every allocation contends for that lock.
|
||||||
|
- **Freeing another thread's block.** A block freed by a thread other than its owner is queued without a lock. Its owner reuses it on that owner's next allocation, or on `thread_collect()`. If the owner has exited, or is idle in `thread_idle()`, the block is freed at once instead.
|
||||||
|
- **Thread pools: call `thread_idle()`.** A pool thread that sleeps makes no allocator calls, so blocks other threads free into its heap, and the heap's free pages, would stay committed until it wakes. Calling `Default_Allocator.thread_idle()` where a pool thread waits for work gives that memory back and parks the heap until the thread's next allocation. Jai's module has no per-thread state, so it needs no such call.
|
||||||
|
- **Partly used pages per thread.** Each heap keeps its own partly used pages, so a program with many threads holds some more memory in partly filled small pages (64 KiB each) than one shared heap would. Larger pages are committed as they fill.
|
||||||
|
|
||||||
|
## 5. Memory use
|
||||||
|
|
||||||
|
- **Retention.** The port keeps freed pages per thread and huge blocks in a cache of up to 128 MiB committed (`HUGE_CACHE_LIMIT_MB`; a program that frees and allocates many huge blocks may run faster with more, holding up to that much while it does). Cached entries idle for a second are decommitted; blocks beyond the budget are unmapped. It decommits free pages only after a delay. Jai's rpmalloc branch returns memory eagerly, and pays page faults for it on reuse (F1, RET-01: about 112 faults per warm cycle). `trim()` returns every free page of the calling thread's heap, plus the whole huge cache, at once. `thread_collect()` does the same for pages past their delay.
|
||||||
|
- **Address space.** Memory is reserved in 256 MiB spans and committed page by page. Virtual size therefore looks large; the committed size is what costs memory. A 128 KiB bitmap for IS_THIS_YOURS is mapped once.
|
||||||
|
- **Measured footprint** (MiB with the benchmark's live set allocated; `my_modules/rpmalloc/tests/results/BENCH-*.md`):
|
||||||
|
|
||||||
|
| Benchmark | Windows: Jai's | Windows: this | macOS: Jai's (C runtime) | macOS: this |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| churn_medium | 204 | 170 | 148 | 66 |
|
||||||
|
| churn_large | 157 | 260 | 128 | 7.8 |
|
||||||
|
| churn_huge | 296 | 452 | 28.5 | 4.2 |
|
||||||
|
|
||||||
|
- **macOS.** The port decommits with `MADV_FREE_REUSABLE`, so freed memory leaves the process's footprint.
|
||||||
|
|
||||||
|
## 6. fork()
|
||||||
|
|
||||||
|
`Process/posix.jai` calls `unsafe_reset()` in the child, then `allocator_proc(.STARTUP)`. Jai's rpmalloc branch forgets its whole state at that point, so every block allocated before the fork leaks in the child. This module instead releases each lock that another thread of the parent might have held: the global heap lock, the heap-creation flag, the huge-cache lock, and an initialisation another thread had started. Everything else stays as it was. The forking thread's heap and its blocks remain valid in the child. Like Jai's, it cannot repair a list another thread was in the middle of changing; that window is a few instructions under one of those locks. `jai build.jai - swap` checks this: without `unsafe_reset` the smoke test hangs, and with it the test passes.
|
||||||
|
|
||||||
|
## 7. Compile time
|
||||||
|
|
||||||
|
Code in `#run` allocates through this module as well; the swap smoke test checks this. Jai's module also works at compile time.
|
||||||
|
|
||||||
|
## 8. Build
|
||||||
|
|
||||||
|
- This module imports `Basic`, `Bit_Operations` and `Atomics`; Jai's imports nothing.
|
||||||
|
- Its code is about 4,000 lines; Jai's rpmalloc branch is about 2,000.
|
||||||
|
- Unsupported platforms fail to compile (§1).
|
||||||
|
- **One Context field.** The module adds `rpmalloc_default_allocator_thread_heap` (a pointer) to every program's `Context`, a cache of the thread's heap, so allocations and frees need no OS thread-local storage call. A second import of the module with other parameters must pass `CONTEXT_HEAP_CACHE = false`, since two imports cannot add the same field. Jai's module adds nothing to the Context.
|
||||||
|
- It works in programs built with `arithmetic_overflow_check = .FATAL`: no code relies on unsigned wraparound. In `my_modules/rpmalloc/tests`, `jai build.jai - swap overflow=fatal` and the port's suite with `overflow=fatal` test this. Both pass on Windows and macOS; Linux is type-checked only.
|
||||||
|
|
||||||
|
## 9. Speed
|
||||||
|
|
||||||
|
Median ns per operation, module defaults, from `my_modules/rpmalloc/tests/results/BENCH-*.md`; lower is better.
|
||||||
|
|
||||||
|
| Benchmark | Windows: Jai's | Windows: this | macOS: Jai's (C runtime) | macOS: this |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| pair_64 (alloc + free of 64 B) | 34.3 | 13.1 | 9.7 | 12.2 |
|
||||||
|
| pair_mixed (16..1024 B) | 256 | 15.5 | 23.6 | 13.4 |
|
||||||
|
| churn_medium (1..64 KiB) | 1059 | 51.7 | 74.0 | 53.2 |
|
||||||
|
| churn_large (64 KiB..1 MiB) | 4600 | 32.3 | 122 | 30.9 |
|
||||||
|
| **churn_huge (2..32 MiB)** | 9526 | **20245** | 618 | **4751** |
|
||||||
|
| realloc_double (64 B to 8 MiB) | 184662 | 15492 | 6373 | 6601 |
|
||||||
|
| mt_churn_8 (8 threads) | 515 | 2.3 | 18.0 | 1.9 |
|
||||||
|
| mt_producer (cross-thread frees) | 484 | 46.4 | 122 | 87.3 |
|
||||||
|
|
||||||
|
On Windows this module is faster everywhere except **churn_huge** (replacing blocks of 2 MiB and up), where it is 2× slower. On macOS, where Jai's module is the system `malloc`, it is level or faster except on pair_64 (0.8×) and churn_huge (7× slower). That gap is shelved in the plan.
|
||||||
|
|
||||||
|
## 10. Test results
|
||||||
|
|
||||||
|
The test suite was run against Jai's module and against the port:
|
||||||
|
|
||||||
|
| Platform | Jai's Default_Allocator | This module's code (the port) |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows x64 (Jai's: rpmalloc 1.4.4) | 30 pass, 11 fail, 4 crash (41 skipped: capabilities it lacks) | 82 pass; every other test is SKIP or N/A |
|
||||||
|
| macOS ARM64 (Jai's: C runtime `malloc`) | 30 pass, 6 fail, 2 crash (48 skipped) | 83 pass; every other test is SKIP or N/A |
|
||||||
|
|
||||||
|
What Jai's module fails, by kind:
|
||||||
|
|
||||||
|
- **Correctness, Windows:**
|
||||||
|
- SIZE-01 and SIZE-07 assert when the OS refuses memory.
|
||||||
|
- SIZE-02 and SIZE-03 accept negative sizes.
|
||||||
|
- RE-02: a same-size resize moves the block.
|
||||||
|
- RE-04: a refused resize returns the old pointer.
|
||||||
|
- HEAP-01: a new heap claims a destroyed heap's blocks.
|
||||||
|
- HEAP-05 and HEAP-10: IS_THIS_YOURS misses blocks it owns.
|
||||||
|
- HEAP-11: destroyed heaps leak their blocks.
|
||||||
|
- LIFE-01: the allocator is unusable after SHUTDOWN.
|
||||||
|
- **Correctness, macOS:** SIZE-02 crashes on a negative size, and RE-04 is the same as on Windows.
|
||||||
|
- **Memory behaviour, Windows:** RET-01, RET-06, RET-10 and RET-14 fail on page faults. Freed memory goes back to the OS and faults in again on every reuse (up to 2,053 faults per 8 MiB cycle).
|
||||||
|
- **Memory behaviour, macOS:** OS-07, RET-02, RET-07, RET-11 and RET-12 fail because the system `malloc` keeps freed memory private (258 of 258 MiB after freeing everything). THR-09: blocks freed by another thread are not reused first.
|
||||||
|
|
||||||
|
One upstream bug remains open in the port: U15 (POSIX only; at most one heap per exiting thread can leak, in a rare destructor order). See `my_modules/rpmalloc/module.jai`.
|
||||||
|
|
||||||
|
## 11. Before you switch a project
|
||||||
|
|
||||||
|
- Check every `RESIZE` / `realloc` call for a correct `old_size` (§3). Jai's module ignores it; this one relies on it.
|
||||||
|
- Expect a larger footprint on Windows for large and huge blocks, or call `trim()` at quiet points.
|
||||||
|
- Workloads with many huge blocks (2 MiB and up) are slower than with Jai's module (§9).
|
||||||
|
- Supported platforms are Windows and macOS; Linux is type-checked only.
|
||||||
|
- A block must still be freed by the allocator that made it. Each program and DLL compiled with this module has its own instance, just as with Jai's.
|
||||||
21
modules/Default_Allocator/LICENSE
Normal file
21
modules/Default_Allocator/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 Mattias Jansson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
21
modules/Default_Allocator/LICENSES/MIT.txt
Normal file
21
modules/Default_Allocator/LICENSES/MIT.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 Mattias Jansson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
24
modules/Default_Allocator/LICENSES/Unlicense.txt
Normal file
24
modules/Default_Allocator/LICENSES/Unlicense.txt
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
||||||
59
modules/Default_Allocator/README.md
Normal file
59
modules/Default_Allocator/README.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Default_Allocator (rpmalloc 2.0.1 port)
|
||||||
|
|
||||||
|
A drop-in replacement for Jai's `modules/Default_Allocator`: the rpmalloc 2.0.1 port, wrapped in the interface Jai's runtime expects. This directory holds only what differs from the port's other flavour. `install.jai` at the repository root puts it together with the shared allocator files.
|
||||||
|
|
||||||
|
## Installing it
|
||||||
|
|
||||||
|
From the repository root (`my_modules/rpmalloc`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jai install.jai - Default_Allocator PROJECT/modules
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the same command again to update the copy when the repository has a newer `VERSION_REVISION`. Add `check` to see what it would do. The repository's `README.md` describes every case.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
Jai picks its default allocator by module name: `Runtime_Support.jai` imports `"Default_Allocator"`. `PROJECT/modules` has to come ahead of Jai's own `modules` directory on the import path:
|
||||||
|
|
||||||
|
- Jai searches the working directory's `modules/` folder first, so building from the project's root is enough: `jai main.jai`.
|
||||||
|
- From elsewhere, or to be explicit: `jai main.jai -import_dir PROJECT/modules`, or in a build program prepend the directory to `Build_Options.import_path`:
|
||||||
|
|
||||||
|
```jai
|
||||||
|
paths: [..] string;
|
||||||
|
array_add(*paths, "PROJECT/modules");
|
||||||
|
array_add(*paths, ..options.import_path);
|
||||||
|
options.import_path = paths;
|
||||||
|
```
|
||||||
|
|
||||||
|
Every allocation the program makes then goes through this allocator, from the first one, and at compile time too. Nothing needs to set `context.allocator`.
|
||||||
|
|
||||||
|
**Thread pools:** call `Default_Allocator.thread_idle()` where a pool thread waits for work. Each thread has its own heap, and an idle thread makes no allocator calls, so without it the memory other threads free into that heap stays committed until the thread wakes (DIFFERENCES.md, section 4).
|
||||||
|
|
||||||
|
To check which allocator, and which version, a program is using:
|
||||||
|
|
||||||
|
```jai
|
||||||
|
name: string;
|
||||||
|
context.allocator.proc(.CAPS, 0, 0, *name, context.allocator.data);
|
||||||
|
// "rpmalloc 2.0.1-jai.N (Jai port) as Default_Allocator", N being VERSION_REVISION
|
||||||
|
```
|
||||||
|
|
||||||
|
`Default_Allocator.VERSION` holds the same version (`version.jai` in the installed copy).
|
||||||
|
|
||||||
|
**Supported:** 64-bit Windows and macOS (tested), and Linux (type-checked only). Other targets stop with an `#assert`; build them without this module on the import path.
|
||||||
|
|
||||||
|
**Read [DIFFERENCES.md](DIFFERENCES.md) before switching a project.** In particular, RESIZE relies on `old_size`, memory retention differs, and huge-block churn is slower.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | In the repository | Installed copy |
|
||||||
|
|---|---|---|
|
||||||
|
| `module.jai` | `Default_Allocator/module.jai`: Jai's `Default_Allocator` interface (`allocator`, `allocator_proc`, `unsafe_reset`, `initialize_if_needed`, …) and the port's public types | `module.jai` |
|
||||||
|
| `README.md`, `DIFFERENCES.md` | `Default_Allocator/` | the same names |
|
||||||
|
| `rpmalloc.jai`, `os_windows.jai`, `os_posix.jai`, `version.jai`, `LICENSE`, `LICENSES/` | the repository root, shared with the `rpmalloc2` flavour | the same names |
|
||||||
|
|
||||||
|
`module.jai` loads the shared files from its own directory, so it builds only in an installed copy. In the repository, `tests/` checks it with `jai build.jai - swap`, which installs this flavour into a scratch directory and runs a program on it.
|
||||||
|
|
||||||
|
When the port's `module.jai` changes the protocol procedure or the public types, bring this `module.jai` up to date by hand. It has its own copy of both.
|
||||||
|
|
||||||
|
License: rpmalloc is dual licensed Unlicense / MIT (`LICENSE`, `LICENSES/`).
|
||||||
303
modules/Default_Allocator/module.jai
Normal file
303
modules/Default_Allocator/module.jai
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
// Default_Allocator: rpmalloc 2.0.1 for Jai, as a drop-in replacement for Jai's modules/Default_Allocator.
|
||||||
|
//
|
||||||
|
// Jai finds the default allocator by module name: Runtime_Support.jai sets the default context's
|
||||||
|
// allocator to Default_Allocator.allocator_proc, and Process/posix.jai calls
|
||||||
|
// Default_Allocator.unsafe_reset() and allocator_proc(.STARTUP) in a forked child. Put the directory
|
||||||
|
// that holds this module ahead of Jai's modules directory on the import path, and every allocation
|
||||||
|
// of the program, from the first one the runtime makes, goes through this allocator.
|
||||||
|
//
|
||||||
|
// The allocator is the port in my_modules/rpmalloc (tested by its tests/ suite). This file is the
|
||||||
|
// repository's Default_Allocator/module.jai; install.jai copies it next to the port's shared files
|
||||||
|
// (rpmalloc.jai, os_*.jai, version.jai) in PROJECT/modules/Default_Allocator, and only there does it
|
||||||
|
// build. Upstream is dual licensed Unlicense / MIT; see LICENSE and LICENSES/.
|
||||||
|
//
|
||||||
|
// Read DIFFERENCES.md before switching a project over: it lists everything that behaves differently
|
||||||
|
// from Jai's Default_Allocator.
|
||||||
|
|
||||||
|
#module_parameters (
|
||||||
|
// The first two are Jai's Default_Allocator parameters, with the same names and defaults.
|
||||||
|
|
||||||
|
// Internal consistency asserts, reported through Memory_Interface.error_callback if set,
|
||||||
|
// otherwise context.assertion_failed. Upstream rpmalloc default: off.
|
||||||
|
ENABLE_ASSERTS := true,
|
||||||
|
// Refuse bad arguments at the public entry points: sizes at or above MAX_ALLOC_SIZE (negative
|
||||||
|
// sizes included), count * size overflow in the calloc family, size + alignment overflow and
|
||||||
|
// non-power-of-two alignments. Upstream rpmalloc default: off.
|
||||||
|
ENABLE_VALIDATE_ARGS := true,
|
||||||
|
|
||||||
|
// The rest are the port's (see my_modules/rpmalloc/module.jai). Runtime_Support imports this
|
||||||
|
// module without parameters, so the defaults below are what a program's context allocator uses.
|
||||||
|
|
||||||
|
// Global, per-thread and (with HEAP_STATISTICS) per-heap counters: global_statistics,
|
||||||
|
// thread_statistics, dump_statistics, heap_statistics.
|
||||||
|
ENABLE_STATISTICS := false,
|
||||||
|
// At finalize, assert that every block was freed. Needs ENABLE_STATISTICS.
|
||||||
|
ENABLE_LEAK_DETECTION := false,
|
||||||
|
// Commit pages on demand, and decommit free pages beyond a per page type threshold.
|
||||||
|
ENABLE_DECOMMIT := true,
|
||||||
|
// Return address space to the OS (huge blocks; everything at finalize with unmap_on_finalize).
|
||||||
|
ENABLE_UNMAP := true,
|
||||||
|
// The heap_* API and Allocator_Mode.CREATE_HEAP / DESTROY_HEAP.
|
||||||
|
FIRST_CLASS_HEAPS := true,
|
||||||
|
// Per first-class heap allocated / committed / mapped sizes (heap_statistics).
|
||||||
|
HEAP_STATISTICS := false,
|
||||||
|
// Recently freed huge blocks kept for reuse (at most 128 MiB, 32 MiB each); 0 disables the cache.
|
||||||
|
HUGE_CACHE_SLOT_COUNT := 32,
|
||||||
|
// How much freed huge-block memory the cache may keep committed, in MiB; a single entry may take a
|
||||||
|
// quarter of it. Upstream's 128. A program that frees and allocates huge blocks often runs faster
|
||||||
|
// with more (churn_huge misses the cache on 26% of steps at 128, 5% at 256, 1% at 512), at the cost of
|
||||||
|
// up to this much memory held while it does; entries idle for a second are decommitted (#jai fix E11).
|
||||||
|
HUGE_CACHE_LIMIT_MB := 128,
|
||||||
|
// Keep the thread heap in the Jai Context (field rpmalloc_default_allocator_thread_heap) as well as in OS thread-local storage, so
|
||||||
|
// allocations and frees need no OS call to find it (#jai fix E10). Two imports of this module cannot
|
||||||
|
// both add the field: turn it off for a second import with other parameters.
|
||||||
|
CONTEXT_HEAP_CACHE := true,
|
||||||
|
// Export internals and fault injection for the port's test suite. Adds exports only.
|
||||||
|
TEST_HOOKS := false
|
||||||
|
)();
|
||||||
|
|
||||||
|
#assert !ENABLE_LEAK_DETECTION || ENABLE_STATISTICS "ENABLE_LEAK_DETECTION needs ENABLE_STATISTICS";
|
||||||
|
|
||||||
|
// #jai Jai's Default_Allocator also builds for PS5, Switch 2, iOS, Android and 32-bit targets (as a
|
||||||
|
// CRT malloc wrapper on most of them). The port has an OS layer for Windows, macOS and Linux only.
|
||||||
|
#assert (OS == .WINDOWS || OS == .MACOS || OS == .LINUX) && (CPU == .X64 || CPU == .ARM64)
|
||||||
|
"This Default_Allocator (rpmalloc 2.0.1 port) supports 64-bit Windows, macOS and Linux only. For other targets, take my_modules off the import path so Jai's own Default_Allocator is used.";
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Jai's Default_Allocator interface, as Runtime_Support, Process and other code use it:
|
||||||
|
|
||||||
|
allocator Allocator.{allocator_proc, null}
|
||||||
|
allocator_proc The Allocator protocol, below.
|
||||||
|
unsafe_reset() For a forked child only, before it allocates.
|
||||||
|
initialize_if_needed() Initialise now; true if this call did it.
|
||||||
|
is_initialized()
|
||||||
|
finalize()
|
||||||
|
usable_size(ptr)
|
||||||
|
is_this_yours(heap, ptr)
|
||||||
|
heap_free_all(heap)
|
||||||
|
global_statistics() Also global_statistics(*stats), the rpmalloc form.
|
||||||
|
heap_statistics(heap)
|
||||||
|
|
||||||
|
Not provided: allocator_default_heap. There is no single default heap; each thread has its own.
|
||||||
|
|
||||||
|
Allocator protocol (allocator_proc):
|
||||||
|
|
||||||
|
ALLOCATE, RESIZE, FREE The calling thread's heap, or the first-class heap in allocator_data.
|
||||||
|
Out of memory returns null. With ENABLE_VALIDATE_ARGS, so does an
|
||||||
|
impossible size (negative or near S64_MAX), and such a RESIZE leaves
|
||||||
|
the block intact. FREE of null is a no-op. RESIZE copies old_size
|
||||||
|
bytes when the block moves (all of the old block when old_size is 0).
|
||||||
|
STARTUP Optional: initialize(). Returns null.
|
||||||
|
SHUTDOWN finalize(). Allocating afterwards initialises again.
|
||||||
|
THREAD_START, THREAD_STOP thread_initialize(), thread_finalize(). Both optional: a thread gets
|
||||||
|
its heap on its first allocation, and gives it back at thread exit.
|
||||||
|
CREATE_HEAP heap_acquire(): a first-class heap, to put in allocator_data.
|
||||||
|
DESTROY_HEAP Frees every block still in the heap, then releases it.
|
||||||
|
IS_THIS_YOURS With a heap: is the address inside a page of that heap? Without:
|
||||||
|
inside a page of any thread heap (not of a first-class heap)?
|
||||||
|
CAPS Everything above; CREATE_HEAP only with FIRST_CLASS_HEAPS.
|
||||||
|
|
||||||
|
For a thread pool: call thread_idle() where a pool thread waits for work. It gives back what
|
||||||
|
the thread's heap holds free and parks the heap, so blocks other threads free into it while it
|
||||||
|
sleeps are given back at once; the thread's next allocation takes the heap back.
|
||||||
|
|
||||||
|
The whole rpmalloc API of the port is exported as well: initialize, thread_collect, trim, thread_idle,
|
||||||
|
rpmalloc, rpaligned_alloc, heap_acquire, and the rest (my_modules/rpmalloc/module.jai lists it).
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
allocator :: Allocator.{allocator_proc, null};
|
||||||
|
|
||||||
|
allocator_proc :: (mode: Allocator_Mode, requested_size: s64, old_size: s64, old_memory: *void, allocator_data: *void) -> *void {
|
||||||
|
heap := cast(*Heap) allocator_data;
|
||||||
|
|
||||||
|
if #complete mode == {
|
||||||
|
case .ALLOCATE;
|
||||||
|
#if FIRST_CLASS_HEAPS if heap return heap_alloc(heap, requested_size);
|
||||||
|
return rpmalloc(requested_size);
|
||||||
|
|
||||||
|
case .RESIZE;
|
||||||
|
return allocator_proc_resize(heap, old_memory, requested_size, old_size);
|
||||||
|
|
||||||
|
case .FREE;
|
||||||
|
#if FIRST_CLASS_HEAPS if heap { heap_free(heap, old_memory); return null; } // Null is a no-op (#jai fix U18).
|
||||||
|
rpfree(old_memory);
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case .STARTUP;
|
||||||
|
initialize();
|
||||||
|
return null; // Process/posix.jai stores this in context.allocator.data: null means the thread heap.
|
||||||
|
|
||||||
|
case .SHUTDOWN;
|
||||||
|
finalize();
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case .THREAD_START;
|
||||||
|
thread_initialize();
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case .THREAD_STOP;
|
||||||
|
thread_finalize();
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case .CREATE_HEAP;
|
||||||
|
#if FIRST_CLASS_HEAPS return heap_acquire();
|
||||||
|
else return null;
|
||||||
|
|
||||||
|
case .DESTROY_HEAP;
|
||||||
|
// #jai fix M4: destroying a heap frees every block still in it.
|
||||||
|
#if FIRST_CLASS_HEAPS if heap {
|
||||||
|
heap_free_all(heap);
|
||||||
|
heap_release(heap);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case .IS_THIS_YOURS;
|
||||||
|
return cast(*void) cast(s64) is_this_yours(heap, old_memory);
|
||||||
|
|
||||||
|
case .CAPS;
|
||||||
|
if old_memory { (cast(*string) old_memory).* = CAPS_VERSION_STRING; }
|
||||||
|
caps := Allocator_Caps.HINT_I_AM_A_GENERAL_HEAP_ALLOCATOR | .MULTIPLE_THREADS | .FREE | .ACTUALLY_RESIZE | .IS_THIS_YOURS;
|
||||||
|
#if FIRST_CLASS_HEAPS caps |= .CREATE_HEAP;
|
||||||
|
return cast(*void) caps;
|
||||||
|
|
||||||
|
case;
|
||||||
|
context.handling_assertion_failure = true;
|
||||||
|
context.assertion_failed(#location(), "Invalid or corrupt mode passed to Default_Allocator.allocator_proc.\n");
|
||||||
|
context.handling_assertion_failure = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jai's Default_Allocator interface: initialise now, unless already done. True if this call did it.
|
||||||
|
initialize_if_needed :: () -> did_init: bool {
|
||||||
|
if is_initialized() return false;
|
||||||
|
initialize();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_initialized :: inline () -> bool {
|
||||||
|
return atomic_load(*global_rpmalloc_init_state) == RPMALLOC_INIT_DONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jai's Default_Allocator form of global_statistics. With ENABLE_STATISTICS off (the default) every
|
||||||
|
// field is 0, as with Jai's.
|
||||||
|
global_statistics :: () -> Global_Statistics {
|
||||||
|
stats: Global_Statistics;
|
||||||
|
global_statistics(*stats);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// :AllocatorAfterFork: Only for the child of fork(), before it allocates (Process/posix.jai). The
|
||||||
|
// child has one thread, the one that called fork; any lock another thread of the parent held at that
|
||||||
|
// moment would never be released, so the next allocation would wait forever. This releases them.
|
||||||
|
//
|
||||||
|
// #jai Jai's Default_Allocator forgets its whole state here and starts afresh. This keeps it: the
|
||||||
|
// forking thread's heap and every block it holds stay valid, and the heaps of the parent's other
|
||||||
|
// threads stay allocated (in the child nobody frees them, as with fork and any allocator). Like Jai's,
|
||||||
|
// it cannot repair a list another thread was in the middle of changing; the window is a few
|
||||||
|
// instructions under the global heap lock or the huge cache lock.
|
||||||
|
unsafe_reset :: () {
|
||||||
|
atomic_store(*global_heap_lock, 0);
|
||||||
|
global_heap_creating = 0; // Another thread was mapping a block of heaps; the child would wait for it.
|
||||||
|
#if HUGE_CACHE_SLOT_COUNT atomic_store(*global_huge_cache.lock, 0);
|
||||||
|
// Another thread was initialising: start again (the forking thread was not, it was in fork).
|
||||||
|
if atomic_load(*global_rpmalloc_init_state) == RPMALLOC_INIT_RUNNING
|
||||||
|
atomic_store(*global_rpmalloc_init_state, RPMALLOC_INIT_UNINIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Public types (2.0.1: rpmalloc.h). Kept identical to my_modules/rpmalloc/module.jai.
|
||||||
|
//
|
||||||
|
|
||||||
|
#load "version.jai"; // VERSION, VERSION_REVISION, RPMALLOC_VERSION
|
||||||
|
|
||||||
|
RPMALLOC_MAX_ALIGNMENT :: 256 * 1024; // 2.0.1: rpmalloc.h:69
|
||||||
|
|
||||||
|
// Flags for rpaligned_realloc / heap_realloc (2.0.1: rpmalloc.h:79-84).
|
||||||
|
Realloc_Flags :: enum_flags u32 {
|
||||||
|
NO_PRESERVE :: 1; // Do not preserve content in reallocation.
|
||||||
|
GROW_OR_FAIL :: 2; // Fail (return null) if the block cannot grow in place; the original stays valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.0.1: rpmalloc.h rpmalloc_interface_t. Pass one to initialize / initialize_config to replace
|
||||||
|
// the OS calls; it is copied, so later changes to it have no effect. If memory_map or memory_unmap
|
||||||
|
// is null, all four OS defaults are used; otherwise a null memory_commit or memory_decommit uses
|
||||||
|
// the OS's (#jai fix U8).
|
||||||
|
Memory_Interface :: struct {
|
||||||
|
// Map `size` bytes aligned to `alignment` (0 or the span size). May store an alignment offset
|
||||||
|
// and the mapped size, both passed back to memory_unmap.
|
||||||
|
memory_map: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void;
|
||||||
|
memory_commit: (address: *void, size: u64) -> s32; // Non-zero on failure.
|
||||||
|
memory_decommit: (address: *void, size: u64) -> s32; // Non-zero on failure.
|
||||||
|
memory_unmap: (address: *void, offset: u64, mapped_size: u64);
|
||||||
|
map_fail_callback: (size: u64) -> s32; // Non-zero: retry the map.
|
||||||
|
error_callback: (message: string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.0.1: rpmalloc.h rpmalloc_config_t.
|
||||||
|
Config :: struct {
|
||||||
|
page_size: u64; // 0: the OS page size.
|
||||||
|
enable_huge_pages: bool;
|
||||||
|
enable_thp: bool; // Linux / Android only.
|
||||||
|
disable_decommit: bool;
|
||||||
|
page_name: string;
|
||||||
|
huge_page_name: string;
|
||||||
|
unmap_on_finalize: bool;
|
||||||
|
disable_thp: bool; // Linux / Android only.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.0.1: rpmalloc.h rpmalloc_global_statistics_t. All zero without ENABLE_STATISTICS. The memory
|
||||||
|
// committed now is active, not committed: committed and decommitted are running totals, as upstream.
|
||||||
|
Global_Statistics :: struct {
|
||||||
|
mapped: u64; // Address space mapped now, committed or not.
|
||||||
|
mapped_peak: u64;
|
||||||
|
committed: u64; // Running total of bytes ever committed; it only grows.
|
||||||
|
decommitted: u64; // Running total of bytes ever decommitted; it only grows.
|
||||||
|
active: u64; // Bytes committed now: what the OS counts (API-07 checks it).
|
||||||
|
active_peak: u64;
|
||||||
|
huge_alloc: u64; // Bytes in huge blocks allocated now.
|
||||||
|
huge_alloc_peak: u64;
|
||||||
|
heap_count: u64; // Heaps in use now.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.0.1: rpmalloc.h rpmalloc_thread_statistics_t.
|
||||||
|
Thread_Statistics :: struct {
|
||||||
|
sizecache: u64; // Bytes available in thread size class caches.
|
||||||
|
spancache: u64; // Bytes available in thread page caches.
|
||||||
|
span_use: [5] struct {
|
||||||
|
current: u64;
|
||||||
|
map_calls: u64;
|
||||||
|
};
|
||||||
|
size_use: [128] struct {
|
||||||
|
alloc_current: u64;
|
||||||
|
alloc_peak: u64;
|
||||||
|
alloc_total: u64;
|
||||||
|
free_total: u64;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.0.1: rpmalloc.h rpmalloc_heap_statistics_t.
|
||||||
|
Heap_Statistics :: struct {
|
||||||
|
allocated_size: u64;
|
||||||
|
committed_size: u64;
|
||||||
|
mapped_size: u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_module
|
||||||
|
|
||||||
|
// #jai fix E10: the thread heap, cached in the Context (see get_thread_heap in rpmalloc.jai).
|
||||||
|
#if CONTEXT_HEAP_CACHE {
|
||||||
|
#add_context rpmalloc_default_allocator_thread_heap: *void;
|
||||||
|
context_thread_heap :: () -> **void #expand { return *context.rpmalloc_default_allocator_thread_heap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A literal, not built from VERSION with #run: a #run would wait for the default allocator, which is
|
||||||
|
// this module, whose CAPS needs this string. The assert keeps it in step with version.jai.
|
||||||
|
CAPS_VERSION_STRING :: "rpmalloc 2.0.1-jai.16 (Jai port) as Default_Allocator";
|
||||||
|
#assert #run text_is(CAPS_VERSION_STRING, "rpmalloc ", VERSION, " (Jai port) as Default_Allocator") "Update CAPS_VERSION_STRING to the VERSION in version.jai";
|
||||||
|
|
||||||
|
#load "rpmalloc.jai";
|
||||||
|
#if OS == .WINDOWS #load "os_windows.jai";
|
||||||
|
else #load "os_posix.jai";
|
||||||
408
modules/Default_Allocator/os_posix.jai
Normal file
408
modules/Default_Allocator/os_posix.jai
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
// OS entry points for macOS and Linux (2.0.1: rpmalloc.c:878-1161, the POSIX branches), plus
|
||||||
|
// the POSIX part of rpmalloc_initialize (2.0.1: rpmalloc.c:2776-2782).
|
||||||
|
//
|
||||||
|
// Everything goes through libc on every CPU. The 1.4.4 Jai port's raw syscalls caused review
|
||||||
|
// findings H4, H7 and M1; none of that code exists here.
|
||||||
|
//
|
||||||
|
// #jai deliberate differences from upstream:
|
||||||
|
// - A failed map returns null without asserting (finding N1).
|
||||||
|
// - macOS decommits with MADV_FREE_REUSABLE, retried on EAGAIN, and commits with
|
||||||
|
// MADV_FREE_REUSE. Upstream prefers MADV_DONTNEED, which exists on macOS but does not reduce
|
||||||
|
// the process footprint there, so decommit is a no-op on macOS (upstream bug U21).
|
||||||
|
// - Every entry point works before initialize(), like os_windows.jai.
|
||||||
|
|
||||||
|
// Fills os_map_granularity and os_page_size. Idempotent.
|
||||||
|
// 2.0.1: rpmalloc.c:2776-2782 (the POSIX part of rpmalloc_initialize)
|
||||||
|
os_initialize :: () {
|
||||||
|
if os_page_size return;
|
||||||
|
os_map_granularity = cast(u64) sysconf_p(_SC_PAGESIZE);
|
||||||
|
os_page_size = os_map_granularity;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_set_page_name :: (address: *void, size: u64) { // 2.0.1: rpmalloc.c:885
|
||||||
|
#if OS == .LINUX {
|
||||||
|
name := ifx os_huge_pages then global_config.huge_page_name else global_config.page_name;
|
||||||
|
if address == MAP_FAILED_P || !name return;
|
||||||
|
// prctl wants a C string; names are short, copy into a terminated buffer.
|
||||||
|
buffer: [80] u8;
|
||||||
|
n := min(name.count, buffer.count - 1);
|
||||||
|
memcpy(buffer.data, name.data, n);
|
||||||
|
buffer[n] = 0;
|
||||||
|
// If the kernel lacks CONFIG_ANON_VMA_NAME, or the name is invalid, this does nothing.
|
||||||
|
prctl_p(PR_SET_VMA, PR_SET_VMA_ANON_NAME, cast(u64) address, size, cast(u64) buffer.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai fix E9: a mapping that is committed as well. On POSIX a fresh anonymous mapping is usable as it
|
||||||
|
// is, so this only counts the commit (no madvise); see os_windows.jai for why huge blocks use it.
|
||||||
|
os_mmap_committed :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void {
|
||||||
|
ptr := os_mmap(size, alignment, offset, mapped_size);
|
||||||
|
#if ENABLE_DECOMMIT if ptr statistics_os_commit(size);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mmap :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void { // 2.0.1: rpmalloc.c:900
|
||||||
|
os_initialize();
|
||||||
|
#if TEST_HOOKS if test_take_fault(*test_fail_maps) return os_map_failed(size, alignment, offset, mapped_size);
|
||||||
|
|
||||||
|
map_size := size + alignment; // UPSTREAM-BUG U1: wraps for sizes near U64_MAX.
|
||||||
|
flags: s32 = MAP_PRIVATE_P | MAP_ANONYMOUS_P | MAP_UNINITIALIZED_P;
|
||||||
|
#if OS == .MACOS {
|
||||||
|
fd := VM_MAKE_TAG_240;
|
||||||
|
if os_huge_pages fd |= VM_FLAGS_SUPERPAGE_SIZE_2MB;
|
||||||
|
ptr := mmap_p(null, map_size, PROT_READ_WRITE_P, flags, fd, 0);
|
||||||
|
if (ptr == MAP_FAILED_P || !ptr) && os_huge_pages {
|
||||||
|
// Superpage allocations can fail through physical memory fragmentation; fall back
|
||||||
|
// to regular pages.
|
||||||
|
ptr = mmap_p(null, map_size, PROT_READ_WRITE_P, flags, VM_MAKE_TAG_240, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
huge_flags: s32 = 0;
|
||||||
|
if os_huge_pages {
|
||||||
|
huge_flags = MAP_HUGETLB_P;
|
||||||
|
// Explicitly request the detected huge page size; the system default huge page size
|
||||||
|
// can be a different (unusable) size like 1 GiB.
|
||||||
|
if os_huge_page_shift huge_flags |= cast(s32)(os_huge_page_shift << MAP_HUGE_SHIFT_P);
|
||||||
|
}
|
||||||
|
ptr := mmap_p(null, map_size, PROT_READ_WRITE_P, huge_flags | flags, -1, 0);
|
||||||
|
if (ptr == MAP_FAILED_P || !ptr) && os_huge_pages {
|
||||||
|
// Huge page allocations can fail: fall back to normal pages and advise transparent
|
||||||
|
// huge pages (best effort; for example EINVAL when THP is disabled system-wide).
|
||||||
|
ptr = mmap_p(null, map_size, PROT_READ_WRITE_P, flags, -1, 0);
|
||||||
|
if ptr && ptr != MAP_FAILED_P madvise_p(ptr, map_size, MADV_HUGEPAGE_P);
|
||||||
|
} else if os_thp && ptr && ptr != MAP_FAILED_P {
|
||||||
|
// Transparent huge page mode: ask for huge pages without a preallocated pool.
|
||||||
|
madvise_p(ptr, map_size, MADV_HUGEPAGE_P);
|
||||||
|
}
|
||||||
|
os_set_page_name(ptr, map_size);
|
||||||
|
}
|
||||||
|
if ptr == MAP_FAILED_P ptr = null;
|
||||||
|
if !ptr return os_map_failed(size, alignment, offset, mapped_size);
|
||||||
|
|
||||||
|
if alignment {
|
||||||
|
padding := cast(u64) ptr & (alignment - 1);
|
||||||
|
if padding padding = alignment - padding;
|
||||||
|
rpmalloc_assert(padding <= alignment, "Internal failure in padding");
|
||||||
|
rpmalloc_assert(!(padding % 8), "Internal failure in padding");
|
||||||
|
// Unmap the alignment padding head and tail, so no unusable address space (or huge page
|
||||||
|
// reservation) is kept. Both the mmap result and the alignment are multiples of the
|
||||||
|
// mapping page size, so head and tail stay page aligned.
|
||||||
|
if padding munmap_p(ptr, padding);
|
||||||
|
ptr = pointer_offset(ptr, padding);
|
||||||
|
if alignment - padding munmap_p(pointer_offset(ptr, size), alignment - padding);
|
||||||
|
offset.* = 0;
|
||||||
|
map_size = size;
|
||||||
|
}
|
||||||
|
mapped_size.* = map_size;
|
||||||
|
statistics_os_map(map_size); // 2.0.1: rpmalloc.c:1023-1033
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure tail of os_mmap (2.0.1: rpmalloc.c:990-998).
|
||||||
|
// #jai returns null without asserting when there is no map_fail_callback (finding N1).
|
||||||
|
os_map_failed :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void {
|
||||||
|
iface := global_memory_interface;
|
||||||
|
if iface && iface.map_fail_callback && iface.map_fail_callback(size + alignment)
|
||||||
|
return os_mmap(size, alignment, offset, mapped_size);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_commit_on_demand :: inline () -> bool { // 2.0.1: rpmalloc.c:1040
|
||||||
|
#if ENABLE_DECOMMIT return !global_config.disable_decommit;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mcommit :: (address: *void, size: u64) -> s32 { // 2.0.1: rpmalloc.c:1049
|
||||||
|
#if ENABLE_DECOMMIT {
|
||||||
|
#if TEST_HOOKS if test_take_fault(*test_fail_commits) return 1;
|
||||||
|
// POSIX commits on first touch, so upstream does nothing here (its mprotect is commented
|
||||||
|
// out). #jai on macOS, tell the kernel the pages given back with MADV_FREE_REUSABLE are
|
||||||
|
// in use again, so the footprint accounting stays right (the pairing Apple documents).
|
||||||
|
#if OS == .MACOS madvise_p(address, size, MADV_FREE_REUSE_P);
|
||||||
|
statistics_os_commit(size); // 2.0.1: rpmalloc.c:1067-1078
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mdecommit :: (address: *void, size: u64) -> s32 { // 2.0.1: rpmalloc.c:1086
|
||||||
|
#if ENABLE_DECOMMIT {
|
||||||
|
// Unlike os_mcommit, honor the live flag (the Linux fallback below can set it mid-run).
|
||||||
|
if global_config.disable_decommit return 1;
|
||||||
|
#if OS == .MACOS {
|
||||||
|
// #jai MADV_FREE_REUSABLE, not MADV_DONTNEED (upstream bug U21).
|
||||||
|
ret := madvise_p(address, size, MADV_FREE_REUSABLE_P);
|
||||||
|
while ret == -1 && errno_p().* == EAGAIN_P {
|
||||||
|
errno_p().* = 0;
|
||||||
|
ret = madvise_p(address, size, MADV_FREE_REUSABLE_P);
|
||||||
|
}
|
||||||
|
failed := ret == -1;
|
||||||
|
} else {
|
||||||
|
failed := madvise_p(address, size, MADV_DONTNEED_P) != 0;
|
||||||
|
if failed && os_huge_pages {
|
||||||
|
// MADV_DONTNEED on huge page mappings needs Linux 5.18 or later: keep pages
|
||||||
|
// committed and stop further decommit attempts.
|
||||||
|
global_config.disable_decommit = true;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
rpmalloc_assert(false, "Failed to decommit virtual memory block");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
statistics_os_decommit(size); // 2.0.1: rpmalloc.c:1129-1133
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_munmap :: (address: *void, offset: u64, mapped_size: u64) { // 2.0.1: rpmalloc.c:1142
|
||||||
|
address = pointer_offset(address, -cast(s64) offset);
|
||||||
|
#if ENABLE_UNMAP {
|
||||||
|
if munmap_p(address, mapped_size)
|
||||||
|
rpmalloc_assert(false, "Failed to unmap virtual memory block");
|
||||||
|
statistics_os_unmap(mapped_size); // 2.0.1: rpmalloc.c:1154-1158
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os_thread_yield :: () { // 2.0.1: rpmalloc.c:396 (sched_yield)
|
||||||
|
sched_yield_p();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coarse monotonic time for the huge block cache.
|
||||||
|
os_monotonic_time_ms :: () -> u64 { // 2.0.1: rpmalloc.c:1586
|
||||||
|
ts: timespec_p;
|
||||||
|
clock_gettime_p(CLOCK_MONOTONIC_FOR_CACHE, *ts);
|
||||||
|
return cast(u64) ts.tv_sec * 1000 + cast(u64) ts.tv_nsec / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Thread-local heap storage, thread identity and the thread exit hook.
|
||||||
|
//
|
||||||
|
// #jai upstream keeps the thread heap in a C11 _Thread_local variable and uses a pthread key only
|
||||||
|
// for the thread exit destructor. This Jai version has no #thread_local, and keeping the heap in
|
||||||
|
// the Context would let copied contexts share a heap between threads (review H3), so one pthread
|
||||||
|
// key does both: it stores the heap pointer and runs the destructor at thread exit. The destructor
|
||||||
|
// matters more here than upstream: Jai's Thread module never sends THREAD_STOP on POSIX (finding
|
||||||
|
// N4), so it is what returns a finished Jai thread's heap.
|
||||||
|
|
||||||
|
os_tls_create :: (destructor: (value: *void) #c_call) -> bool {
|
||||||
|
return pthread_key_create_p(*tls_key, destructor) == 0; // 2.0.1: rpmalloc.c:2954
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_destroy :: () {
|
||||||
|
pthread_key_delete_p(tls_key); // 2.0.1: rpmalloc.c:3102
|
||||||
|
tls_key = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai fix E10: the calling thread's stack, [low, high); 0, 0 if it cannot be found out (the Context
|
||||||
|
// cache of the thread heap is then never used).
|
||||||
|
os_thread_stack_range :: () -> low: u64, high: u64 {
|
||||||
|
self := pthread_self_p();
|
||||||
|
#if OS == .MACOS {
|
||||||
|
high := cast(u64) pthread_get_stackaddr_np_p(self); // The top: the stack grows down.
|
||||||
|
size := pthread_get_stacksize_np_p(self);
|
||||||
|
if !high || size > high return 0, 0;
|
||||||
|
return high - size, high;
|
||||||
|
} else {
|
||||||
|
attr: [8] u64; // pthread_attr_t: 56 bytes on x86-64, 64 on ARM64 glibc.
|
||||||
|
if pthread_getattr_np_p(self, attr.data) != 0 return 0, 0;
|
||||||
|
address: *void;
|
||||||
|
size: u64;
|
||||||
|
ok := pthread_attr_getstack_p(attr.data, *address, *size) == 0;
|
||||||
|
pthread_attr_destroy_p(attr.data);
|
||||||
|
if !ok return 0, 0;
|
||||||
|
return cast(u64) address, cast(u64) address + size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_get :: inline () -> *void {
|
||||||
|
return pthread_getspecific_p(tls_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_set :: (value: *void) {
|
||||||
|
pthread_setspecific_p(tls_key, value); // 2.0.1: rpmalloc.c:805
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai upstream reads the thread pointer (__builtin_thread_pointer or fs/gs/tpidr registers,
|
||||||
|
// rpmalloc.c:763-790). pthread_self is equally unique among live threads and never 0.
|
||||||
|
// See os_windows.jai (#jai fix U22). POSIX exit() does not run other threads' key destructors, and
|
||||||
|
// the exiting thread's own run only from pthread_exit, so there is nothing to skip here.
|
||||||
|
os_process_exiting :: inline () -> bool { return false; }
|
||||||
|
|
||||||
|
os_thread_id :: inline () -> u64 {
|
||||||
|
return cast(u64) pthread_self_p();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Huge page detection (2.0.1: rpmalloc.c:2831-2892, the Linux and Apple branches).
|
||||||
|
os_detect_huge_pages :: () {
|
||||||
|
#if OS == .LINUX {
|
||||||
|
huge_page_size: u64 = 0;
|
||||||
|
meminfo: [4096] u8;
|
||||||
|
n := os_read_system_file("/proc/meminfo", meminfo.data, meminfo.count);
|
||||||
|
if n {
|
||||||
|
text := string.{n, meminfo.data};
|
||||||
|
at := find_substring(text, "Hugepagesize:");
|
||||||
|
if at >= 0 huge_page_size = parse_decimal(advance_string(text, at + 13)) * 1024;
|
||||||
|
}
|
||||||
|
huge_page_size = usable_huge_page_size(huge_page_size);
|
||||||
|
if huge_page_size {
|
||||||
|
// Hugepagesize only reports the configured size, not whether any huge pages are
|
||||||
|
// available. Probe by mapping one huge page with the flags real mappings use.
|
||||||
|
huge_shift: u64 = 0;
|
||||||
|
while (cast(u64) 1 << huge_shift) < huge_page_size huge_shift += 1;
|
||||||
|
probe_flags := MAP_PRIVATE_P | MAP_ANONYMOUS_P | MAP_HUGETLB_P | cast(s32)(huge_shift << MAP_HUGE_SHIFT_P);
|
||||||
|
probe := mmap_p(null, huge_page_size, PROT_READ_WRITE_P, probe_flags, -1, 0);
|
||||||
|
if probe != MAP_FAILED_P && probe {
|
||||||
|
munmap_p(probe, huge_page_size);
|
||||||
|
os_huge_pages = true;
|
||||||
|
os_huge_page_shift = huge_shift;
|
||||||
|
os_page_size = huge_page_size;
|
||||||
|
os_map_granularity = huge_page_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else #if OS == .MACOS && CPU == .X64 {
|
||||||
|
// Superpages are only supported on x86-64; Apple silicon has no huge page support.
|
||||||
|
os_huge_pages = true;
|
||||||
|
os_page_size = 2 * 1024 * 1024;
|
||||||
|
os_map_granularity = os_page_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transparent huge pages (2.0.1: rpmalloc.c:2921-2936, Linux only).
|
||||||
|
os_configure_thp :: () {
|
||||||
|
#if OS == .LINUX {
|
||||||
|
if global_config.enable_thp && !os_huge_pages && !global_config.disable_thp {
|
||||||
|
// With THP disabled system-wide ("never") madvise(MADV_HUGEPAGE) does nothing, so
|
||||||
|
// report THP as disabled rather than claim it is in use.
|
||||||
|
state: [64] u8;
|
||||||
|
n := os_read_system_file("/sys/kernel/mm/transparent_hugepage/enabled", state.data, state.count);
|
||||||
|
thp_never := n && find_substring(string.{n, state.data}, "[never]") >= 0;
|
||||||
|
if !thp_never os_thp = true;
|
||||||
|
}
|
||||||
|
if global_config.disable_thp prctl_p(PR_SET_THP_DISABLE_P, 1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .LINUX {
|
||||||
|
// Read a small (pseudo) file such as /proc/meminfo into a caller buffer with raw open/read, so
|
||||||
|
// nothing allocates during initialisation. Returns the bytes read (the buffer is always zero
|
||||||
|
// terminated), or 0.
|
||||||
|
os_read_system_file :: (path: string, buffer: *u8, capacity: s64) -> s64 { // 2.0.1: rpmalloc.c:2684
|
||||||
|
if capacity < 2 return 0;
|
||||||
|
fd := open_p(path.data, O_RDONLY_P | O_CLOEXEC_P); // path is a zero-terminated literal
|
||||||
|
if fd < 0 return 0;
|
||||||
|
total: s64 = 0;
|
||||||
|
while total + 1 < capacity {
|
||||||
|
got := read_p(fd, buffer + total, cast(u64)((capacity - 1) - total));
|
||||||
|
if got <= 0 break;
|
||||||
|
total += got;
|
||||||
|
}
|
||||||
|
close_p(fd);
|
||||||
|
buffer[total] = 0;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
find_substring :: (text: string, pattern: string) -> s64 {
|
||||||
|
if pattern.count > text.count return -1;
|
||||||
|
for i: 0..text.count - pattern.count {
|
||||||
|
match := true;
|
||||||
|
for j: 0..pattern.count-1 if text[i + j] != pattern[j] { match = false; break; }
|
||||||
|
if match return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
advance_string :: (text: string, n: s64) -> string {
|
||||||
|
if n >= text.count return "";
|
||||||
|
return string.{text.count - n, text.data + n};
|
||||||
|
}
|
||||||
|
|
||||||
|
// strtol(..., 10) for a non-negative number: skips leading blanks, stops at the first non-digit.
|
||||||
|
parse_decimal :: (text: string) -> u64 {
|
||||||
|
i := 0;
|
||||||
|
while i < text.count && (text[i] == #char " " || text[i] == #char "\t") i += 1;
|
||||||
|
value: u64 = 0;
|
||||||
|
while i < text.count && text[i] >= #char "0" && text[i] <= #char "9" {
|
||||||
|
value = value * 10 + (text[i] - #char "0");
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
libc_rpm :: #library,system "libc";
|
||||||
|
|
||||||
|
mmap_p :: (address: *void, length: u64, prot: s32, flags: s32, fd: s32, offset: s64) -> *void #foreign libc_rpm "mmap";
|
||||||
|
munmap_p :: (address: *void, length: u64) -> s32 #foreign libc_rpm "munmap";
|
||||||
|
madvise_p :: (address: *void, length: u64, advice: s32) -> s32 #foreign libc_rpm "madvise";
|
||||||
|
sysconf_p :: (name: s32) -> s64 #foreign libc_rpm "sysconf";
|
||||||
|
sched_yield_p :: () -> s32 #foreign libc_rpm "sched_yield";
|
||||||
|
|
||||||
|
timespec_p :: struct {
|
||||||
|
tv_sec: s64;
|
||||||
|
tv_nsec: s64;
|
||||||
|
}
|
||||||
|
clock_gettime_p :: (clock_id: s32, ts: *timespec_p) -> s32 #foreign libc_rpm "clock_gettime";
|
||||||
|
// 6 is CLOCK_MONOTONIC_COARSE on Linux (read through the vDSO, as upstream prefers) and
|
||||||
|
// CLOCK_MONOTONIC on macOS, which has no coarse clock.
|
||||||
|
CLOCK_MONOTONIC_FOR_CACHE :: 6;
|
||||||
|
|
||||||
|
MAP_FAILED_P :: cast(*void) -1;
|
||||||
|
PROT_READ_WRITE_P :: 0x1 | 0x2;
|
||||||
|
MAP_PRIVATE_P :: 0x0002;
|
||||||
|
|
||||||
|
#if OS == .MACOS {
|
||||||
|
errno_p :: () -> *s32 #foreign libc_rpm "__error";
|
||||||
|
MAP_ANONYMOUS_P :: 0x1000;
|
||||||
|
MAP_UNINITIALIZED_P :: 0;
|
||||||
|
_SC_PAGESIZE :: 29;
|
||||||
|
EAGAIN_P :: 35;
|
||||||
|
MADV_FREE_REUSABLE_P :: 7;
|
||||||
|
MADV_FREE_REUSE_P :: 8;
|
||||||
|
VM_MAKE_TAG_240 :: cast(s32)(240 << 24); // VM_MAKE_TAG(240U)
|
||||||
|
VM_FLAGS_SUPERPAGE_SIZE_2MB :: 2 << 16; // VM_FLAGS_SUPERPAGE_SIZE_2MB (x86-64 macOS)
|
||||||
|
} else {
|
||||||
|
prctl_p :: (option: s32, arg2: u64, arg3: u64, arg4: u64, arg5: u64) -> s32 #foreign libc_rpm "prctl";
|
||||||
|
MAP_ANONYMOUS_P :: 0x20;
|
||||||
|
MAP_UNINITIALIZED_P :: 0x400_0000; // Only honoured on no-MMU kernels.
|
||||||
|
MAP_HUGETLB_P :: 0x4_0000;
|
||||||
|
MAP_HUGE_SHIFT_P :: 26;
|
||||||
|
MADV_DONTNEED_P :: 4;
|
||||||
|
MADV_HUGEPAGE_P :: 14;
|
||||||
|
_SC_PAGESIZE :: 30;
|
||||||
|
PR_SET_VMA :: 0x5356_4d41;
|
||||||
|
PR_SET_VMA_ANON_NAME :: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread-local storage and huge page detection.
|
||||||
|
|
||||||
|
libc_tls :: #library,system "libc";
|
||||||
|
|
||||||
|
#if OS == .MACOS pthread_key_t_p :: u64; // unsigned long
|
||||||
|
else pthread_key_t_p :: u32; // unsigned int
|
||||||
|
|
||||||
|
tls_key: pthread_key_t_p;
|
||||||
|
|
||||||
|
pthread_key_create_p :: (key: *pthread_key_t_p, destructor: (value: *void) #c_call) -> s32 #foreign libc_tls "pthread_key_create";
|
||||||
|
pthread_key_delete_p :: (key: pthread_key_t_p) -> s32 #foreign libc_tls "pthread_key_delete";
|
||||||
|
pthread_getspecific_p :: (key: pthread_key_t_p) -> *void #foreign libc_tls "pthread_getspecific";
|
||||||
|
pthread_setspecific_p :: (key: pthread_key_t_p, value: *void) -> s32 #foreign libc_tls "pthread_setspecific";
|
||||||
|
pthread_self_p :: () -> *void #foreign libc_tls "pthread_self";
|
||||||
|
#if OS == .MACOS {
|
||||||
|
pthread_get_stackaddr_np_p :: (thread: *void) -> *void #foreign libc_tls "pthread_get_stackaddr_np";
|
||||||
|
pthread_get_stacksize_np_p :: (thread: *void) -> u64 #foreign libc_tls "pthread_get_stacksize_np";
|
||||||
|
} else {
|
||||||
|
pthread_getattr_np_p :: (thread: *void, attr: *u64) -> s32 #foreign libc_tls "pthread_getattr_np";
|
||||||
|
pthread_attr_getstack_p :: (attr: *u64, address: **void, size: *u64) -> s32 #foreign libc_tls "pthread_attr_getstack";
|
||||||
|
pthread_attr_destroy_p :: (attr: *u64) -> s32 #foreign libc_tls "pthread_attr_destroy";
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .LINUX {
|
||||||
|
open_p :: (path: *u8, flags: s32) -> s32 #foreign libc_tls "open";
|
||||||
|
read_p :: (fd: s32, buffer: *u8, count: u64) -> s64 #foreign libc_tls "read";
|
||||||
|
close_p :: (fd: s32) -> s32 #foreign libc_tls "close";
|
||||||
|
O_RDONLY_P :: 0;
|
||||||
|
O_CLOEXEC_P :: 0x8_0000;
|
||||||
|
PR_SET_THP_DISABLE_P :: 41;
|
||||||
|
}
|
||||||
334
modules/Default_Allocator/os_windows.jai
Normal file
334
modules/Default_Allocator/os_windows.jai
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
// OS entry points for Windows (2.0.1: rpmalloc.c:878-1161, the PLATFORM_WINDOWS branches),
|
||||||
|
// plus the Windows part of rpmalloc_initialize (2.0.1: rpmalloc.c:2765-2782).
|
||||||
|
//
|
||||||
|
// #jai deliberate differences from upstream, all in the error paths:
|
||||||
|
// - A failed map or commit returns null / non-zero without asserting (finding N1). Upstream
|
||||||
|
// asserts when no map_fail_callback is set and ENABLE_ASSERTS is on.
|
||||||
|
// - Every entry point works before initialize(): it initialises the OS globals on demand and
|
||||||
|
// tolerates a null global_memory_interface (used by the test hooks).
|
||||||
|
|
||||||
|
// Fills os_map_granularity, os_page_size and os_virtualalloc2. Idempotent.
|
||||||
|
// 2.0.1: rpmalloc.c:2765-2782 (the PLATFORM_WINDOWS part of rpmalloc_initialize)
|
||||||
|
os_initialize :: () {
|
||||||
|
if os_page_size return;
|
||||||
|
info: SYSTEM_INFO_W;
|
||||||
|
GetSystemInfo_w(*info);
|
||||||
|
os_map_granularity = info.dwAllocationGranularity;
|
||||||
|
os_virtualalloc2 = null;
|
||||||
|
// #jai GetModuleHandleA with an ASCII name; upstream uses the W variant. Same module.
|
||||||
|
kernelbase := GetModuleHandleA_w("kernelbase.dll".data);
|
||||||
|
if kernelbase os_virtualalloc2 = cast(VirtualAlloc2_Proc) GetProcAddress_w(kernelbase, "VirtualAlloc2".data);
|
||||||
|
os_page_size = info.dwPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_set_page_name :: (address: *void, size: u64) { // 2.0.1: rpmalloc.c:885 (Linux only; a no-op here)
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mmap :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void { // 2.0.1: rpmalloc.c:900
|
||||||
|
return os_map(size, alignment, offset, mapped_size, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai fix E9: a mapping that is committed as well, in the same call. Committing a range that is only
|
||||||
|
// reserved costs about 15 us per 24 MiB, and makes releasing it cost about 48 us instead of 7 us
|
||||||
|
// (MEM_RESERVE | MEM_COMMIT in one VirtualAlloc: 0 and 7 us). Huge blocks are committed whole at once
|
||||||
|
// anyway, so heap_allocate_block_huge maps them this way: churn_huge missed the huge cache on a quarter
|
||||||
|
// of its steps and spent 13 of its 19.6 us per step releasing such mappings.
|
||||||
|
os_mmap_committed :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void {
|
||||||
|
ptr := os_map(size, alignment, offset, mapped_size, true);
|
||||||
|
#if ENABLE_DECOMMIT if ptr statistics_os_commit(size);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_map :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64, commit: bool) -> *void {
|
||||||
|
os_initialize();
|
||||||
|
#if TEST_HOOKS if test_take_fault(*test_fail_maps) return os_map_failed(size, alignment, offset, mapped_size);
|
||||||
|
|
||||||
|
map_size := size + alignment; // UPSTREAM-BUG U1: wraps for sizes near U64_MAX.
|
||||||
|
// Ok to MEM_COMMIT: according to MSDN, "actual physical pages are not allocated unless/until the
|
||||||
|
// virtual addresses are actually accessed". But with decommit enabled it is better not to commit
|
||||||
|
// immediately and instead commit per page, to avoid saturating the OS commit limit.
|
||||||
|
#if ENABLE_DECOMMIT {
|
||||||
|
do_commit: u32 = ifx commit || global_config.disable_decommit then cast(u32) MEM_COMMIT_W else 0;
|
||||||
|
} else {
|
||||||
|
do_commit: u32 = MEM_COMMIT_W;
|
||||||
|
}
|
||||||
|
base_flags: u32 = MEM_RESERVE_W | do_commit;
|
||||||
|
large_flag: u32 = ifx os_huge_pages then cast(u32) MEM_LARGE_PAGES_W else 0;
|
||||||
|
ptr: *void;
|
||||||
|
if alignment && os_virtualalloc2 {
|
||||||
|
// Map an aligned region directly, so no alignment padding is kept per mapping.
|
||||||
|
reqs: MEM_ADDRESS_REQUIREMENTS_W;
|
||||||
|
reqs.Alignment = alignment;
|
||||||
|
param: MEM_EXTENDED_PARAMETER_W;
|
||||||
|
param.Type = MemExtendedParameterAddressRequirements;
|
||||||
|
param.Pointer = *reqs;
|
||||||
|
map_size = size;
|
||||||
|
ptr = os_virtualalloc2(null, null, map_size, large_flag | base_flags, PAGE_READWRITE_W, *param, 1);
|
||||||
|
if !ptr && large_flag
|
||||||
|
ptr = os_virtualalloc2(null, null, map_size, base_flags, PAGE_READWRITE_W, *param, 1);
|
||||||
|
} else {
|
||||||
|
// Pre Windows 10: over-allocate and offset for alignment below.
|
||||||
|
ptr = VirtualAlloc_w(null, map_size, large_flag | base_flags, PAGE_READWRITE_W);
|
||||||
|
if !ptr && large_flag
|
||||||
|
ptr = VirtualAlloc_w(null, map_size, base_flags, PAGE_READWRITE_W);
|
||||||
|
}
|
||||||
|
if !ptr return os_map_failed(size, alignment, offset, mapped_size);
|
||||||
|
|
||||||
|
if alignment {
|
||||||
|
padding := cast(u64) ptr & (alignment - 1);
|
||||||
|
if padding padding = alignment - padding;
|
||||||
|
rpmalloc_assert(padding <= alignment, "Internal failure in padding");
|
||||||
|
rpmalloc_assert(!(padding % 8), "Internal failure in padding");
|
||||||
|
ptr = pointer_offset(ptr, padding);
|
||||||
|
offset.* = padding;
|
||||||
|
}
|
||||||
|
mapped_size.* = map_size;
|
||||||
|
statistics_os_map(map_size); // 2.0.1: rpmalloc.c:1023-1033
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure tail of os_mmap (2.0.1: rpmalloc.c:990-998).
|
||||||
|
// #jai returns null without asserting when there is no map_fail_callback (finding N1).
|
||||||
|
os_map_failed :: (size: u64, alignment: u64, offset: *u64, mapped_size: *u64) -> *void {
|
||||||
|
iface := global_memory_interface;
|
||||||
|
if iface && iface.map_fail_callback && iface.map_fail_callback(size + alignment)
|
||||||
|
return os_mmap(size, alignment, offset, mapped_size);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_commit_on_demand :: inline () -> bool { // 2.0.1: rpmalloc.c:1040
|
||||||
|
#if ENABLE_DECOMMIT return !global_config.disable_decommit;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mcommit :: (address: *void, size: u64) -> s32 { // 2.0.1: rpmalloc.c:1049
|
||||||
|
#if ENABLE_DECOMMIT {
|
||||||
|
// Gated by callers on the per-span commit_on_demand flag. Spans committed in full at map
|
||||||
|
// time (including large pages, which cannot be partially committed) never reach here.
|
||||||
|
failed := false;
|
||||||
|
#if TEST_HOOKS failed = test_take_fault(*test_fail_commits);
|
||||||
|
if !failed failed = !VirtualAlloc_w(address, size, MEM_COMMIT_W, PAGE_READWRITE_W);
|
||||||
|
if failed {
|
||||||
|
iface := global_memory_interface;
|
||||||
|
if iface && iface.map_fail_callback && iface.map_fail_callback(size)
|
||||||
|
return os_mcommit(address, size);
|
||||||
|
// #jai no assert (finding N1); the caller handles the failure.
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
statistics_os_commit(size); // 2.0.1: rpmalloc.c:1067-1078
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_mdecommit :: (address: *void, size: u64) -> s32 { // 2.0.1: rpmalloc.c:1086
|
||||||
|
#if ENABLE_DECOMMIT {
|
||||||
|
// Unlike os_mcommit, honor the live flag (the Linux fallback can set it mid-run).
|
||||||
|
if global_config.disable_decommit return 1;
|
||||||
|
if !VirtualFree_w(address, size, MEM_DECOMMIT_W) {
|
||||||
|
rpmalloc_assert(false, "Failed to decommit virtual memory block");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
statistics_os_decommit(size); // 2.0.1: rpmalloc.c:1129-1133
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_munmap :: (address: *void, offset: u64, mapped_size: u64) { // 2.0.1: rpmalloc.c:1142
|
||||||
|
address = pointer_offset(address, -cast(s64) offset);
|
||||||
|
#if ENABLE_UNMAP {
|
||||||
|
if !VirtualFree_w(address, 0, MEM_RELEASE_W)
|
||||||
|
rpmalloc_assert(false, "Failed to unmap virtual memory block");
|
||||||
|
statistics_os_unmap(mapped_size); // 2.0.1: rpmalloc.c:1154-1158
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VirtualAlloc2 is resolved at run time from kernelbase.dll (Windows 10 and later).
|
||||||
|
VirtualAlloc2_Proc :: #type (process: *void, base_address: *void, size: u64, allocation_type: u32, page_protection: u32,
|
||||||
|
parameters: *MEM_EXTENDED_PARAMETER_W, parameter_count: u32) -> *void #c_call;
|
||||||
|
os_virtualalloc2: VirtualAlloc2_Proc; // 2.0.1: rpmalloc.c:77
|
||||||
|
|
||||||
|
MEM_ADDRESS_REQUIREMENTS_W :: struct {
|
||||||
|
LowestStartingAddress: *void;
|
||||||
|
HighestEndingAddress: *void;
|
||||||
|
Alignment: u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MEM_EXTENDED_PARAMETER: a 64-bit word (Type:8, Reserved:56) followed by a pointer-sized union.
|
||||||
|
MEM_EXTENDED_PARAMETER_W :: struct {
|
||||||
|
Type: u64;
|
||||||
|
Pointer: *void;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_thread_yield :: () { // 2.0.1: rpmalloc.c:396 (SwitchToThread)
|
||||||
|
SwitchToThread_w();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coarse monotonic time for the huge block cache.
|
||||||
|
os_monotonic_time_ms :: inline () -> u64 { // 2.0.1: rpmalloc.c:1586
|
||||||
|
return GetTickCount64_w();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Thread-local heap storage, thread identity and the thread exit hook.
|
||||||
|
//
|
||||||
|
// #jai upstream keeps the thread heap in a C11 _Thread_local variable and uses FLS only for the
|
||||||
|
// thread exit destructor. This Jai version has no #thread_local, and keeping the heap in the
|
||||||
|
// Context would let copied contexts share a heap between threads (review H3), so the heap pointer
|
||||||
|
// lives in an OS TLS slot (TlsAlloc). An FLS slot (FlsAlloc) carries the exit destructor, as
|
||||||
|
// upstream. Possible performance follow-up: read the TEB slot directly instead of calling TlsGetValue.
|
||||||
|
|
||||||
|
os_tls_create :: (destructor: (value: *void) #c_call) -> bool {
|
||||||
|
tls_index = TlsAlloc_w();
|
||||||
|
if tls_index == TLS_OUT_OF_INDEXES_W return false;
|
||||||
|
fls_index = FlsAlloc_w(destructor); // 2.0.1: rpmalloc.c:2952
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_destroy :: () {
|
||||||
|
FlsFree_w(fls_index); // 2.0.1: rpmalloc.c:3099
|
||||||
|
fls_index = 0;
|
||||||
|
TlsFree_w(tls_index);
|
||||||
|
tls_index = TLS_OUT_OF_INDEXES_W;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai fix E10: the calling thread's stack, [low, high).
|
||||||
|
os_thread_stack_range :: () -> low: u64, high: u64 {
|
||||||
|
low, high: u64;
|
||||||
|
GetCurrentThreadStackLimits_w(*low, *high);
|
||||||
|
return low, high;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_get :: inline () -> *void {
|
||||||
|
return TlsGetValue_w(tls_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
os_tls_set :: (value: *void) {
|
||||||
|
TlsSetValue_w(tls_index, value);
|
||||||
|
FlsSetValue_w(fls_index, value); // 2.0.1: rpmalloc.c:803
|
||||||
|
}
|
||||||
|
|
||||||
|
// #jai upstream returns the TEB address (NtCurrentTeb, rpmalloc.c:762). GetCurrentThreadId is
|
||||||
|
// equally unique among live threads, never 0, and needs no inline assembly.
|
||||||
|
// #jai fix U22: true while the process is exiting. ExitProcess stops every other thread first, so a
|
||||||
|
// lock one of them held stays held; the thread-exit destructor must not wait for it then.
|
||||||
|
os_process_exiting :: () -> bool {
|
||||||
|
return RtlDllShutdownInProgress_w() != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
os_thread_id :: inline () -> u64 {
|
||||||
|
return GetCurrentThreadId_w();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large page detection (2.0.1: rpmalloc.c:2798-2830).
|
||||||
|
os_detect_huge_pages :: () {
|
||||||
|
token: *void;
|
||||||
|
large_page_minimum := GetLargePageMinimum_w();
|
||||||
|
if large_page_minimum OpenProcessToken_w(GetCurrentProcess_w(), TOKEN_ADJUST_PRIVILEGES_W | TOKEN_QUERY_W, *token);
|
||||||
|
if token {
|
||||||
|
luid: LUID_W;
|
||||||
|
if LookupPrivilegeValueA_w(null, "SeLockMemoryPrivilege".data, *luid) {
|
||||||
|
privileges: TOKEN_PRIVILEGES_W;
|
||||||
|
privileges.PrivilegeCount = 1;
|
||||||
|
privileges.Privileges[0].Luid = luid;
|
||||||
|
privileges.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED_W;
|
||||||
|
if AdjustTokenPrivileges_w(token, 0, *privileges, 0, null, null) {
|
||||||
|
if GetLastError_w() == 0 os_huge_pages = true; // ERROR_SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle_w(token);
|
||||||
|
}
|
||||||
|
// Disable huge pages if the large page size is not a power of two that fits the builtin geometry.
|
||||||
|
if os_huge_pages && ((large_page_minimum & (large_page_minimum - 1)) || large_page_minimum > LARGE_PAGE_SIZE)
|
||||||
|
os_huge_pages = false;
|
||||||
|
if os_huge_pages {
|
||||||
|
if large_page_minimum > os_page_size os_page_size = large_page_minimum;
|
||||||
|
if large_page_minimum > os_map_granularity os_map_granularity = large_page_minimum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transparent huge pages are Linux only (2.0.1: rpmalloc.c:2921-2936).
|
||||||
|
os_configure_thp :: () { }
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
kernel32_rpm :: #library,system "kernel32";
|
||||||
|
|
||||||
|
SYSTEM_INFO_W :: struct {
|
||||||
|
wProcessorArchitecture: u16;
|
||||||
|
wReserved: u16;
|
||||||
|
dwPageSize: u32;
|
||||||
|
lpMinimumApplicationAddress: *void;
|
||||||
|
lpMaximumApplicationAddress: *void;
|
||||||
|
dwActiveProcessorMask: u64;
|
||||||
|
dwNumberOfProcessors: u32;
|
||||||
|
dwProcessorType: u32;
|
||||||
|
dwAllocationGranularity: u32;
|
||||||
|
wProcessorLevel: u16;
|
||||||
|
wProcessorRevision: u16;
|
||||||
|
}
|
||||||
|
|
||||||
|
MemExtendedParameterAddressRequirements :: 1;
|
||||||
|
|
||||||
|
MEM_COMMIT_W :: 0x0000_1000;
|
||||||
|
MEM_RESERVE_W :: 0x0000_2000;
|
||||||
|
MEM_DECOMMIT_W :: 0x0000_4000;
|
||||||
|
MEM_RELEASE_W :: 0x0000_8000;
|
||||||
|
MEM_LARGE_PAGES_W :: 0x2000_0000;
|
||||||
|
PAGE_READWRITE_W :: 0x04;
|
||||||
|
|
||||||
|
VirtualAlloc_w :: (address: *void, size: u64, type: u32, protect: u32) -> *void #foreign kernel32_rpm "VirtualAlloc";
|
||||||
|
VirtualFree_w :: (address: *void, size: u64, type: u32) -> s32 #foreign kernel32_rpm "VirtualFree";
|
||||||
|
GetSystemInfo_w :: (info: *SYSTEM_INFO_W) #foreign kernel32_rpm "GetSystemInfo";
|
||||||
|
GetModuleHandleA_w :: (name: *u8) -> *void #foreign kernel32_rpm "GetModuleHandleA";
|
||||||
|
GetProcAddress_w :: (module: *void, name: *u8) -> *void #foreign kernel32_rpm "GetProcAddress";
|
||||||
|
SwitchToThread_w :: () -> s32 #foreign kernel32_rpm "SwitchToThread";
|
||||||
|
GetTickCount64_w :: () -> u64 #foreign kernel32_rpm "GetTickCount64";
|
||||||
|
|
||||||
|
// Thread-local storage and huge page detection.
|
||||||
|
|
||||||
|
TLS_OUT_OF_INDEXES_W :: 0xFFFF_FFFF;
|
||||||
|
tls_index: u32 = TLS_OUT_OF_INDEXES_W;
|
||||||
|
fls_index: u32;
|
||||||
|
|
||||||
|
// The Windows LUID is {DWORD LowPart; LONG HighPart}: 4-byte aligned, so LUID_AND_ATTRIBUTES is 12
|
||||||
|
// bytes and TOKEN_PRIVILEGES.Privileges starts at offset 4. Declaring the LUID as a u64 shifts it
|
||||||
|
// to offset 8, which made large pages silently fail in the 1.4.4 Jai port (review M5).
|
||||||
|
LUID_W :: struct {
|
||||||
|
LowPart: u32;
|
||||||
|
HighPart: s32;
|
||||||
|
}
|
||||||
|
LUID_AND_ATTRIBUTES_W :: struct {
|
||||||
|
Luid: LUID_W;
|
||||||
|
Attributes: u32;
|
||||||
|
}
|
||||||
|
TOKEN_PRIVILEGES_W :: struct {
|
||||||
|
PrivilegeCount: u32;
|
||||||
|
Privileges: [1] LUID_AND_ATTRIBUTES_W;
|
||||||
|
}
|
||||||
|
#assert size_of(TOKEN_PRIVILEGES_W) == 16 "TOKEN_PRIVILEGES layout (review M5)";
|
||||||
|
|
||||||
|
TOKEN_ADJUST_PRIVILEGES_W :: 0x0020;
|
||||||
|
TOKEN_QUERY_W :: 0x0008;
|
||||||
|
SE_PRIVILEGE_ENABLED_W :: 0x0002;
|
||||||
|
|
||||||
|
kernel32_tls :: #library,system "kernel32";
|
||||||
|
GetCurrentThreadStackLimits_w :: (low: *u64, high: *u64) #foreign kernel32_tls "GetCurrentThreadStackLimits";
|
||||||
|
advapi32_tls :: #library,system "advapi32";
|
||||||
|
ntdll_tls :: #library,system "ntdll";
|
||||||
|
RtlDllShutdownInProgress_w :: () -> u8 #foreign ntdll_tls "RtlDllShutdownInProgress";
|
||||||
|
|
||||||
|
TlsAlloc_w :: () -> u32 #foreign kernel32_tls "TlsAlloc";
|
||||||
|
TlsFree_w :: (index: u32) -> s32 #foreign kernel32_tls "TlsFree";
|
||||||
|
TlsGetValue_w :: (index: u32) -> *void #foreign kernel32_tls "TlsGetValue";
|
||||||
|
TlsSetValue_w :: (index: u32, value: *void) -> s32 #foreign kernel32_tls "TlsSetValue";
|
||||||
|
FlsAlloc_w :: (callback: (value: *void) #c_call) -> u32 #foreign kernel32_tls "FlsAlloc";
|
||||||
|
FlsFree_w :: (index: u32) -> s32 #foreign kernel32_tls "FlsFree";
|
||||||
|
FlsSetValue_w :: (index: u32, value: *void) -> s32 #foreign kernel32_tls "FlsSetValue";
|
||||||
|
GetCurrentThreadId_w :: () -> u32 #foreign kernel32_tls "GetCurrentThreadId";
|
||||||
|
GetCurrentProcess_w :: () -> *void #foreign kernel32_tls "GetCurrentProcess";
|
||||||
|
GetLargePageMinimum_w :: () -> u64 #foreign kernel32_tls "GetLargePageMinimum";
|
||||||
|
GetLastError_w :: () -> u32 #foreign kernel32_tls "GetLastError";
|
||||||
|
CloseHandle_w :: (handle: *void) -> s32 #foreign kernel32_tls "CloseHandle";
|
||||||
|
OpenProcessToken_w :: (process: *void, access: u32, token: **void) -> s32 #foreign advapi32_tls "OpenProcessToken";
|
||||||
|
LookupPrivilegeValueA_w :: (system: *u8, name: *u8, luid: *LUID_W) -> s32 #foreign advapi32_tls "LookupPrivilegeValueA";
|
||||||
|
AdjustTokenPrivileges_w :: (token: *void, disable_all: s32, new_state: *TOKEN_PRIVILEGES_W, buffer_length: u32, previous: *void, return_length: *u32) -> s32 #foreign advapi32_tls "AdjustTokenPrivileges";
|
||||||
3490
modules/Default_Allocator/rpmalloc.jai
Normal file
3490
modules/Default_Allocator/rpmalloc.jai
Normal file
File diff suppressed because it is too large
Load Diff
48
modules/Default_Allocator/version.jai
Normal file
48
modules/Default_Allocator/version.jai
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// The version of this port, the same for both flavours (rpmalloc2 and Default_Allocator).
|
||||||
|
//
|
||||||
|
// VERSION is upstream's version followed by this port's revision. Raise VERSION_REVISION with every
|
||||||
|
// change to a file that install.jai installs: an installed copy whose revision is lower is behind,
|
||||||
|
// and install.jai updates it. Raise it here, in VERSION, and in CAPS_VERSION_STRING in module.jai and
|
||||||
|
// Default_Allocator/module.jai; compile-time asserts fail the build until all four agree. To see what
|
||||||
|
// an installed copy is, read this file in it, or the CAPS name its allocator reports
|
||||||
|
// ("rpmalloc 2.0.1-jai.N (Jai port)").
|
||||||
|
|
||||||
|
VERSION :: "2.0.1-jai.16";
|
||||||
|
VERSION_REVISION :: 16;
|
||||||
|
RPMALLOC_VERSION :: "2.0.1"; // The upstream rpmalloc release this is a port of.
|
||||||
|
|
||||||
|
#assert #run version_is(VERSION, RPMALLOC_VERSION, VERSION_REVISION)
|
||||||
|
"VERSION must be RPMALLOC_VERSION \"-jai.\" VERSION_REVISION";
|
||||||
|
|
||||||
|
#scope_module
|
||||||
|
|
||||||
|
// For the compile-time version asserts, here and in both module.jai files. #jai not tprint: they run
|
||||||
|
// as the target's code, and the x64 tprint uses SIMD assembly that a compiler on another CPU cannot
|
||||||
|
// execute (a Linux x64 build on Apple silicon failed). Plain loops run anywhere, and nothing allocates.
|
||||||
|
|
||||||
|
// Is `version` exactly upstream "-jai." revision?
|
||||||
|
version_is :: (version: string, upstream: string, revision: int) -> bool {
|
||||||
|
PREFIX :: "-jai.";
|
||||||
|
if !text_starts(version, 0, upstream) || !text_starts(version, upstream.count, PREFIX) return false;
|
||||||
|
at := upstream.count + PREFIX.count;
|
||||||
|
if at >= version.count return false;
|
||||||
|
n := 0;
|
||||||
|
for i: at..version.count-1 {
|
||||||
|
c := version[i];
|
||||||
|
if c < #char "0" || c > #char "9" return false;
|
||||||
|
n = n * 10 + (c - #char "0");
|
||||||
|
}
|
||||||
|
return n == revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is `text` exactly before, middle, after joined?
|
||||||
|
text_is :: (text: string, before: string, middle: string, after: string) -> bool {
|
||||||
|
if text.count != before.count + middle.count + after.count return false;
|
||||||
|
return text_starts(text, 0, before) && text_starts(text, before.count, middle) && text_starts(text, before.count + middle.count, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
text_starts :: (text: string, at: int, part: string) -> bool {
|
||||||
|
if at + part.count > text.count return false;
|
||||||
|
for 0..part.count-1 if text[at + it] != part[it] return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@ -372,8 +372,9 @@ emit_changes :: (using watcher: *$T/File_Watcher) -> changed: bool, needs_wait:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove moved the last change into each emitted one's slot, so the indices are rebuilt.
|
// remove moved the last change into each emitted one's slot, so the indices are rebuilt, in the
|
||||||
table_reset(*pending_index);
|
// table's own slots: this runs on every poll while changes wait.
|
||||||
|
table_reset_keeping_memory(*pending_index);
|
||||||
for changes table_add(*pending_index, it.full_path, it_index);
|
for changes table_add(*pending_index, it.full_path, it_index);
|
||||||
|
|
||||||
if changes.count == 0 {
|
if changes.count == 0 {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -102,7 +107,7 @@ labels_create :: (models_folder: string, model_name: string, prefer_gpu := true)
|
|||||||
engine := New(Labels_Engine);
|
engine := New(Labels_Engine);
|
||||||
engine.model = model.*;
|
engine.model = model.*;
|
||||||
api := ort.api;
|
api := ort.api;
|
||||||
ok, error := ort_check(api.CreateEnv(.ORT_LOGGING_LEVEL_WARNING, "labels", *engine.env));
|
ok, error := ort_check(api.CreateEnv(ort.log_level, "labels", *engine.env));
|
||||||
if !ok { labels_destroy(engine); return null, false, error; }
|
if !ok { labels_destroy(engine); return null, false, error; }
|
||||||
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
||||||
|
|
||||||
@ -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";
|
||||||
|
|||||||
137
modules/OCR/budget.jai
Normal file
137
modules/OCR/budget.jai
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// On the GPU, how many detector and recognizer runs share it at once: as many as fit in its memory.
|
||||||
|
// Each run reserves what it is expected to need before it starts, and waits while the runs in flight
|
||||||
|
// leave too little; one run always goes. A run's need grows with its input, and the engine measures
|
||||||
|
// how fast when it is made, so it holds for any model and GPU: from the arena's peak (onnxruntime's
|
||||||
|
// MaxInUse) over runs on two input sizes. The limit is a share of the GPU's free memory, read again at
|
||||||
|
// the start of every ocr_process call, as other engines and programs come and go. A run must also fit
|
||||||
|
// in what the GPU has free when it starts: the arenas keep finished runs' blocks until the stage ends
|
||||||
|
// (ocr_shrink_arena), a run of another size can need a new block beside them, and cuDNN and cuBLAS
|
||||||
|
// take memory of their own, so the reservations alone let the server model reach 15.4 GB of 16.
|
||||||
|
//
|
||||||
|
// Without it, gpu_workers runs went at once whatever the images: the server model, four large images
|
||||||
|
// at a time, took 15 GB of a 16 GB GPU, and on a smaller GPU Windows pages the excess into shared
|
||||||
|
// system memory, running it many times slower.
|
||||||
|
|
||||||
|
Ocr_Budget :: struct {
|
||||||
|
enabled: bool; // on CUDA, when the engine could measure its runs
|
||||||
|
limit: s64; // bytes the runs in flight may reserve together
|
||||||
|
in_use: s64; // reserved by the runs in flight; under mutex
|
||||||
|
headroom: s64; // bytes left free on the GPU, beside the limit and when a run starts
|
||||||
|
recent: [8] Ocr_Budget_Grant; // the last runs let go, under mutex: what the GPU's free memory may not show yet
|
||||||
|
mutex: Mutex;
|
||||||
|
det_fixed, det_per_pixel: float64; // a detector run's need: det_fixed + det_per_pixel * height * width
|
||||||
|
rec_fixed, rec_per_column: float64; // a recognizer run's: rec_fixed + rec_per_column * lines * width
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run let go, which may not have taken its memory yet.
|
||||||
|
Ocr_Budget_Grant :: struct { at: Apollo_Time; bytes: s64; }
|
||||||
|
OCR_BUDGET_GRANT_MS :: 50; // how long a run let go may take to take its memory
|
||||||
|
|
||||||
|
OCR_BUDGET_MARGIN :: 1.2; // estimates are raised by this much: other shapes, other kernels
|
||||||
|
OCR_BUDGET_HEADROOM :: 0.1; // of the free memory, left for everything else on the GPU
|
||||||
|
OCR_BUDGET_MIN_HEADROOM :: 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Measures what the detector's and recognizer's runs need, from runs on two sizes each. Called at
|
||||||
|
// creation, under ort_gpu_lock, after ocr_warm_up; on failure the budget is off and every worker runs.
|
||||||
|
ocr_budget_calibrate :: (engine: *Ocr_Engine) {
|
||||||
|
b := *engine.budget;
|
||||||
|
// What one run needs, from the arena's peak, run after run of growing size so each sets a new peak.
|
||||||
|
need :: (engine: *Ocr_Engine, session: *Ocr_Session, shape: [4] s64) -> s64, ok: bool {
|
||||||
|
before, _, ok := ort_gpu_arena_stats(session);
|
||||||
|
if !ok return 0, false;
|
||||||
|
count := shape[0] * shape[1] * shape[2] * shape[3];
|
||||||
|
input := NewArray(count, float, initialized = true,, context.default_allocator);
|
||||||
|
defer array_free(input,, context.default_allocator);
|
||||||
|
out, run_ok := ocr_run(session, engine.memory_info, input, shape);
|
||||||
|
if !run_ok return 0, false;
|
||||||
|
ocr_release_output(*out);
|
||||||
|
_, peak, stats_ok := ort_gpu_arena_stats(session);
|
||||||
|
if !stats_ok || peak <= before return 0, false;
|
||||||
|
return peak - before, true;
|
||||||
|
}
|
||||||
|
fit :: (x0: float64, y0: s64, x1: float64, y1: s64) -> fixed: float64, slope: float64 {
|
||||||
|
slope := max(cast(float64) (y1 - y0) / (x1 - x0), 0);
|
||||||
|
return max(y0 - slope * x0, 0), slope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The detector at 512 and 1024 px square; the recognizer on a full batch 256 and 1024 px wide.
|
||||||
|
d0, ok0 := need(engine, *engine.det, .[1, 3, 512, 512]);
|
||||||
|
d1, ok1 := need(engine, *engine.det, .[1, 3, 1024, 1024]);
|
||||||
|
lines := cast(s64) max(engine.model.rec_batch_gpu, 1);
|
||||||
|
height := cast(s64) engine.model.rec_height;
|
||||||
|
r0, ok2 := need(engine, *engine.rec, .[lines, 3, height, 256]);
|
||||||
|
r1, ok3 := need(engine, *engine.rec, .[lines, 3, height, 1024]);
|
||||||
|
ocr_shrink_arena(engine, *engine.det);
|
||||||
|
ocr_shrink_arena(engine, *engine.rec);
|
||||||
|
if !ok0 || !ok1 || !ok2 || !ok3 return;
|
||||||
|
b.det_fixed, b.det_per_pixel = fit(512.0 * 512, d0, 1024.0 * 1024, d1);
|
||||||
|
b.rec_fixed, b.rec_per_column = fit(cast(float64) lines * 256, r0, cast(float64) lines * 1024, r1);
|
||||||
|
init(*b.mutex);
|
||||||
|
b.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The share of the GPU's free memory the runs of this call may take; at the start of ocr_process.
|
||||||
|
ocr_budget_update :: (engine: *Ocr_Engine) {
|
||||||
|
b := *engine.budget;
|
||||||
|
if !b.enabled return;
|
||||||
|
free, _, ok := ort_cuda_memory();
|
||||||
|
if !ok return; // the last limit stands
|
||||||
|
headroom := max(cast(s64) (free * OCR_BUDGET_HEADROOM), OCR_BUDGET_MIN_HEADROOM);
|
||||||
|
lock(*b.mutex);
|
||||||
|
b.headroom = headroom;
|
||||||
|
b.limit = max(free - headroom, 0);
|
||||||
|
unlock(*b.mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_budget_detector :: (engine: *Ocr_Engine, height: s64, width: s64) -> s64 {
|
||||||
|
b := *engine.budget;
|
||||||
|
return cast(s64) ((b.det_fixed + b.det_per_pixel * height * width) * OCR_BUDGET_MARGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_budget_recognizer :: (engine: *Ocr_Engine, lines: s64, width: s64) -> s64 {
|
||||||
|
b := *engine.budget;
|
||||||
|
return cast(s64) ((b.rec_fixed + b.rec_per_column * lines * width) * OCR_BUDGET_MARGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits until bytes fit beside the runs in flight and in the GPU's free memory (at once when no run
|
||||||
|
// is in flight), and reserves them.
|
||||||
|
ocr_budget_reserve :: (engine: *Ocr_Engine, bytes: s64) {
|
||||||
|
b := *engine.budget;
|
||||||
|
if !b.enabled return;
|
||||||
|
waited := false;
|
||||||
|
while true {
|
||||||
|
free, _, free_ok := ort_cuda_memory();
|
||||||
|
lock(*b.mutex);
|
||||||
|
fits := b.in_use == 0;
|
||||||
|
if !fits && b.in_use + bytes <= b.limit {
|
||||||
|
// Runs let go in the last moments may not show in free yet.
|
||||||
|
now := current_time_monotonic();
|
||||||
|
pending: s64;
|
||||||
|
for b.recent if it.bytes && to_milliseconds(now - it.at) < OCR_BUDGET_GRANT_MS pending += it.bytes;
|
||||||
|
fits = !free_ok || bytes + pending <= free - b.headroom;
|
||||||
|
}
|
||||||
|
if fits {
|
||||||
|
b.in_use += bytes;
|
||||||
|
oldest := 0;
|
||||||
|
for b.recent if it.at < b.recent[oldest].at oldest = it_index;
|
||||||
|
b.recent[oldest] = .{current_time_monotonic(), bytes};
|
||||||
|
}
|
||||||
|
unlock(*b.mutex);
|
||||||
|
if fits break;
|
||||||
|
if !waited { waited = true; atomic_add(*engine.stats.budget_waits, 1); }
|
||||||
|
sleep_milliseconds(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_budget_release :: (engine: *Ocr_Engine, bytes: s64) {
|
||||||
|
b := *engine.budget;
|
||||||
|
if !b.enabled return;
|
||||||
|
lock(*b.mutex);
|
||||||
|
b.in_use -= bytes;
|
||||||
|
unlock(*b.mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_budget_destroy :: (engine: *Ocr_Engine) {
|
||||||
|
if engine.budget.enabled destroy(*engine.budget.mutex);
|
||||||
|
engine.budget = .{};
|
||||||
|
}
|
||||||
@ -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,34 @@ 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;
|
||||||
|
reserved := ocr_budget_detector(engine, h, w);
|
||||||
|
ocr_budget_reserve(engine, reserved); // budget.jai: waits while the GPU's memory is taken
|
||||||
|
out, ok, error = ocr_run(*engine.det, engine.memory_info, input, shape);
|
||||||
|
ocr_budget_release(engine, reserved);
|
||||||
|
if !ok return false, error;
|
||||||
|
probabilities, dims = out.data, array_view(out.dims, 0, out.dim_count);
|
||||||
|
}
|
||||||
|
if dims.count != 4 || dims[2] != h || dims[3] != w {
|
||||||
|
return false, tprint("The detector gave an output of shape %, not [1, 1, %, %].", dims, h, w);
|
||||||
}
|
}
|
||||||
|
|
||||||
regions := ocr_find_regions(out.data, w, valid_w, valid_h, model.det_threshold);
|
regions := ocr_find_regions(probabilities, w, valid_w, valid_h, model.det_threshold);
|
||||||
defer {
|
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 +280,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,21 +104,76 @@ ocr_create_sessions :: (engine: *Ocr_Engine, models_folder: string, gpu: bool, i
|
|||||||
model := *engine.model;
|
model := *engine.model;
|
||||||
ok: bool;
|
ok: bool;
|
||||||
error: string;
|
error: string;
|
||||||
engine.det, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.detector), gpu, intra_op_threads, engine.options);
|
// The detector's and recognizer's inputs change size with nearly every run: their arenas give back
|
||||||
|
// what no run is using once per call (ocr_shrink_arena), or they keep a block of every size.
|
||||||
|
engine.det, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.detector), gpu, intra_op_threads, engine.options, shrink_arena = true);
|
||||||
if !ok return false, error;
|
if !ok return false, error;
|
||||||
engine.rec, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.recognizer), gpu, intra_op_threads, engine.options);
|
engine.rec, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.recognizer), gpu, intra_op_threads, engine.options, shrink_arena = true);
|
||||||
if !ok return false, error;
|
if !ok return false, error;
|
||||||
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;
|
||||||
// PP-LCNet text line orientation takes 160 by 80; a model that says otherwise is taken at its word.
|
ocr_orientation_size(engine);
|
||||||
shape := engine.orientation.input_shape;
|
|
||||||
engine.orientation_height = ifx engine.orientation.input_rank == 4 && shape[2] > 0 then cast(s32) shape[2] else 80;
|
|
||||||
engine.orientation_width = ifx engine.orientation.input_rank == 4 && shape[3] > 0 then cast(s32) shape[3] else 160;
|
|
||||||
}
|
}
|
||||||
return true, "";
|
return true, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PP-LCNet text line orientation takes 160 by 80; a model that says otherwise is taken at its word.
|
||||||
|
ocr_orientation_size :: (engine: *Ocr_Engine) {
|
||||||
|
shape := engine.orientation.input_shape;
|
||||||
|
engine.orientation_height = ifx engine.orientation.input_rank == 4 && shape[2] > 0 then cast(s32) shape[2] else 80;
|
||||||
|
engine.orientation_width = ifx engine.orientation.input_rank == 4 && shape[3] > 0 then cast(s32) shape[3] else 160;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: the detector and recognizer through Core ML (modules/Core_ML) on the GPU, each run once on
|
||||||
|
// its smallest size, and the orientation classifier, if any, through onnxruntime on the CPU. On
|
||||||
|
// failure, why; what was made is left for ocr_release_coreml and ocr_release_sessions.
|
||||||
|
ocr_create_coreml :: (engine: *Ocr_Engine, models_folder: string, cores: s32) -> ok: bool, error: string {
|
||||||
|
#if OS != .MACOS {
|
||||||
|
return false, "Core ML is macOS's.";
|
||||||
|
} else {
|
||||||
|
model := *engine.model;
|
||||||
|
if !model.detector_coreml || !model.recognizer_coreml return false, tprint("there are no Core ML models for %; tools/export_ppocr_coreml.py makes them.", model.name);
|
||||||
|
for string.[model.detector_coreml, model.recognizer_coreml] {
|
||||||
|
if !file_exists(tprint("%/%", models_folder, it)) return false, tprint("% is not there; tools/export_ppocr_coreml.py makes it.", it);
|
||||||
|
}
|
||||||
|
ok: bool;
|
||||||
|
error: string;
|
||||||
|
engine.det_coreml, ok, error = coreml_load(tprint("%/%", models_folder, model.detector_coreml), "x", "y");
|
||||||
|
if !ok return false, error;
|
||||||
|
engine.rec_coreml, ok, error = coreml_load(tprint("%/%", models_folder, model.recognizer_coreml), "x", "y");
|
||||||
|
if !ok return false, error;
|
||||||
|
if model.textline_ori {
|
||||||
|
engine.orientation, ok, error = ocr_create_session(engine.env, tprint("%/%", models_folder, model.textline_ori), false, max(cores / 4, 1), engine.options);
|
||||||
|
if !ok return false, error;
|
||||||
|
ocr_orientation_size(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first runs, at the smallest sizes the packages take.
|
||||||
|
Warm_Up :: struct { model: *Coreml_Model; shape: [4] s64; }
|
||||||
|
runs := Warm_Up.[
|
||||||
|
.{engine.det_coreml, .[1, 3, model.coreml_det_height_step, model.coreml_det_width_step]},
|
||||||
|
.{engine.rec_coreml, .[model.coreml_rec_batch, 3, model.rec_height, model.coreml_rec_width_step]},
|
||||||
|
];
|
||||||
|
for runs {
|
||||||
|
input := NewArray(it.shape[0] * it.shape[1] * it.shape[2] * it.shape[3], float, initialized = true,, context.default_allocator);
|
||||||
|
defer array_free(input,, context.default_allocator);
|
||||||
|
out, run_ok, run_error := coreml_predict(it.model, input, it.shape);
|
||||||
|
if !run_ok return false, tprint("Core ML could not run the % on the GPU: %", ifx it_index == 0 then "detector" else "recognizer", run_error);
|
||||||
|
coreml_release_output(*out);
|
||||||
|
}
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_release_coreml :: (engine: *Ocr_Engine) {
|
||||||
|
#if OS == .MACOS {
|
||||||
|
coreml_free(engine.det_coreml);
|
||||||
|
coreml_free(engine.rec_coreml);
|
||||||
|
engine.det_coreml, engine.rec_coreml = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ocr_release_sessions :: (engine: *Ocr_Engine) {
|
ocr_release_sessions :: (engine: *Ocr_Engine) {
|
||||||
ocr_release_session(*engine.det);
|
ocr_release_session(*engine.det);
|
||||||
ocr_release_session(*engine.rec);
|
ocr_release_session(*engine.rec);
|
||||||
@ -115,6 +185,19 @@ ocr_release_sessions :: (engine: *Ocr_Engine) {
|
|||||||
// engine destroyed without ever having run left the next engine to crash in cublasLtCtxInit, with a
|
// engine destroyed without ever having run left the next engine to crash in cublasLtCtxInit, with a
|
||||||
// DEP access violation, on its first run; an engine that has run once leaves nothing broken. It also
|
// DEP access violation, on its first run; an engine that has run once leaves nothing broken. It also
|
||||||
// moves the first run's setup into creation. On failure, the reason.
|
// moves the first run's setup into creation. On failure, the reason.
|
||||||
|
// On CUDA, the session's arena (the detector's or the recognizer's) gives back every block no run is
|
||||||
|
// using: called by ocr_process once a stage's runs are all done, as a run on a small input, since
|
||||||
|
// onnxruntime shrinks an arena only at the end of a run. Once per run instead cost a third to a half
|
||||||
|
// of OCR's speed.
|
||||||
|
ocr_shrink_arena :: (engine: *Ocr_Engine, session: *Ocr_Session) {
|
||||||
|
if engine.provider != .CUDA || !session.session return;
|
||||||
|
side := ifx session == *engine.rec then cast(s64) engine.model.rec_height else 32;
|
||||||
|
shape := s64.[1, 3, side, side];
|
||||||
|
input := NewArray(3 * side * side, float, initialized = true,, temp);
|
||||||
|
out, ok := ocr_run(session, engine.memory_info, input, shape, shrink = true);
|
||||||
|
if ok ocr_release_output(*out);
|
||||||
|
}
|
||||||
|
|
||||||
ocr_warm_up :: (engine: *Ocr_Engine) -> ok: bool, error: string {
|
ocr_warm_up :: (engine: *Ocr_Engine) -> ok: bool, error: string {
|
||||||
Warm_Up :: struct { session: *Ocr_Session; height, width: s64; }
|
Warm_Up :: struct { session: *Ocr_Session; height, width: s64; }
|
||||||
runs := Warm_Up.[
|
runs := Warm_Up.[
|
||||||
@ -135,8 +218,8 @@ ocr_warm_up :: (engine: *Ocr_Engine) -> ok: bool, error: string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A session for the model file, set up as Ocr_Options says (modules/onnxruntime/session.jai).
|
// A session for the model file, set up as Ocr_Options says (modules/onnxruntime/session.jai).
|
||||||
ocr_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads: s32, ocr_options: Ocr_Options) -> Ocr_Session, ok: bool, error: string {
|
ocr_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads: s32, ocr_options: Ocr_Options, shrink_arena := false) -> Ocr_Session, ok: bool, error: string {
|
||||||
settings := Ort_Gpu_Settings.{arena_extend_strategy = ocr_options.cuda_arena_extend_strategy, cudnn_max_workspace = ocr_options.cudnn_max_workspace};
|
settings := Ort_Gpu_Settings.{arena_extend_strategy = ocr_options.cuda_arena_extend_strategy, cudnn_max_workspace = ocr_options.cudnn_max_workspace, shrink_arena = shrink_arena};
|
||||||
session, ok, error := ort_create_session(env, path, gpu, intra_op_threads, settings, missing_hint = "run `jai fetch_models.jai` in modules/OCR and rebuild");
|
session, ok, error := ort_create_session(env, path, gpu, intra_op_threads, settings, missing_hint = "run `jai fetch_models.jai` in modules/OCR and rebuild");
|
||||||
return session, ok, error;
|
return session, ok, error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
@ -47,6 +48,8 @@ Ocr_Engine :: struct {
|
|||||||
rec_batch: s32; // text lines per recognizer run: the model's rec_batch_gpu or rec_batch_cpu
|
rec_batch: s32; // text lines per recognizer run: the model's rec_batch_gpu or rec_batch_cpu
|
||||||
options: Ocr_Options;
|
options: Ocr_Options;
|
||||||
stats: Ocr_Stats; // since creation, or the last ocr_reset_stats
|
stats: Ocr_Stats; // since creation, or the last ocr_reset_stats
|
||||||
|
pool: Ocr_Pool; // the worker threads, for the engine's life (run_on_workers)
|
||||||
|
budget: Ocr_Budget; // on CUDA: how many runs fit in GPU memory at once (budget.jai)
|
||||||
|
|
||||||
// When set, and true, ocr_process stops starting work: the batch of lines or image each worker is
|
// When set, and true, ocr_process stops starting work: the batch of lines or image each worker is
|
||||||
// on is finished, and then every image fails as "cancelled" (none is part read). Set by the caller.
|
// on is finished, and then every image fails as "cancelled" (none is part read). Set by the caller.
|
||||||
@ -67,18 +70,19 @@ Ocr_Stats :: struct {
|
|||||||
recognize_us: s64; // cropping lines, the recognizer, and decoding the text
|
recognize_us: s64; // cropping lines, the recognizer, and decoding the text
|
||||||
orientation_us: s64; // cropping lines and the orientation classifier
|
orientation_us: s64; // cropping lines and the orientation classifier
|
||||||
turned: s64; // lines that read better the other way up, or level as found
|
turned: s64; // lines that read better the other way up, or level as found
|
||||||
|
budget_waits: s64; // detector and recognizer runs that waited for GPU memory (budget.jai)
|
||||||
}
|
}
|
||||||
|
|
||||||
ocr_stats :: (engine: *Ocr_Engine) -> Ocr_Stats { return engine.stats; }
|
ocr_stats :: (engine: *Ocr_Engine) -> Ocr_Stats { return engine.stats; }
|
||||||
ocr_reset_stats :: (engine: *Ocr_Engine) { engine.stats = .{}; }
|
ocr_reset_stats :: (engine: *Ocr_Engine) { engine.stats = .{}; }
|
||||||
|
|
||||||
// How an engine runs on the GPU, beyond its model's models.config entry. The defaults are what ran
|
// How an engine runs on the GPU, beyond its model's models.config entry. Nearly every image and
|
||||||
// fastest on the 20 sample captures (RTX 5080 laptop, onnxruntime 1.30.0): nearly every image and
|
// batch is a different size. onnxruntime's arena keeps each block it takes from CUDA and never joins
|
||||||
// batch is a different size, and with onnxruntime's exact-size arena and cuDNN's largest workspaces
|
// or frees them, so inputs that grow one run after another leave a block of every size behind: the
|
||||||
// GPU memory filled up and the server model ran several times slower.
|
// runs go largest first (ocr_process) so later ones fit in what the first took.
|
||||||
Ocr_Options :: struct {
|
Ocr_Options :: struct {
|
||||||
gpu_workers: s32 = 4; // threads preparing images and decoding results while the GPU runs the networks; more let the server model fill GPU memory
|
gpu_workers: s32 = 4; // threads preparing images and decoding results while the GPU runs the networks
|
||||||
cuda_arena_extend_strategy := "kNextPowerOfTwo"; // reuses freed blocks for other sizes; "kSameAsRequested" grows by exactly what is asked
|
cuda_arena_extend_strategy := "kSameAsRequested"; // how much the arena takes when it runs short: what is asked; "kNextPowerOfTwo" takes twice as much each time, up to twice what the runs need
|
||||||
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
|
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
|
||||||
rec_width_step: s32 = 64; // a recognizer batch's width is rounded up to a multiple of this, so shapes recur
|
rec_width_step: s32 = 64; // a recognizer batch's width is rounded up to a multiple of this, so shapes recur
|
||||||
}
|
}
|
||||||
@ -109,18 +113,31 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
|
|||||||
engine.dictionary = entries;
|
engine.dictionary = entries;
|
||||||
|
|
||||||
api := ort.api;
|
api := ort.api;
|
||||||
ok, error := ort_check(api.CreateEnv(.ORT_LOGGING_LEVEL_WARNING, "ocr", *engine.env));
|
ok, error := ort_check(api.CreateEnv(ort.log_level, "ocr", *engine.env));
|
||||||
if !ok { ocr_destroy(engine); return null, false, error; }
|
if !ok { ocr_destroy(engine); return null, false, error; }
|
||||||
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
||||||
|
|
||||||
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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +148,10 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
|
|||||||
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2);
|
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2);
|
||||||
if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine);
|
if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine);
|
||||||
if !sessions_ok ocr_release_sessions(engine);
|
if !sessions_ok ocr_release_sessions(engine);
|
||||||
|
if sessions_ok {
|
||||||
|
engine.provider = .CUDA; // before calibrating: ocr_shrink_arena only works on CUDA
|
||||||
|
ocr_budget_calibrate(engine);
|
||||||
|
}
|
||||||
ort_gpu_unlock();
|
ort_gpu_unlock();
|
||||||
if sessions_ok {
|
if sessions_ok {
|
||||||
engine.provider = .CUDA;
|
engine.provider = .CUDA;
|
||||||
@ -149,6 +170,7 @@ ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, op
|
|||||||
engine.provider = .CPU;
|
engine.provider = .CPU;
|
||||||
engine.rec_batch = max(engine.model.rec_batch_cpu, 1);
|
engine.rec_batch = max(engine.model.rec_batch_cpu, 1);
|
||||||
}
|
}
|
||||||
|
ocr_pool_start(engine);
|
||||||
return engine, true, "";
|
return engine, true, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,14 +180,26 @@ ocr_provider :: (engine: *Ocr_Engine) -> Ocr_Provider {
|
|||||||
|
|
||||||
// Whether the GPU is in use, and when not, why, and what to install so it can be.
|
// 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) {
|
||||||
if !engine return;
|
if !engine return;
|
||||||
|
ocr_pool_stop(engine);
|
||||||
|
ocr_budget_destroy(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);
|
||||||
@ -177,6 +211,7 @@ ocr_destroy :: (engine: *Ocr_Engine) {
|
|||||||
ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.allocator) -> [] Ocr_Result {
|
ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.allocator) -> [] Ocr_Result {
|
||||||
results := NewArray(images.count, Ocr_Result,, allocator);
|
results := NewArray(images.count, Ocr_Result,, allocator);
|
||||||
if !images.count return results;
|
if !images.count return results;
|
||||||
|
ocr_budget_update(engine); // budget.jai: the GPU memory this call's runs may share
|
||||||
|
|
||||||
// Detection: images shared out among the workers.
|
// Detection: images shared out among the workers.
|
||||||
per_image := NewArray(images.count, Image_Work,, context.default_allocator);
|
per_image := NewArray(images.count, Image_Work,, context.default_allocator);
|
||||||
@ -185,13 +220,22 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.
|
|||||||
array_free(per_image,, context.default_allocator);
|
array_free(per_image,, context.default_allocator);
|
||||||
}
|
}
|
||||||
for * per_image it.image = *images[it_index];
|
for * per_image it.image = *images[it_index];
|
||||||
run_on_workers(engine, per_image.count, (engine: *Ocr_Engine, index: s64, data: *void) {
|
// The largest first: the detector's arena then holds a block that fits every later image
|
||||||
work := cast(*Image_Work) data + index;
|
// (Ocr_Options). Smallest first, it took a new one for each larger image and kept them all.
|
||||||
|
detect_order := NewArray(per_image.count, *Image_Work,, context.default_allocator);
|
||||||
|
defer array_free(detect_order,, context.default_allocator);
|
||||||
|
for * per_image detect_order[it_index] = it;
|
||||||
|
quick_sort(detect_order, (a: *Image_Work, b: *Image_Work) -> s64 {
|
||||||
|
return cast(s64) b.image.width * b.image.height - cast(s64) a.image.width * a.image.height;
|
||||||
|
});
|
||||||
|
run_on_workers(engine, detect_order.count, (engine: *Ocr_Engine, index: s64, data: *void) {
|
||||||
|
work := (cast(**Image_Work) data)[index];
|
||||||
start := current_time_monotonic();
|
start := current_time_monotonic();
|
||||||
work.boxes, work.ok, work.error = ocr_detect(engine, work.image.*);
|
work.boxes, work.ok, work.error = ocr_detect(engine, work.image.*);
|
||||||
atomic_add(*engine.stats.detect_us, to_microseconds(current_time_monotonic() - start));
|
atomic_add(*engine.stats.detect_us, to_microseconds(current_time_monotonic() - start));
|
||||||
if work.error work.error = copy_string(work.error,, context.default_allocator);
|
if work.error work.error = copy_string(work.error,, context.default_allocator);
|
||||||
}, per_image.data);
|
}, detect_order.data);
|
||||||
|
ocr_shrink_arena(engine, *engine.det); // before recognition, so the two stages' blocks are not held at once
|
||||||
if ocr_cancelled(engine) return ocr_all_cancelled(results, allocator);
|
if ocr_cancelled(engine) return ocr_all_cancelled(results, allocator);
|
||||||
|
|
||||||
// Recognition: every line of every image, batched by width so little of each batch is padding.
|
// Recognition: every line of every image, batched by width so little of each batch is padding.
|
||||||
@ -247,7 +291,10 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.
|
|||||||
order := NewArray(jobs.count, *Line_Job,, context.default_allocator);
|
order := NewArray(jobs.count, *Line_Job,, context.default_allocator);
|
||||||
defer array_free(order,, context.default_allocator);
|
defer array_free(order,, context.default_allocator);
|
||||||
for * jobs order[it_index] = it;
|
for * jobs order[it_index] = it;
|
||||||
quick_sort(order, (a: *Line_Job, b: *Line_Job) -> s64 { return a.width - b.width; });
|
// Widest first, so each batch fits in the arena block the first one took (Ocr_Options). Narrowest
|
||||||
|
// first, every batch was wider than the last and took a new block: 5.7 GB of them for the mobile
|
||||||
|
// model on gpu-bench's 30 images, where the widest batch needs 0.9 GB.
|
||||||
|
quick_sort(order, (a: *Line_Job, b: *Line_Job) -> s64 { return b.width - a.width; });
|
||||||
|
|
||||||
batch_size := max(engine.rec_batch, 1);
|
batch_size := max(engine.rec_batch, 1);
|
||||||
batch_count := (order.count + batch_size - 1) / batch_size;
|
batch_count := (order.count + batch_size - 1) / batch_size;
|
||||||
@ -281,6 +328,8 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ocr_shrink_arena(engine, *engine.rec); // models.jai: what this call's runs took, given back
|
||||||
|
|
||||||
// Each image's lines, in reading order.
|
// Each image's lines, in reading order.
|
||||||
for work, image_index: per_image {
|
for work, image_index: per_image {
|
||||||
result := *results[image_index];
|
result := *results[image_index];
|
||||||
@ -337,53 +386,99 @@ Image_Work :: struct {
|
|||||||
Work_Proc :: #type (engine: *Ocr_Engine, index: s64, data: *void);
|
Work_Proc :: #type (engine: *Ocr_Engine, index: s64, data: *void);
|
||||||
|
|
||||||
// Calls proc for each index below count, on engine.workers threads at once.
|
// Calls proc for each index below count, on engine.workers threads at once.
|
||||||
run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void) {
|
// The engine's worker threads: started with it and kept until ocr_destroy, never one set per call.
|
||||||
if count <= 0 return;
|
// onnxruntime (1.26 to 1.30 at least) sets up a CUDA stream with cuBLAS and cuDNN handles for every
|
||||||
Shared :: struct { engine: *Ocr_Engine; count: s64; next: s64; proc: Work_Proc; data: *void; }
|
// OS thread that runs a session, about 10 MB each, kept after the thread ends (microsoft/onnxruntime
|
||||||
shared := Shared.{engine = engine, count = count, proc = proc, data = data};
|
// #33037). Workers made afresh for every stage of every call, as they were, gave the OCR sessions
|
||||||
|
// thousands of threads in a scan, and starting them cost time: 6% on gpu-bench's mobile OCR.
|
||||||
|
Ocr_Pool :: struct {
|
||||||
|
threads: [] Thread;
|
||||||
|
started: s64;
|
||||||
|
mutex: Mutex; // one run_on_workers at a time
|
||||||
|
wake: Semaphore; // signalled once per worker for each job, and for quitting
|
||||||
|
done: Semaphore; // each worker signals it when it has finished its part of a job
|
||||||
|
quit: bool;
|
||||||
|
// The job: items 0..count-1, taken in turn.
|
||||||
|
count, next: s64;
|
||||||
|
proc: Work_Proc;
|
||||||
|
data: *void;
|
||||||
|
}
|
||||||
|
|
||||||
worker_proc :: (thread: *Thread) -> s64 {
|
ocr_pool_start :: (engine: *Ocr_Engine) {
|
||||||
// The context comes from the calling thread, allocator included, and that may be its temporary
|
pool := *engine.pool;
|
||||||
// allocator, which belongs to that thread alone. Workers allocate from the heap, and use their
|
init(*pool.mutex);
|
||||||
// own temporary storage.
|
init(*pool.wake);
|
||||||
context.allocator = context.default_allocator;
|
init(*pool.done);
|
||||||
shared := cast(*Shared) thread.data;
|
// thread_init allocates the thread's temporary storage from the context allocator.
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
pool.threads = NewArray(max(engine.workers, 1), Thread);
|
||||||
|
for * pool.threads {
|
||||||
|
if !thread_init(it, ocr_pool_worker, temporary_storage_size = 1024 * 1024) break;
|
||||||
|
thread_start(it, engine); // sets thread.data
|
||||||
|
pool.started += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_pool_stop :: (engine: *Ocr_Engine) {
|
||||||
|
pool := *engine.pool;
|
||||||
|
if !pool.threads.count return;
|
||||||
|
pool.quit = true;
|
||||||
|
for 1..pool.started signal(*pool.wake);
|
||||||
|
for 0..pool.started - 1 {
|
||||||
|
thread_is_done(*pool.threads[it], -1);
|
||||||
|
thread_deinit(*pool.threads[it]);
|
||||||
|
}
|
||||||
|
array_free(pool.threads,, context.default_allocator);
|
||||||
|
destroy(*pool.wake);
|
||||||
|
destroy(*pool.done);
|
||||||
|
destroy(*pool.mutex);
|
||||||
|
pool.* = .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
ocr_pool_worker :: (thread: *Thread) -> s64 {
|
||||||
|
// Workers allocate from the heap, and use their own temporary storage.
|
||||||
|
context.allocator = context.default_allocator;
|
||||||
|
engine := cast(*Ocr_Engine) thread.data;
|
||||||
|
pool := *engine.pool;
|
||||||
|
while true {
|
||||||
|
Default_Allocator.thread_idle(); // parked until the next job: give back what this thread's heap holds free
|
||||||
|
wait_for(*pool.wake);
|
||||||
|
if pool.quit break;
|
||||||
while true {
|
while true {
|
||||||
if ocr_cancelled(shared.engine) break; // what is left fails as cancelled (ocr_process)
|
if ocr_cancelled(engine) break; // what is left fails as cancelled (ocr_process)
|
||||||
index := atomic_add(*shared.next, 1);
|
index := atomic_add(*pool.next, 1);
|
||||||
if index >= shared.count break;
|
if index >= pool.count break;
|
||||||
shared.proc(shared.engine, index, shared.data);
|
pool.proc(engine, index, pool.data);
|
||||||
reset_temporary_storage();
|
reset_temporary_storage();
|
||||||
}
|
}
|
||||||
return 0;
|
signal(*pool.done);
|
||||||
}
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// thread_init allocates the thread's temporary storage from the context allocator and tells it
|
run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void) {
|
||||||
// the thread is starting; the caller's may be a temporary allocator, which refuses that.
|
if count <= 0 return;
|
||||||
push_allocator(context.default_allocator);
|
pool := *engine.pool;
|
||||||
thread_count := min(cast(s64) engine.workers, count);
|
if !pool.started { // no threads: do it here
|
||||||
threads := NewArray(thread_count, Thread,, context.default_allocator);
|
for 0..count - 1 {
|
||||||
defer array_free(threads,, context.default_allocator);
|
if ocr_cancelled(engine) break;
|
||||||
started := 0;
|
proc(engine, it, data);
|
||||||
for * threads {
|
}
|
||||||
if !thread_init(it, worker_proc, temporary_storage_size = 1024 * 1024) break;
|
return;
|
||||||
thread_start(it, *shared); // sets thread.data
|
|
||||||
started += 1;
|
|
||||||
}
|
|
||||||
if !started { // no threads: do it here
|
|
||||||
here: Thread;
|
|
||||||
here.data = *shared;
|
|
||||||
worker_proc(*here);
|
|
||||||
}
|
|
||||||
for 0..started - 1 {
|
|
||||||
thread_is_done(*threads[it], -1);
|
|
||||||
thread_deinit(*threads[it]);
|
|
||||||
}
|
}
|
||||||
|
lock(*pool.mutex);
|
||||||
|
defer unlock(*pool.mutex);
|
||||||
|
pool.count, pool.next, pool.proc, pool.data = count, 0, proc, data;
|
||||||
|
// Every worker is woken and reports back, even with fewer items than workers: a worker that finds
|
||||||
|
// nothing left reports at once, so no wake is left over for the next job.
|
||||||
|
for 1..pool.started signal(*pool.wake);
|
||||||
|
for 1..pool.started wait_for(*pool.done);
|
||||||
}
|
}
|
||||||
|
|
||||||
#load "models.jai";
|
#load "models.jai";
|
||||||
#load "detect.jai";
|
#load "detect.jai";
|
||||||
#load "recognize.jai";
|
#load "recognize.jai";
|
||||||
|
#load "budget.jai";
|
||||||
|
|
||||||
#import "Basic";
|
#import "Basic";
|
||||||
#import "String";
|
#import "String";
|
||||||
@ -395,6 +490,8 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void
|
|||||||
#import "Thread";
|
#import "Thread";
|
||||||
#import "Atomics";
|
#import "Atomics";
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
|
Default_Allocator :: #import "Default_Allocator"; // rpmalloc (modules/Default_Allocator), for thread_idle where the workers wait
|
||||||
|
#if OS == .MACOS #import "Core_ML";
|
||||||
#if OS == .WINDOWS {
|
#if OS == .WINDOWS {
|
||||||
#import "Windows";
|
#import "Windows";
|
||||||
#import "Windows_Utf8";
|
#import "Windows_Utf8";
|
||||||
|
|||||||
@ -29,21 +29,47 @@ 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;
|
||||||
|
reserved := ocr_budget_recognizer(engine, rows, width);
|
||||||
|
ocr_budget_reserve(engine, reserved); // budget.jai: waits while the GPU's memory is taken
|
||||||
|
out, ok, error = ocr_run(*engine.rec, engine.memory_info, input, shape);
|
||||||
|
ocr_budget_release(engine, reserved);
|
||||||
|
if !ok return false, error;
|
||||||
|
scores, dims = out.data, array_view(out.dims, 0, out.dim_count);
|
||||||
|
}
|
||||||
|
if dims.count != 3 || dims[0] != rows return false, tprint("The recognizer gave an output of shape %.", dims);
|
||||||
|
steps := dims[1];
|
||||||
|
classes := dims[2];
|
||||||
if classes != engine.dictionary.count + 2 {
|
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 +81,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, "";
|
||||||
|
}
|
||||||
263
modules/Screen_Record/audio_source.jai
Normal file
263
modules/Screen_Record/audio_source.jai
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
// Sound for a recording, with WASAPI: what the computer plays (the default output device, in loopback)
|
||||||
|
// and the microphone (the default input device). Each is asked for in shared mode as 48 kHz stereo
|
||||||
|
// float, which Windows converts any device's own format to (AUTOCONVERTPCM), so nothing here
|
||||||
|
// resamples. Where a device will not convert, its own format is taken if it is 48 kHz float, and its
|
||||||
|
// channels are made stereo here.
|
||||||
|
//
|
||||||
|
// Each packet comes with the QueryPerformanceCounter time its first sample was heard, which places it
|
||||||
|
// on the recording's timeline (module.jai's audio thread mixes them). Loopback sends nothing while
|
||||||
|
// nothing plays: those gaps are silence. A device unplugged mid-recording (a headset) is opened
|
||||||
|
// again, from the new default, once a second.
|
||||||
|
|
||||||
|
AUDIO_RATE :: 48000;
|
||||||
|
AUDIO_CHANNELS :: 2;
|
||||||
|
|
||||||
|
Audio_Source_Kind :: enum u8 { SYSTEM; MICROPHONE; }
|
||||||
|
|
||||||
|
Audio_Source :: struct {
|
||||||
|
kind: Audio_Source_Kind;
|
||||||
|
name: string; // "Speakers (Realtek(R) Audio)"; heap
|
||||||
|
client: *Audio_Client;
|
||||||
|
capture: *Audio_Capture_Client;
|
||||||
|
channels: s32; // as the device gives them: 2, unless it would not convert
|
||||||
|
open: bool;
|
||||||
|
lost_at: s64; // when it was lost (ticks), to open it again a second on; 0 while open
|
||||||
|
error: string; // why it could not be opened; heap
|
||||||
|
}
|
||||||
|
|
||||||
|
Audio_Packet :: struct {
|
||||||
|
samples: [] float; // interleaved stereo, AUDIO_CHANNELS a frame; empty for silence
|
||||||
|
frames: s64;
|
||||||
|
ticks: s64; // when its first frame was heard, in QueryPerformanceCounter ticks
|
||||||
|
silent: bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default device of its kind, started. False with why: none, or it will not open.
|
||||||
|
audio_open :: (s: *Audio_Source, kind: Audio_Source_Kind) -> ok: bool, error: string {
|
||||||
|
s.kind = kind;
|
||||||
|
enumerator: *Device_Enumerator;
|
||||||
|
hr := audio_CoCreateInstance(*CLSID_MM_DEVICE_ENUMERATOR, null, CLSCTX_ALL, *IID_MM_DEVICE_ENUMERATOR, xx *enumerator);
|
||||||
|
if FAILED(hr) return false, hresult_text("the audio device list", hr);
|
||||||
|
defer com_release(*enumerator);
|
||||||
|
device: *Audio_Device;
|
||||||
|
flow: s32 = ifx kind == .SYSTEM then E_RENDER else E_CAPTURE;
|
||||||
|
hr = enumerator.vtable.GetDefaultAudioEndpoint(enumerator, flow, E_CONSOLE, *device);
|
||||||
|
if FAILED(hr) return false, ifx kind == .SYSTEM then "no sound output device" else "no microphone";
|
||||||
|
defer com_release(*device);
|
||||||
|
free(s.name);
|
||||||
|
s.name = copy_string(audio_device_name(device));
|
||||||
|
|
||||||
|
hr = device.vtable.Activate(device, *IID_AUDIO_CLIENT, CLSCTX_ALL, null, xx *s.client);
|
||||||
|
if FAILED(hr) return false, hresult_text(tprint("% (%)", s.name, "IAudioClient"), hr);
|
||||||
|
|
||||||
|
flags: u32 = AUTOCONVERTPCM | SRC_DEFAULT_QUALITY;
|
||||||
|
if kind == .SYSTEM flags |= STREAMFLAGS_LOOPBACK;
|
||||||
|
wanted := WAVEFORMATEX.{wFormatTag = WAVE_FORMAT_IEEE_FLOAT, nChannels = AUDIO_CHANNELS, nSamplesPerSec = AUDIO_RATE,
|
||||||
|
nAvgBytesPerSec = AUDIO_RATE * AUDIO_CHANNELS * 4, nBlockAlign = AUDIO_CHANNELS * 4, wBitsPerSample = 32};
|
||||||
|
BUFFER :: 2_000_000; // 200 ms, in 100 ns: room for the audio thread being late
|
||||||
|
hr = s.client.vtable.Initialize(s.client, SHAREMODE_SHARED, flags, BUFFER, 0, *wanted, null);
|
||||||
|
s.channels = AUDIO_CHANNELS;
|
||||||
|
if FAILED(hr) {
|
||||||
|
// No conversion here: the device's own format, if it is 48 kHz float, its channels made stereo after.
|
||||||
|
mix: *WAVEFORMATEX;
|
||||||
|
if FAILED(s.client.vtable.GetMixFormat(s.client, *mix)) return false, hresult_text(tprint("% will not give 48 kHz stereo", s.name), hr);
|
||||||
|
defer audio_CoTaskMemFree(mix);
|
||||||
|
is_float := mix.wFormatTag == WAVE_FORMAT_IEEE_FLOAT || (mix.wFormatTag == WAVE_FORMAT_EXTENSIBLE && mix.wBitsPerSample == 32);
|
||||||
|
if !is_float || mix.nSamplesPerSec != AUDIO_RATE
|
||||||
|
return false, tprint("% gives % Hz %, not 48 kHz float, and will not convert", s.name, mix.nSamplesPerSec, ifx is_float then "float" else "PCM");
|
||||||
|
com_release(*s.client);
|
||||||
|
hr = device.vtable.Activate(device, *IID_AUDIO_CLIENT, CLSCTX_ALL, null, xx *s.client);
|
||||||
|
if FAILED(hr) return false, hresult_text(s.name, hr);
|
||||||
|
hr = s.client.vtable.Initialize(s.client, SHAREMODE_SHARED, flags & ~(AUTOCONVERTPCM | SRC_DEFAULT_QUALITY), BUFFER, 0, mix, null);
|
||||||
|
if FAILED(hr) return false, hresult_text(tprint("% (Initialize)", s.name), hr);
|
||||||
|
s.channels = mix.nChannels;
|
||||||
|
}
|
||||||
|
hr = s.client.vtable.GetService(s.client, *IID_AUDIO_CAPTURE_CLIENT, xx *s.capture);
|
||||||
|
if FAILED(hr) return false, hresult_text(tprint("% (IAudioCaptureClient)", s.name), hr);
|
||||||
|
hr = s.client.vtable.Start(s.client);
|
||||||
|
if FAILED(hr) return false, hresult_text(tprint("% (Start)", s.name), hr);
|
||||||
|
s.open = true;
|
||||||
|
s.lost_at = 0;
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The packets waiting, each as stereo float (temp), oldest first. lost: the device went away
|
||||||
|
// (unplugged, or the default changed): audio_close, and open it again later.
|
||||||
|
audio_take :: (s: *Audio_Source, frequency: s64) -> [] Audio_Packet, lost: bool {
|
||||||
|
packets: [..] Audio_Packet;
|
||||||
|
packets.allocator = temp;
|
||||||
|
while true {
|
||||||
|
next: u32;
|
||||||
|
hr := s.capture.vtable.GetNextPacketSize(s.capture, *next);
|
||||||
|
if hresult_is(hr, AUDCLNT_E_DEVICE_INVALIDATED) return packets, true;
|
||||||
|
if FAILED(hr) || !next break;
|
||||||
|
data: *u8;
|
||||||
|
frames, flags: u32;
|
||||||
|
device_position, qpc_position: u64; // qpc_position: QueryPerformanceCounter time, in 100 ns
|
||||||
|
hr = s.capture.vtable.GetBuffer(s.capture, *data, *frames, *flags, *device_position, *qpc_position);
|
||||||
|
if hresult_is(hr, AUDCLNT_E_DEVICE_INVALIDATED) return packets, true;
|
||||||
|
if FAILED(hr) || hresult_is(hr, AUDCLNT_S_BUFFER_EMPTY) break;
|
||||||
|
|
||||||
|
packet: Audio_Packet;
|
||||||
|
packet.frames = frames;
|
||||||
|
packet.ticks = cast(s64) (cast(float64) qpc_position * frequency / 10_000_000.0);
|
||||||
|
packet.silent = (flags & BUFFERFLAGS_SILENT) != 0;
|
||||||
|
if !packet.silent && frames {
|
||||||
|
packet.samples = NewArray(cast(s64) frames * AUDIO_CHANNELS, float, initialized = false,, temp);
|
||||||
|
input := cast(*float) data;
|
||||||
|
if s.channels == AUDIO_CHANNELS {
|
||||||
|
memcpy(packet.samples.data, input, cast(s64) frames * AUDIO_CHANNELS * size_of(float));
|
||||||
|
} else {
|
||||||
|
// Mono to both sides; more channels than two: the first two (front left and right).
|
||||||
|
channels := cast(s64) s.channels;
|
||||||
|
for f: 0..cast(s64) frames - 1 {
|
||||||
|
left := input[f * channels];
|
||||||
|
right := ifx channels > 1 then input[f * channels + 1] else left;
|
||||||
|
packet.samples[f * 2] = left;
|
||||||
|
packet.samples[f * 2 + 1] = right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.capture.vtable.ReleaseBuffer(s.capture, frames);
|
||||||
|
array_add(*packets, packet);
|
||||||
|
}
|
||||||
|
return packets, false;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_close :: (s: *Audio_Source) {
|
||||||
|
if s.client && s.open s.client.vtable.Stop(s.client);
|
||||||
|
com_release(*s.capture);
|
||||||
|
com_release(*s.client);
|
||||||
|
s.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_free :: (s: *Audio_Source) {
|
||||||
|
audio_close(s);
|
||||||
|
free(s.name);
|
||||||
|
free(s.error);
|
||||||
|
s.name, s.error = "", "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// COM on this thread, in the multithreaded apartment, as WASAPI's objects are used from it only.
|
||||||
|
audio_thread_start :: () {
|
||||||
|
audio_CoInitializeEx(null, COINIT_MULTITHREADED);
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_thread_end :: () {
|
||||||
|
audio_CoUninitialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
audio_device_name :: (device: *Audio_Device) -> string {
|
||||||
|
store: *Property_Store;
|
||||||
|
if FAILED(device.vtable.OpenPropertyStore(device, STGM_READ, *store)) return "";
|
||||||
|
defer com_release(*store);
|
||||||
|
value: PROPVARIANT;
|
||||||
|
if FAILED(store.vtable.GetValue(store, *PKEY_DEVICE_FRIENDLY_NAME, *value)) return "";
|
||||||
|
defer audio_PropVariantClear(*value);
|
||||||
|
VT_LPWSTR :: 31;
|
||||||
|
if value.vt != VT_LPWSTR || !value.pointer return "";
|
||||||
|
name, ok := wide_to_utf8(cast(*u16) value.pointer,, temp);
|
||||||
|
return ifx ok then name else "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- bindings
|
||||||
|
|
||||||
|
ole32_dll :: #library,system "ole32";
|
||||||
|
|
||||||
|
audio_CoInitializeEx :: (reserved: *void, flags: u32) -> HRESULT #foreign ole32_dll "CoInitializeEx";
|
||||||
|
audio_CoUninitialize :: () #foreign ole32_dll "CoUninitialize";
|
||||||
|
audio_CoCreateInstance :: (clsid: *GUID, outer: *void, class_context: u32, iid: *GUID, out: **void) -> HRESULT #foreign ole32_dll "CoCreateInstance";
|
||||||
|
audio_CoTaskMemFree :: (memory: *void) #foreign ole32_dll "CoTaskMemFree";
|
||||||
|
audio_PropVariantClear :: (value: *PROPVARIANT) -> HRESULT #foreign ole32_dll "PropVariantClear";
|
||||||
|
|
||||||
|
COINIT_MULTITHREADED : u32 : 0;
|
||||||
|
CLSCTX_ALL : u32 : 0x17;
|
||||||
|
E_RENDER : s32 : 0;
|
||||||
|
E_CAPTURE : s32 : 1;
|
||||||
|
E_CONSOLE : s32 : 0;
|
||||||
|
STGM_READ : u32 : 0;
|
||||||
|
SHAREMODE_SHARED : s32 : 0;
|
||||||
|
STREAMFLAGS_LOOPBACK : u32 : 0x00020000;
|
||||||
|
AUTOCONVERTPCM : u32 : 0x80000000;
|
||||||
|
SRC_DEFAULT_QUALITY : u32 : 0x08000000;
|
||||||
|
BUFFERFLAGS_SILENT : u32 : 0x2;
|
||||||
|
WAVE_FORMAT_IEEE_FLOAT : u16 : 3;
|
||||||
|
WAVE_FORMAT_EXTENSIBLE : u16 : 0xFFFE;
|
||||||
|
AUDCLNT_S_BUFFER_EMPTY :: 0x08890001;
|
||||||
|
AUDCLNT_E_DEVICE_INVALIDATED :: 0x88890004;
|
||||||
|
|
||||||
|
CLSID_MM_DEVICE_ENUMERATOR := #run guid("BCDE0395-E52F-467C-8E3D-C4579291692E");
|
||||||
|
IID_MM_DEVICE_ENUMERATOR := #run guid("A95664D2-9614-4F35-A746-DE8DB63617E6");
|
||||||
|
IID_AUDIO_CLIENT := #run guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2");
|
||||||
|
IID_AUDIO_CAPTURE_CLIENT := #run guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317");
|
||||||
|
|
||||||
|
PROPERTYKEY :: struct { fmtid: GUID; pid: u32; }
|
||||||
|
PKEY_DEVICE_FRIENDLY_NAME := PROPERTYKEY.{#run guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), 14};
|
||||||
|
|
||||||
|
// A PROPVARIANT, as far as a string in it: its type, then (after 6 reserved bytes) the pointer.
|
||||||
|
PROPVARIANT :: struct {
|
||||||
|
vt: u16;
|
||||||
|
reserved: [3] u16;
|
||||||
|
pointer: *void;
|
||||||
|
padding: *void;
|
||||||
|
}
|
||||||
|
|
||||||
|
WAVEFORMATEX :: struct {
|
||||||
|
wFormatTag: u16;
|
||||||
|
nChannels: u16;
|
||||||
|
nSamplesPerSec: u32;
|
||||||
|
nAvgBytesPerSec: u32;
|
||||||
|
nBlockAlign: u16;
|
||||||
|
wBitsPerSample: u16;
|
||||||
|
cbSize: u16;
|
||||||
|
}
|
||||||
|
|
||||||
|
Unknown_Methods :: struct {
|
||||||
|
QueryInterface: (this: *void, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
AddRef: (this: *void) -> u32 #c_call;
|
||||||
|
Release: (this: *void) -> u32 #c_call;
|
||||||
|
}
|
||||||
|
|
||||||
|
Device_Enumerator :: struct { vtable: *struct {
|
||||||
|
using base: Unknown_Methods;
|
||||||
|
EnumAudioEndpoints: *void;
|
||||||
|
GetDefaultAudioEndpoint: (this: *Device_Enumerator, flow: s32, role: s32, out: **Audio_Device) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Audio_Device :: struct { vtable: *struct {
|
||||||
|
using base: Unknown_Methods;
|
||||||
|
Activate: (this: *Audio_Device, iid: *GUID, class_context: u32, parameters: *void, out: **void) -> HRESULT #c_call;
|
||||||
|
OpenPropertyStore: (this: *Audio_Device, access: u32, out: **Property_Store) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Property_Store :: struct { vtable: *struct {
|
||||||
|
using base: Unknown_Methods;
|
||||||
|
GetCount: *void;
|
||||||
|
GetAt: *void;
|
||||||
|
GetValue: (this: *Property_Store, key: *PROPERTYKEY, out: *PROPVARIANT) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Audio_Client :: struct { vtable: *struct {
|
||||||
|
using base: Unknown_Methods;
|
||||||
|
Initialize: (this: *Audio_Client, share_mode: s32, flags: u32, buffer_duration: s64, periodicity: s64, format: *WAVEFORMATEX, session: *GUID) -> HRESULT #c_call;
|
||||||
|
GetBufferSize: *void;
|
||||||
|
GetStreamLatency: *void;
|
||||||
|
GetCurrentPadding: *void;
|
||||||
|
IsFormatSupported: *void;
|
||||||
|
GetMixFormat: (this: *Audio_Client, out: **WAVEFORMATEX) -> HRESULT #c_call;
|
||||||
|
GetDevicePeriod: *void;
|
||||||
|
Start: (this: *Audio_Client) -> HRESULT #c_call;
|
||||||
|
Stop: (this: *Audio_Client) -> HRESULT #c_call;
|
||||||
|
Reset: *void;
|
||||||
|
SetEventHandle: *void;
|
||||||
|
GetService: (this: *Audio_Client, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Audio_Capture_Client :: struct { vtable: *struct {
|
||||||
|
using base: Unknown_Methods;
|
||||||
|
GetBuffer: (this: *Audio_Capture_Client, data: **u8, frames: *u32, flags: *u32, device_position: *u64, qpc_position: *u64) -> HRESULT #c_call;
|
||||||
|
ReleaseBuffer: (this: *Audio_Capture_Client, frames: u32) -> HRESULT #c_call;
|
||||||
|
GetNextPacketSize: (this: *Audio_Capture_Client, out: *u32) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
135
modules/Screen_Record/capture_gpu.jai
Normal file
135
modules/Screen_Record/capture_gpu.jai
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// What both ways of taking the screen share (dxgi_source.jai, wgc_source.jai): a D3D11 device of
|
||||||
|
// their own on the adapter the screen is attached to, the region as last seen, copied out of each
|
||||||
|
// new image on the GPU, and the staging textures it is read back through.
|
||||||
|
|
||||||
|
Capture_Gpu :: struct {
|
||||||
|
adapter: *IDXGIAdapter1;
|
||||||
|
device: *ID3D11Device;
|
||||||
|
device_context: *ID3D11DeviceContext;
|
||||||
|
box: D3D11_BOX; // the region, in the screen's pixels
|
||||||
|
latest: *ID3D11Texture2D; // the region as last seen
|
||||||
|
staging: [RECORD_STAGING] *ID3D11Texture2D;
|
||||||
|
copy_ticks: s64; // spent taking the region out of images (QueryPerformanceCounter ticks)
|
||||||
|
}
|
||||||
|
|
||||||
|
RECORD_STAGING :: 3; // staging textures in turn: the one read is two frames behind the one copied
|
||||||
|
|
||||||
|
// What a source's next() found.
|
||||||
|
Capture_Next :: enum u8 { NONE; IMAGE; POINTER; IMAGE_AND_POINTER; LOST; FAILED; }
|
||||||
|
|
||||||
|
// The device on the screen's adapter, and the textures for a region (in the screen's pixels).
|
||||||
|
gpu_open :: (g: *Capture_Gpu, screen: Record_Screen, region: RECT) -> ok: bool, error: string {
|
||||||
|
factory: *IDXGIFactory1;
|
||||||
|
hr := CreateDXGIFactory1(*uid(IDXGIFactory1_UUID), xx *factory);
|
||||||
|
if FAILED(hr) return false, hresult_text("CreateDXGIFactory1", hr);
|
||||||
|
defer IUnknown_Release(factory);
|
||||||
|
hr = IDXGIFactory1_EnumAdapters1(factory, screen.adapter_index, *g.adapter);
|
||||||
|
if FAILED(hr) return false, hresult_text("the screen's adapter is gone", hr);
|
||||||
|
|
||||||
|
levels := D3D_FEATURE_LEVEL.[._11_1, ._11_0, ._10_1, ._10_0];
|
||||||
|
level: D3D_FEATURE_LEVEL;
|
||||||
|
hr = D3D11CreateDevice(g.adapter, .UNKNOWN, null, .BGRA_SUPPORT, levels.data, levels.count, D3D11_SDK_VERSION, *g.device, *level, *g.device_context);
|
||||||
|
if FAILED(hr) return false, hresult_text("D3D11CreateDevice on the screen's adapter", hr);
|
||||||
|
|
||||||
|
g.box = .{left = xx region.left, top = xx region.top, right = xx region.right, bottom = xx region.bottom, front = 0, back = 1};
|
||||||
|
desc: D3D11_TEXTURE2D_DESC;
|
||||||
|
desc.Width = cast(u32) (region.right - region.left);
|
||||||
|
desc.Height = cast(u32) (region.bottom - region.top);
|
||||||
|
desc.MipLevels = 1;
|
||||||
|
desc.ArraySize = 1;
|
||||||
|
desc.Format = .B8G8R8A8_UNORM;
|
||||||
|
desc.SampleDesc.Count = 1;
|
||||||
|
desc.Usage = .DEFAULT;
|
||||||
|
hr = ID3D11Device_CreateTexture2D(g.device, *desc, null, *g.latest);
|
||||||
|
if FAILED(hr) return false, hresult_text("a texture for the region", hr);
|
||||||
|
desc.Usage = .STAGING;
|
||||||
|
desc.CPUAccessFlags = .READ;
|
||||||
|
for * g.staging {
|
||||||
|
hr = ID3D11Device_CreateTexture2D(g.device, *desc, null, it);
|
||||||
|
if FAILED(hr) return false, hresult_text("a staging texture", hr);
|
||||||
|
}
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The region of a whole screen's image into g.latest.
|
||||||
|
gpu_copy_region :: (g: *Capture_Gpu, image: *ID3D11Texture2D) {
|
||||||
|
start := ticks_now();
|
||||||
|
ID3D11DeviceContext_CopySubresourceRegion(g.device_context, g.latest, 0, 0, 0, 0, image, 0, *g.box);
|
||||||
|
g.copy_ticks += ticks_now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_close :: (g: *Capture_Gpu) {
|
||||||
|
for * g.staging com_release(it);
|
||||||
|
com_release(*g.latest);
|
||||||
|
com_release(*g.device_context);
|
||||||
|
com_release(*g.device);
|
||||||
|
com_release(*g.adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The calling thread made aware of each monitor's DPI (Windows 10 1703 on), so the desktop it is told
|
||||||
|
// of is in the screens' own pixels.
|
||||||
|
gpu_thread_dpi_aware :: () {
|
||||||
|
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 :: cast(HANDLE) -4;
|
||||||
|
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_module
|
||||||
|
|
||||||
|
user32_dll :: #library,system "user32";
|
||||||
|
SetThreadDpiAwarenessContext :: (value: HANDLE) -> HANDLE #foreign user32_dll;
|
||||||
|
|
||||||
|
// A GUID from its text, "A95664D2-9614-4F35-A746-DE8DB63617E6": for #run.
|
||||||
|
guid :: (text: string) -> GUID {
|
||||||
|
hex :: (s: string) -> u64 {
|
||||||
|
value: u64;
|
||||||
|
for 0..s.count - 1 {
|
||||||
|
c := s[it];
|
||||||
|
digit: u64 = ifx c >= #char "a" then cast(u64) (c - #char "a" + 10) else ifx c >= #char "A" then cast(u64) (c - #char "A" + 10) else cast(u64) (c - #char "0");
|
||||||
|
value = value * 16 + digit;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
g: GUID;
|
||||||
|
g.Data1 = cast(u32) hex(slice(text, 0, 8));
|
||||||
|
g.Data2 = cast(u16) hex(slice(text, 9, 4));
|
||||||
|
g.Data3 = cast(u16) hex(slice(text, 14, 4));
|
||||||
|
for 0..1 g.Data4[it] = cast(u8) hex(slice(text, 19 + it * 2, 2));
|
||||||
|
for 0..5 g.Data4[it + 2] = cast(u8) hex(slice(text, 24 + it * 2, 2));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
com_release :: (p: *$T) {
|
||||||
|
if p.* { IUnknown_Release(cast(*IUnknown) p.*); p.* = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ticks_now :: () -> s64 {
|
||||||
|
t: LARGE_INTEGER;
|
||||||
|
QueryPerformanceCounter(*t);
|
||||||
|
return t.QuadPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
hresult_is :: (hr: HRESULT, code: u32) -> bool {
|
||||||
|
return cast,no_check(u32) hr == code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "what failed: 0x887A0004 (DXGI_ERROR_UNSUPPORTED)", temp.
|
||||||
|
hresult_text :: (what: string, hr: HRESULT) -> string {
|
||||||
|
code := cast,no_check(u32) hr;
|
||||||
|
name := "";
|
||||||
|
if code == {
|
||||||
|
case DXGI_ERROR_UNSUPPORTED; name = " (DXGI_ERROR_UNSUPPORTED)";
|
||||||
|
case DXGI_ERROR_NOT_CURRENTLY_AVAILABLE; name = " (DXGI_ERROR_NOT_CURRENTLY_AVAILABLE)";
|
||||||
|
case DXGI_ERROR_ACCESS_LOST; name = " (DXGI_ERROR_ACCESS_LOST)";
|
||||||
|
case 0x80070005; name = " (E_ACCESSDENIED)";
|
||||||
|
case 0x80004002; name = " (E_NOINTERFACE)";
|
||||||
|
case 0x80070057; name = " (E_INVALIDARG)";
|
||||||
|
case 0x80040154; name = " (REGDB_E_CLASSNOTREG)";
|
||||||
|
case 0x80004001; name = " (E_NOTIMPL)";
|
||||||
|
}
|
||||||
|
return tprint("%: 0x%%", what, formatInt(code, base = 16, minimum_digits = 8), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
wide_text :: (wide: *s16) -> string {
|
||||||
|
text, ok := wide_to_utf8(cast(*u16) wide,, temp);
|
||||||
|
return ifx ok then text else "";
|
||||||
|
}
|
||||||
48
modules/Screen_Record/cursor.jai
Normal file
48
modules/Screen_Record/cursor.jai
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// The mouse cursor, drawn into frames read back from Desktop Duplication, which leaves it out of the
|
||||||
|
// image and hands over its shape and position separately. Three kinds of shape:
|
||||||
|
//
|
||||||
|
// COLOR BGRA, blended by its alpha.
|
||||||
|
// MASKED_COLOR BGRA, where alpha 0 replaces the pixel under it and 0xFF XORs it.
|
||||||
|
// MONOCHROME 1 bit a pixel, twice as tall: an AND mask over an XOR mask. AND 1 XOR 1 inverts the
|
||||||
|
// pixel under it, which is how the text I-beam shows on any background.
|
||||||
|
|
||||||
|
Cursor_Shape :: struct {
|
||||||
|
type: DXGI_OUTDUPL_POINTER_SHAPE_TYPE;
|
||||||
|
width, height, pitch: s32; // height as handed over: twice the cursor's for MONOCHROME
|
||||||
|
data: [..] u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws shape with its top left at x, y (in the frame's pixels; it may reach past the edges) into
|
||||||
|
// BGRA pixels, rows packed.
|
||||||
|
cursor_draw :: (pixels: *u8, width: s32, height: s32, shape: *Cursor_Shape, x: s32, y: s32) {
|
||||||
|
if !shape.data.count return;
|
||||||
|
rows := ifx shape.type == .MONOCHROME then shape.height / 2 else shape.height;
|
||||||
|
for row: 0..rows - 1 {
|
||||||
|
dy := y + row;
|
||||||
|
if dy < 0 || dy >= height continue;
|
||||||
|
for column: 0..shape.width - 1 {
|
||||||
|
dx := x + column;
|
||||||
|
if dx < 0 || dx >= width continue;
|
||||||
|
d := pixels + (cast(s64) dy * width + dx) * 4;
|
||||||
|
if shape.type == {
|
||||||
|
case .COLOR;
|
||||||
|
s := shape.data.data + row * shape.pitch + column * 4;
|
||||||
|
a := cast(u32) s[3];
|
||||||
|
if a == 0 continue;
|
||||||
|
for c: 0..2 d[c] = cast(u8) ((cast(u32) s[c] * a + cast(u32) d[c] * (255 - a) + 127) / 255);
|
||||||
|
case .MASKED_COLOR;
|
||||||
|
s := shape.data.data + row * shape.pitch + column * 4;
|
||||||
|
if s[3] == 0 for c: 0..2 d[c] = s[c];
|
||||||
|
else for c: 0..2 d[c] ^= s[c];
|
||||||
|
case .MONOCHROME;
|
||||||
|
bit := cast(u8) (0x80 >> (column % 8));
|
||||||
|
and_mask := shape.data[row * shape.pitch + column / 8] & bit;
|
||||||
|
xor_mask := shape.data[(row + rows) * shape.pitch + column / 8] & bit;
|
||||||
|
if and_mask && !xor_mask continue; // transparent
|
||||||
|
if and_mask { for c: 0..2 d[c] = 255 - d[c]; continue; } // inverted
|
||||||
|
value: u8 = ifx xor_mask then cast(u8) 255 else 0; // white or black
|
||||||
|
for c: 0..2 d[c] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
modules/Screen_Record/dxgi_source.jai
Normal file
164
modules/Screen_Record/dxgi_source.jai
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
// DXGI Desktop Duplication: the screens as the GPU composes them, a frame each time one changes,
|
||||||
|
// with the cursor apart (cursor.jai draws it in).
|
||||||
|
//
|
||||||
|
// It runs on a device made on the adapter the screen is attached to (capture_gpu.jai): duplicating
|
||||||
|
// with a device on another adapter fails. On a laptop with two GPUs, where the screen hangs off one
|
||||||
|
// and the process may run on the other, DuplicateOutput1 can fail with DXGI_ERROR_UNSUPPORTED all the
|
||||||
|
// same; that, too many duplications of the screen at once, and a rotated screen are what dxgi_open
|
||||||
|
// returns fall_back for, and Windows.Graphics.Capture (wgc_source.jai) takes over.
|
||||||
|
//
|
||||||
|
// DuplicateOutput1 (IDXGIOutput5) is used where there is one: the plain DuplicateOutput is documented
|
||||||
|
// to fail for a process aware of each monitor's DPI, as mcap is (it did not on an RTX 5080 laptop).
|
||||||
|
// legacy forces the plain one, for record-probe.
|
||||||
|
|
||||||
|
Dxgi_Source :: struct {
|
||||||
|
#as using gpu: Capture_Gpu;
|
||||||
|
output: *IDXGIOutput1;
|
||||||
|
output5: *IDXGIOutput5; // null before Windows 10 1703
|
||||||
|
duplication: *IDXGIOutputDuplication;
|
||||||
|
legacy: bool;
|
||||||
|
method: string; // which duplication call worked, for the log
|
||||||
|
|
||||||
|
pointer_position: POINT; // the cursor's top left, in the screen's pixels
|
||||||
|
pointer_visible: bool;
|
||||||
|
shape: Cursor_Shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every screen attached to the desktop, on every adapter, in DXGI's order. Temp.
|
||||||
|
record_list_screens :: () -> [] Record_Screen {
|
||||||
|
screens: [..] Record_Screen;
|
||||||
|
screens.allocator = temp;
|
||||||
|
factory: *IDXGIFactory1;
|
||||||
|
if FAILED(CreateDXGIFactory1(*uid(IDXGIFactory1_UUID), xx *factory)) return screens;
|
||||||
|
defer IUnknown_Release(factory);
|
||||||
|
|
||||||
|
adapter_index: u32;
|
||||||
|
while true {
|
||||||
|
adapter: *IDXGIAdapter1;
|
||||||
|
if FAILED(IDXGIFactory1_EnumAdapters1(factory, adapter_index, *adapter)) break;
|
||||||
|
defer IUnknown_Release(adapter);
|
||||||
|
adapter_desc: DXGI_ADAPTER_DESC1;
|
||||||
|
IDXGIAdapter1_GetDesc1(adapter, *adapter_desc);
|
||||||
|
adapter_name := wide_text(adapter_desc.Description.data);
|
||||||
|
|
||||||
|
output_index: u32;
|
||||||
|
while true {
|
||||||
|
output: *IDXGIOutput;
|
||||||
|
if FAILED(IDXGIAdapter_EnumOutputs(adapter, output_index, *output)) break;
|
||||||
|
desc: DXGI_OUTPUT_DESC;
|
||||||
|
IDXGIOutput_GetDesc(output, *desc);
|
||||||
|
IUnknown_Release(output);
|
||||||
|
if desc.AttachedToDesktop != .FALSE {
|
||||||
|
array_add(*screens, .{adapter_index = adapter_index, output_index = output_index, adapter = adapter_name,
|
||||||
|
name = wide_text(desc.DeviceName.data), desktop = desc.DesktopCoordinates,
|
||||||
|
rotation = desc.Rotation, monitor = desc.Monitor});
|
||||||
|
}
|
||||||
|
output_index += 1;
|
||||||
|
}
|
||||||
|
adapter_index += 1;
|
||||||
|
}
|
||||||
|
return screens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates the screen. region is in the screen's pixels and lies on it.
|
||||||
|
dxgi_open :: (s: *Dxgi_Source, screen: Record_Screen, region: RECT, legacy: bool) -> ok: bool, error: string, fall_back: bool {
|
||||||
|
s.legacy = legacy;
|
||||||
|
if screen.rotation != .IDENTITY && screen.rotation != .UNSPECIFIED return false, "the screen is rotated", true;
|
||||||
|
|
||||||
|
ok, error := gpu_open(s, screen, region);
|
||||||
|
if !ok return false, error, false;
|
||||||
|
|
||||||
|
output: *IDXGIOutput;
|
||||||
|
hr := IDXGIAdapter_EnumOutputs(s.adapter, screen.output_index, *output);
|
||||||
|
if FAILED(hr) return false, hresult_text("the screen is gone", hr), false;
|
||||||
|
defer IUnknown_Release(output);
|
||||||
|
hr = IUnknown_QueryInterface(output, *uid(IDXGIOutput1_UUID), xx *s.output);
|
||||||
|
if FAILED(hr) return false, hresult_text("the screen cannot be duplicated (no IDXGIOutput1)", hr), true;
|
||||||
|
IUnknown_QueryInterface(output, *uid(IDXGIOutput5_UUID), xx *s.output5); // optional
|
||||||
|
|
||||||
|
hr = dxgi_duplicate(s);
|
||||||
|
if FAILED(hr) {
|
||||||
|
// Unsupported: two GPUs, the device on the wrong one. Not currently available: too many duplications.
|
||||||
|
fall_back := hresult_is(hr, DXGI_ERROR_UNSUPPORTED) || hresult_is(hr, DXGI_ERROR_NOT_CURRENTLY_AVAILABLE);
|
||||||
|
return false, hresult_text(tprint("Desktop Duplication (%)", s.method), hr), fall_back;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A process not aware of each monitor's DPI is told the desktop in scaled pixels, while the frames
|
||||||
|
// come in the screen's own: the region would be the wrong part of the screen.
|
||||||
|
duplication_desc: DXGI_OUTDUPL_DESC;
|
||||||
|
IDXGIOutputDuplication_GetDesc(s.duplication, *duplication_desc);
|
||||||
|
desktop_width := screen.desktop.right - screen.desktop.left;
|
||||||
|
desktop_height := screen.desktop.bottom - screen.desktop.top;
|
||||||
|
if duplication_desc.ModeDesc.Width != cast(u32) desktop_width || duplication_desc.ModeDesc.Height != cast(u32) desktop_height {
|
||||||
|
return false, tprint("the screen is % x %, but this process sees it as % x %: it must be aware of each monitor's DPI",
|
||||||
|
duplication_desc.ModeDesc.Width, duplication_desc.ModeDesc.Height, desktop_width, desktop_height), false;
|
||||||
|
}
|
||||||
|
return true, "", false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates the screen, again after the duplication was lost.
|
||||||
|
dxgi_duplicate :: (s: *Dxgi_Source) -> HRESULT {
|
||||||
|
com_release(*s.duplication);
|
||||||
|
if s.output5 && !s.legacy {
|
||||||
|
s.method = "DuplicateOutput1";
|
||||||
|
formats := DXGI_FORMAT.[.B8G8R8A8_UNORM];
|
||||||
|
return IDXGIOutput5_DuplicateOutput1(s.output5, s.device, 0, formats.count, formats.data, *s.duplication);
|
||||||
|
}
|
||||||
|
s.method = "DuplicateOutput";
|
||||||
|
return IDXGIOutput1_DuplicateOutput(s.output, s.device, *s.duplication);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits up to timeout_ms for the screen to change. A new image's region goes into s.latest; a moved
|
||||||
|
// or changed cursor updates s.pointer_* and s.shape. On LOST the duplication is gone: dxgi_duplicate.
|
||||||
|
dxgi_next :: (s: *Dxgi_Source, timeout_ms: u32) -> Capture_Next, error: string {
|
||||||
|
info: DXGI_OUTDUPL_FRAME_INFO;
|
||||||
|
resource: *IDXGIResource;
|
||||||
|
hr := IDXGIOutputDuplication_AcquireNextFrame(s.duplication, timeout_ms, *info, *resource);
|
||||||
|
if hresult_is(hr, DXGI_ERROR_WAIT_TIMEOUT) return .NONE, "";
|
||||||
|
if hresult_is(hr, DXGI_ERROR_ACCESS_LOST) return .LOST, "";
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("AcquireNextFrame", hr);
|
||||||
|
defer IDXGIOutputDuplication_ReleaseFrame(s.duplication); // held, it stalls the desktop
|
||||||
|
defer IUnknown_Release(resource);
|
||||||
|
|
||||||
|
image := info.LastPresentTime.QuadPart != 0;
|
||||||
|
if image {
|
||||||
|
texture: *ID3D11Texture2D;
|
||||||
|
hr = IUnknown_QueryInterface(resource, *uid(ID3D11Texture2D_UUID), xx *texture);
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("the frame is not a texture", hr);
|
||||||
|
gpu_copy_region(s, texture);
|
||||||
|
IUnknown_Release(texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer := info.LastMouseUpdateTime.QuadPart != 0;
|
||||||
|
if pointer {
|
||||||
|
s.pointer_position = info.PointerPosition.Position;
|
||||||
|
s.pointer_visible = info.PointerPosition.Visible != .FALSE;
|
||||||
|
if info.PointerShapeBufferSize {
|
||||||
|
array_resize(*s.shape.data, info.PointerShapeBufferSize, initialize = false);
|
||||||
|
needed: u32;
|
||||||
|
shape_info: DXGI_OUTDUPL_POINTER_SHAPE_INFO;
|
||||||
|
hr = IDXGIOutputDuplication_GetFramePointerShape(s.duplication, cast(u32) s.shape.data.count, s.shape.data.data, *needed, *shape_info);
|
||||||
|
if SUCCEEDED(hr) {
|
||||||
|
s.shape.type = cast(DXGI_OUTDUPL_POINTER_SHAPE_TYPE) shape_info.Type;
|
||||||
|
s.shape.width = cast(s32) shape_info.Width;
|
||||||
|
s.shape.height = cast(s32) shape_info.Height;
|
||||||
|
s.shape.pitch = cast(s32) shape_info.Pitch;
|
||||||
|
} else {
|
||||||
|
array_reset_keeping_memory(*s.shape.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if image && pointer return .IMAGE_AND_POINTER, "";
|
||||||
|
if image return .IMAGE, "";
|
||||||
|
if pointer return .POINTER, "";
|
||||||
|
return .NONE, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
dxgi_close :: (s: *Dxgi_Source) {
|
||||||
|
com_release(*s.duplication);
|
||||||
|
com_release(*s.output5);
|
||||||
|
com_release(*s.output);
|
||||||
|
gpu_close(s);
|
||||||
|
array_free(s.shape.data);
|
||||||
|
s.shape = .{};
|
||||||
|
}
|
||||||
319
modules/Screen_Record/encoder.jai
Normal file
319
modules/Screen_Record/encoder.jai
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
// The encoder thread's half: frames of BGRA pixels to H.264 in a fragmented MP4, with FFmpeg
|
||||||
|
// (modules/ffmpeg). The first encoder in RECORD_ENCODERS that opens at the size wanted is used:
|
||||||
|
// NVENC, AMD's AMF, Intel's QSV, Windows' Media Foundation, then libx264 on the CPU; on macOS,
|
||||||
|
// VideoToolbox (Apple silicon's media engine), at a constant quality. NVENC takes the
|
||||||
|
// BGRA as it is and converts it on the GPU; the others are given NV12 or YUV 4:2:0, converted with
|
||||||
|
// swscale (BT.709, limited range, as tagged).
|
||||||
|
//
|
||||||
|
// With sound, an AAC stream (FFmpeg's own encoder, 160 kb/s, 48 kHz stereo) beside it, written from the
|
||||||
|
// audio thread: the two streams' packets meet in the file under a lock (mux).
|
||||||
|
//
|
||||||
|
// The file is written fragmented, a fragment at each keyframe, so one cut short by a crash still plays
|
||||||
|
// up to its last fragment. Frames are written only when the screen changes, so a count of frames can
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
#if OS == .MACOS RECORD_ENCODERS :: string.["h264_videotoolbox"]; // Apple's, in every Mac; the macOS FFmpeg has no libx264
|
||||||
|
else RECORD_ENCODERS :: string.["h264_nvenc", "h264_amf", "h264_qsv", "h264_mf", "libx264"];
|
||||||
|
RECORD_KEYFRAME_SECONDS :: 2;
|
||||||
|
RECORD_AUDIO_BIT_RATE :: 160_000;
|
||||||
|
|
||||||
|
Encoder :: struct {
|
||||||
|
format: *AVFormatContext;
|
||||||
|
codec: *AVCodecContext;
|
||||||
|
stream: *AVStream;
|
||||||
|
frame: *AVFrame;
|
||||||
|
packet: *AVPacket;
|
||||||
|
sws: *SwsContext;
|
||||||
|
name: string; // the encoder used
|
||||||
|
input: AVPixelFormat; // what it is given: BGR0 as captured, or converted to NV12 or YUV420P
|
||||||
|
width, height: s32;
|
||||||
|
last_pts: s64 = -1;
|
||||||
|
last_key_pts: s64 = -1; // the last keyframe asked for
|
||||||
|
header_written: bool;
|
||||||
|
bytes: s64; // of packets written
|
||||||
|
|
||||||
|
// Sound, when asked for: AAC, written from the audio thread.
|
||||||
|
audio: *AVCodecContext;
|
||||||
|
audio_stream: *AVStream;
|
||||||
|
audio_frame: *AVFrame;
|
||||||
|
audio_packet: *AVPacket;
|
||||||
|
audio_frame_size: s32; // samples a frame: AAC's 1024
|
||||||
|
mux: Mutex; // the video and audio threads' packets into the file
|
||||||
|
mux_ready: bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens the file and the first encoder that will take width x height (wanted alone, when given).
|
||||||
|
// Failures to open an encoder are listed in tried (temp), for the log.
|
||||||
|
encoder_open :: (e: *Encoder, path: string, width: s32, height: s32, fps: s32, wanted: string, audio := false) -> ok: bool, error: string, tried: string {
|
||||||
|
av_log_set_level(AV_LOG_ERROR);
|
||||||
|
e.width, e.height = width, height;
|
||||||
|
result := avformat_alloc_output_context2(*e.format, null, "mp4", temp_c_string(path));
|
||||||
|
if result < 0 || !e.format return false, av_error("FFmpeg cannot make an MP4", result), "";
|
||||||
|
|
||||||
|
tried: [..] string;
|
||||||
|
tried.allocator = temp;
|
||||||
|
names: [] string = RECORD_ENCODERS;
|
||||||
|
if wanted names = .[wanted];
|
||||||
|
for names {
|
||||||
|
ok, why := encoder_try(e, it, width, height, fps);
|
||||||
|
if ok break;
|
||||||
|
array_add(*tried, tprint("%: %", it, why));
|
||||||
|
}
|
||||||
|
tried_text := join(..tried, "; ",, temp);
|
||||||
|
if !e.codec {
|
||||||
|
encoder_free(e);
|
||||||
|
return false, tprint("no H.264 encoder opened (%)", tried_text), tried_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stream = avformat_new_stream(e.format, null);
|
||||||
|
if !e.stream { encoder_free(e); return false, "FFmpeg cannot add a stream", tried_text; }
|
||||||
|
avcodec_parameters_from_context(e.stream.codecpar, e.codec);
|
||||||
|
e.stream.time_base = e.codec.time_base;
|
||||||
|
if audio {
|
||||||
|
audio_ok, audio_error := encoder_open_audio(e);
|
||||||
|
if !audio_ok { encoder_free(e); return false, audio_error, tried_text; }
|
||||||
|
}
|
||||||
|
init(*e.mux, "record mux");
|
||||||
|
e.mux_ready = true;
|
||||||
|
|
||||||
|
result = avio_open(*e.format.pb, temp_c_string(path), AVIO_FLAG_WRITE);
|
||||||
|
if result < 0 { encoder_free(e); return false, av_error(tprint("FFmpeg cannot write %", path), result), tried_text; }
|
||||||
|
options: *AVDictionary;
|
||||||
|
av_dict_set(*options, "movflags", "frag_keyframe+empty_moov+default_base_moof", 0);
|
||||||
|
result = avformat_write_header(e.format, *options);
|
||||||
|
av_dict_free(*options);
|
||||||
|
if result < 0 { encoder_free(e); return false, av_error("FFmpeg cannot start the MP4", result), tried_text; }
|
||||||
|
e.header_written = true;
|
||||||
|
|
||||||
|
e.packet = av_packet_alloc();
|
||||||
|
e.frame = av_frame_alloc();
|
||||||
|
if !e.packet || !e.frame { encoder_free(e); return false, "FFmpeg is out of memory", tried_text; }
|
||||||
|
if e.input != .BGR0 {
|
||||||
|
e.sws = sws_getContext(width, height, .BGR0, width, height, e.input, SWS_POINT, null, null, null);
|
||||||
|
if !e.sws { encoder_free(e); return false, "swscale cannot convert to the encoder's format", tried_text; }
|
||||||
|
coefficients := cast(*[4] s32) sws_getCoefficients(SWS_CS_ITU709);
|
||||||
|
sws_setColorspaceDetails(e.sws, coefficients, 1, coefficients, 0, 0, 1 << 16, 1 << 16);
|
||||||
|
}
|
||||||
|
return true, "", tried_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encodes one frame; pts in RECORD_TIME_BASE ticks, made later than the last one if it is not.
|
||||||
|
encoder_write :: (e: *Encoder, pixels: *u8, pts: s64) -> ok: bool, error: string {
|
||||||
|
pts_out := max(pts, e.last_pts + 1);
|
||||||
|
e.last_pts = pts_out;
|
||||||
|
|
||||||
|
frame := e.frame;
|
||||||
|
av_frame_unref(frame);
|
||||||
|
frame.format = cast(s32) e.input;
|
||||||
|
frame.width = e.width;
|
||||||
|
frame.height = e.height;
|
||||||
|
if e.input == .BGR0 {
|
||||||
|
// Not reference-counted: avcodec_send_frame copies it, so the buffer goes back to the pool at once.
|
||||||
|
frame.data[0] = pixels;
|
||||||
|
frame.linesize[0] = e.width * 4;
|
||||||
|
} else {
|
||||||
|
result := av_frame_get_buffer(frame, 0);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot make a frame", result);
|
||||||
|
source := pixels;
|
||||||
|
stride := e.width * 4;
|
||||||
|
sws_scale(e.sws, *source, *stride, 0, e.height, frame.data.data, frame.linesize.data);
|
||||||
|
}
|
||||||
|
frame.pts = pts_out;
|
||||||
|
if e.last_key_pts < 0 || pts_out - e.last_key_pts >= RECORD_KEYFRAME_SECONDS * RECORD_TIME_BASE {
|
||||||
|
frame.pict_type = .I; // an IDR, with forced-idr: a new fragment
|
||||||
|
e.last_key_pts = pts_out;
|
||||||
|
}
|
||||||
|
|
||||||
|
result := avcodec_send_frame(e.codec, frame);
|
||||||
|
av_frame_unref(frame);
|
||||||
|
if result < 0 return false, av_error(tprint("% cannot encode", e.name), result);
|
||||||
|
drained, error := encoder_drain(e);
|
||||||
|
return drained, error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One AAC frame of sound: e.audio_frame_size frames of interleaved stereo float, at pts in samples.
|
||||||
|
// From the audio thread.
|
||||||
|
encoder_write_audio :: (e: *Encoder, samples: *float, pts: s64) -> ok: bool, error: string {
|
||||||
|
frame := e.audio_frame;
|
||||||
|
result := av_frame_make_writable(frame);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot make a sound frame", result);
|
||||||
|
left := cast(*float) frame.data[0];
|
||||||
|
right := cast(*float) frame.data[1];
|
||||||
|
for 0..e.audio_frame_size - 1 {
|
||||||
|
left[it] = samples[it * 2];
|
||||||
|
right[it] = samples[it * 2 + 1];
|
||||||
|
}
|
||||||
|
frame.pts = pts;
|
||||||
|
result = avcodec_send_frame(e.audio, frame);
|
||||||
|
if result < 0 return false, av_error("the AAC encoder", result);
|
||||||
|
drained, error := encoder_drain(e, e.audio, e.audio_stream, e.audio_packet);
|
||||||
|
return drained, error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flushes the encoders, sound first, finishes the file and closes it.
|
||||||
|
encoder_close :: (e: *Encoder) -> ok: bool, error: string {
|
||||||
|
ok, error := true, "";
|
||||||
|
if e.audio && e.header_written {
|
||||||
|
avcodec_send_frame(e.audio, null);
|
||||||
|
ok, error = encoder_drain(e, e.audio, e.audio_stream, e.audio_packet);
|
||||||
|
}
|
||||||
|
if e.codec && e.header_written {
|
||||||
|
avcodec_send_frame(e.codec, null);
|
||||||
|
video_ok, video_error := encoder_drain(e);
|
||||||
|
if ok ok, error = video_ok, video_error;
|
||||||
|
result := av_write_trailer(e.format);
|
||||||
|
if ok && result < 0 ok, error = false, av_error("FFmpeg cannot finish the MP4", result);
|
||||||
|
}
|
||||||
|
encoder_free(e);
|
||||||
|
return ok, error;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
RECORD_TIME_BASE :: 90000; // pts ticks a second: MPEG's
|
||||||
|
VIDEOTOOLBOX_QUALITY :: 65; // 0-100: sharp text at a few MB a minute for a screen
|
||||||
|
|
||||||
|
encoder_try :: (e: *Encoder, name: string, width: s32, height: s32, fps: s32) -> ok: bool, error: string {
|
||||||
|
codec := avcodec_find_encoder_by_name(temp_c_string(name));
|
||||||
|
if !codec return false, "not in this FFmpeg";
|
||||||
|
input := encoder_input_format(codec);
|
||||||
|
if input == .NONE return false, "takes no format this can give it";
|
||||||
|
|
||||||
|
c := avcodec_alloc_context3(codec);
|
||||||
|
if !c return false, "out of memory";
|
||||||
|
c.width = width;
|
||||||
|
c.height = height;
|
||||||
|
c.time_base = .{1, RECORD_TIME_BASE};
|
||||||
|
c.framerate = .{fps, 1};
|
||||||
|
c.pix_fmt = input;
|
||||||
|
c.gop_size = fps * 2;
|
||||||
|
c.max_b_frames = 0;
|
||||||
|
c.sample_aspect_ratio = .{1, 1};
|
||||||
|
if input != .BGR0 { // swscale's conversion; NVENC picks and tags its own for BGRA
|
||||||
|
c.colorspace = .BT709;
|
||||||
|
c.color_primaries = .BT709;
|
||||||
|
c.color_trc = .BT709;
|
||||||
|
c.color_range = .MPEG;
|
||||||
|
}
|
||||||
|
if e.format.oformat.flags & AVFMT_GLOBALHEADER c.flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
|
||||||
|
|
||||||
|
options: *AVDictionary;
|
||||||
|
set :: (key: string, value: string) #expand { av_dict_set(*`options, temp_c_string(key), temp_c_string(value), 0); }
|
||||||
|
if name == {
|
||||||
|
case "h264_nvenc"; set("preset", "p4"); set("rc", "vbr"); set("cq", "23"); set("forced-idr", "1"); c.bit_rate = 0;
|
||||||
|
case "h264_amf"; set("rc", "cqp"); set("qp_i", "22"); set("qp_p", "24"); set("quality", "balanced");
|
||||||
|
case "h264_qsv"; set("global_quality", "23"); set("preset", "medium");
|
||||||
|
case "h264_mf"; set("rate_control", "quality"); set("quality", "70"); set("scenario", "display_remoting");
|
||||||
|
case "libx264"; set("preset", "veryfast"); set("crf", "23"); set("forced-idr", "1");
|
||||||
|
case "h264_videotoolbox";
|
||||||
|
// A constant quality (VideoToolbox's 0-1, as 0-100), which Apple silicon takes: FFmpeg's default
|
||||||
|
// bit rate, 200 kb/s, would blur a screen. Real time, as frames come as they are recorded.
|
||||||
|
set("realtime", "1");
|
||||||
|
set("allow_sw", "0");
|
||||||
|
c.flags |= AV_CODEC_FLAG_QSCALE;
|
||||||
|
c.global_quality = FF_QP2LAMBDA * VIDEOTOOLBOX_QUALITY;
|
||||||
|
}
|
||||||
|
result := avcodec_open2(c, codec, *options);
|
||||||
|
av_dict_free(*options);
|
||||||
|
if result < 0 {
|
||||||
|
avcodec_free_context(*c);
|
||||||
|
return false, av_error("cannot open", result);
|
||||||
|
}
|
||||||
|
e.codec = c;
|
||||||
|
e.input = input;
|
||||||
|
e.name = name;
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// BGRA as captured when the encoder takes it, else what swscale converts it to.
|
||||||
|
encoder_input_format :: (codec: *AVCodec) -> AVPixelFormat {
|
||||||
|
if !codec.pix_fmts return .NONE;
|
||||||
|
for wanted: AVPixelFormat.[.BGR0, .BGRA, .NV12, .YUV420P] {
|
||||||
|
format := codec.pix_fmts;
|
||||||
|
while format.* != .NONE {
|
||||||
|
if format.* == wanted return ifx wanted == .BGRA then .BGR0 else wanted;
|
||||||
|
format += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
#if OS == .MACOS AVERROR_EAGAIN :: -35; // AVERROR(EAGAIN): -EAGAIN, whose errno differs
|
||||||
|
else AVERROR_EAGAIN :: -11;
|
||||||
|
if !codec codec, stream, packet = e.codec, e.stream, e.packet;
|
||||||
|
while true {
|
||||||
|
result := avcodec_receive_packet(codec, packet);
|
||||||
|
if result == AVERROR_EAGAIN || result == cast,no_check(s32) AVERROR_EOF return true, "";
|
||||||
|
if result < 0 return false, av_error(tprint("% failed", ifx codec == e.audio then "the AAC encoder" else e.name), result);
|
||||||
|
av_packet_rescale_ts(packet, codec.time_base, stream.time_base);
|
||||||
|
packet.stream_index = stream.index;
|
||||||
|
lock(*e.mux);
|
||||||
|
e.bytes += packet.size;
|
||||||
|
result = av_interleaved_write_frame(e.format, packet); // takes the packet's data
|
||||||
|
unlock(*e.mux);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot write to the MP4", result);
|
||||||
|
}
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder_open_audio :: (e: *Encoder) -> ok: bool, error: string {
|
||||||
|
codec := avcodec_find_encoder_by_name("aac");
|
||||||
|
if !codec return false, "this FFmpeg has no AAC encoder";
|
||||||
|
c := avcodec_alloc_context3(codec);
|
||||||
|
if !c return false, "out of memory";
|
||||||
|
c.sample_fmt = .FLTP;
|
||||||
|
c.sample_rate = AUDIO_RATE;
|
||||||
|
av_channel_layout_default(*c.ch_layout, AUDIO_CHANNELS);
|
||||||
|
c.bit_rate = RECORD_AUDIO_BIT_RATE;
|
||||||
|
c.time_base = .{1, AUDIO_RATE};
|
||||||
|
if e.format.oformat.flags & AVFMT_GLOBALHEADER c.flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
|
||||||
|
result := avcodec_open2(c, codec, null);
|
||||||
|
if result < 0 { avcodec_free_context(*c); return false, av_error("the AAC encoder cannot open", result); }
|
||||||
|
e.audio = c;
|
||||||
|
e.audio_frame_size = ifx c.frame_size > 0 then c.frame_size else 1024;
|
||||||
|
|
||||||
|
e.audio_stream = avformat_new_stream(e.format, null);
|
||||||
|
if !e.audio_stream return false, "FFmpeg cannot add a sound stream";
|
||||||
|
avcodec_parameters_from_context(e.audio_stream.codecpar, c);
|
||||||
|
e.audio_stream.time_base = c.time_base;
|
||||||
|
|
||||||
|
e.audio_packet = av_packet_alloc();
|
||||||
|
e.audio_frame = av_frame_alloc();
|
||||||
|
if !e.audio_packet || !e.audio_frame return false, "FFmpeg is out of memory";
|
||||||
|
e.audio_frame.format = cast(s32) AVSampleFormat.FLTP;
|
||||||
|
e.audio_frame.nb_samples = e.audio_frame_size;
|
||||||
|
e.audio_frame.sample_rate = AUDIO_RATE;
|
||||||
|
av_channel_layout_default(*e.audio_frame.ch_layout, AUDIO_CHANNELS);
|
||||||
|
result = av_frame_get_buffer(e.audio_frame, 0);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot make a sound frame", result);
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder_free :: (e: *Encoder) {
|
||||||
|
if e.audio_frame av_frame_free(*e.audio_frame);
|
||||||
|
if e.audio_packet av_packet_free(*e.audio_packet);
|
||||||
|
if e.audio avcodec_free_context(*e.audio);
|
||||||
|
e.audio_stream = null;
|
||||||
|
if e.mux_ready { destroy(*e.mux); e.mux_ready = false; }
|
||||||
|
if e.sws sws_freeContext(e.sws);
|
||||||
|
if e.frame av_frame_free(*e.frame);
|
||||||
|
if e.packet av_packet_free(*e.packet);
|
||||||
|
if e.codec avcodec_free_context(*e.codec);
|
||||||
|
if e.format {
|
||||||
|
if e.format.pb avio_closep(*e.format.pb);
|
||||||
|
avformat_free_context(e.format);
|
||||||
|
}
|
||||||
|
e.sws, e.format, e.stream = null, null, null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_module
|
||||||
|
|
||||||
|
// What FFmpeg says an error code is, after what failed (temp).
|
||||||
|
av_error :: (what: string, code: s32) -> string {
|
||||||
|
buffer: [256] u8;
|
||||||
|
av_strerror(code, buffer.data, buffer.count);
|
||||||
|
return tprint("% (%)", what, to_string(buffer.data));
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
modules/Screen_Record/module.jai
Normal file
35
modules/Screen_Record/module.jai
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// 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, macos-plan phase 7). On Windows with
|
||||||
|
// Desktop Duplication or Windows.Graphics.Capture and WASAPI (windows.jai); on macOS with
|
||||||
|
// ScreenCaptureKit, its frames and sound alike, through modules/Screen_Record/macos/capture.m
|
||||||
|
// (macos.jai). Either way to FFmpeg (encoder.jai), the sound mixed onto the video's timeline
|
||||||
|
// (audio_mix.jai), and made a regular MP4 at the end (remux.jai).
|
||||||
|
//
|
||||||
|
// recorder: Recorder;
|
||||||
|
// ok, error := record_start(*recorder, .{path = "out.mp4", ...});
|
||||||
|
// ...
|
||||||
|
// ok, error = record_stop(*recorder);
|
||||||
|
|
||||||
|
RECORD_MIN_WIDTH :: 240; // a smaller region is refused
|
||||||
|
RECORD_MIN_HEIGHT :: 180;
|
||||||
|
RECORD_QUEUE :: 8; // frames waiting for the encoder at most
|
||||||
|
RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded
|
||||||
|
|
||||||
|
// Whether a region this size may be recorded: at least RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT. It is
|
||||||
|
// rounded down to even sizes after, which keeps it at least that, as both are even.
|
||||||
|
record_size_allowed :: (width: s64, height: s64) -> bool {
|
||||||
|
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .WINDOWS #load "windows.jai";
|
||||||
|
#if OS == .MACOS #load "macos.jai";
|
||||||
|
#load "audio_mix.jai";
|
||||||
|
#load "encoder.jai";
|
||||||
|
#load "remux.jai";
|
||||||
|
|
||||||
|
#import "Basic";
|
||||||
|
#import "String";
|
||||||
|
#import "Atomics";
|
||||||
|
#import "Math";
|
||||||
|
#import "Thread";
|
||||||
|
#import "ffmpeg";
|
||||||
65
modules/Screen_Record/remux.jai
Normal file
65
modules/Screen_Record/remux.jai
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// A recording made into a regular MP4. While recording, the MP4 is written fragmented (encoder.jai), so
|
||||||
|
// a crash still leaves one that plays; but a fragmented MP4's header says nothing of its length or
|
||||||
|
// where its frames are, and one cut short by a crash never gets the index written at the end of a
|
||||||
|
// clean stop, so players show its time and seek in it badly. record_remux copies its packets, as they
|
||||||
|
// are, into a regular MP4: one header, with the length and the index of every frame, at the front
|
||||||
|
// (faststart), so it plays, seeks and streams as any MP4 does. Nothing is encoded again.
|
||||||
|
|
||||||
|
// Copies source's video and audio packets into a regular MP4 at destination. packets: how many.
|
||||||
|
record_remux :: (source: string, destination: string) -> ok: bool, error: string, packets: s64 {
|
||||||
|
av_log_set_level(AV_LOG_ERROR);
|
||||||
|
input: *AVFormatContext;
|
||||||
|
result := avformat_open_input(*input, temp_c_string(source), null, null);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot open the recording", result), 0;
|
||||||
|
defer avformat_close_input(*input);
|
||||||
|
result = avformat_find_stream_info(input, null);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot read the recording's streams", result), 0;
|
||||||
|
|
||||||
|
output: *AVFormatContext;
|
||||||
|
result = avformat_alloc_output_context2(*output, null, "mp4", temp_c_string(destination));
|
||||||
|
if result < 0 || !output return false, av_error("FFmpeg cannot make an MP4", result), 0;
|
||||||
|
defer {
|
||||||
|
if output.pb avio_closep(*output.pb);
|
||||||
|
avformat_free_context(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each input stream's output stream, or -1 for one left out (neither video nor audio).
|
||||||
|
map := NewArray(input.nb_streams, s32,, temp);
|
||||||
|
for 0..input.nb_streams - 1 {
|
||||||
|
in := input.streams[it];
|
||||||
|
map[it] = -1;
|
||||||
|
if in.codecpar.codec_type != .VIDEO && in.codecpar.codec_type != .AUDIO continue;
|
||||||
|
out := avformat_new_stream(output, null);
|
||||||
|
if !out return false, "FFmpeg cannot add a stream", 0;
|
||||||
|
avcodec_parameters_copy(out.codecpar, in.codecpar);
|
||||||
|
out.codecpar.codec_tag = 0; // the MP4 muxer picks its own
|
||||||
|
out.time_base = in.time_base;
|
||||||
|
map[it] = out.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = avio_open(*output.pb, temp_c_string(destination), AVIO_FLAG_WRITE);
|
||||||
|
if result < 0 return false, av_error(tprint("FFmpeg cannot write %", destination), result), 0;
|
||||||
|
options: *AVDictionary;
|
||||||
|
av_dict_set(*options, "movflags", "+faststart", 0); // the header before the frames: a second pass at the end
|
||||||
|
result = avformat_write_header(output, *options);
|
||||||
|
av_dict_free(*options);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot start the MP4", result), 0;
|
||||||
|
|
||||||
|
packet := av_packet_alloc();
|
||||||
|
defer av_packet_free(*packet);
|
||||||
|
packets: s64;
|
||||||
|
while av_read_frame(input, packet) >= 0 { // to the end, or to where a crash cut it
|
||||||
|
index := map[packet.stream_index];
|
||||||
|
if index < 0 { av_packet_unref(packet); continue; }
|
||||||
|
av_packet_rescale_ts(packet, input.streams[packet.stream_index].time_base, output.streams[index].time_base);
|
||||||
|
packet.stream_index = index;
|
||||||
|
packet.pos = -1;
|
||||||
|
result = av_interleaved_write_frame(output, packet); // takes the packet's data
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot write to the MP4", result), packets;
|
||||||
|
packets += 1;
|
||||||
|
}
|
||||||
|
if !packets return false, "the recording holds no frames", 0;
|
||||||
|
result = av_write_trailer(output);
|
||||||
|
if result < 0 return false, av_error("FFmpeg cannot finish the MP4", result), packets;
|
||||||
|
return true, "", packets;
|
||||||
|
}
|
||||||
388
modules/Screen_Record/wgc_source.jai
Normal file
388
modules/Screen_Record/wgc_source.jai
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
// Windows.Graphics.Capture: the screen as Windows' own capture API hands it over, for when Desktop
|
||||||
|
// Duplication cannot run (dxgi_source.jai). It works across GPUs, hands over frames the right way up
|
||||||
|
// on a rotated screen, and draws the cursor in itself.
|
||||||
|
//
|
||||||
|
// It is WinRT, bound here by hand: the few interfaces it needs, as vtables after IInspectable's six
|
||||||
|
// methods. Everything is made and used on the capture thread, which is in the multithreaded apartment
|
||||||
|
// (RoInitialize), with a free-threaded frame pool, so no dispatcher queue or message loop is needed.
|
||||||
|
//
|
||||||
|
// A frame's arrival sets an event, through a small handler object (Frame_Handler); if Windows will not
|
||||||
|
// take the handler, the pool is polled every millisecond instead. Windows 11 lets the yellow border it
|
||||||
|
// draws around a screen being captured be turned off (IsBorderRequired); Windows 10 always draws it.
|
||||||
|
|
||||||
|
Wgc_Source :: struct {
|
||||||
|
#as using gpu: Capture_Gpu;
|
||||||
|
screen_size: Size_Int32; // the frames' size, as the pool was made for
|
||||||
|
item: *Capture_Item;
|
||||||
|
winrt_device: *IInspectable; // IDirect3DDevice, over gpu.device
|
||||||
|
pool: *Frame_Pool;
|
||||||
|
session: *Capture_Session;
|
||||||
|
handler: Frame_Handler;
|
||||||
|
handler_token: s64;
|
||||||
|
polling: bool; // Windows would not take the handler
|
||||||
|
border: string; // what came of turning the border off, for the log
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts capturing the screen. region is in the screen's pixels and lies on it.
|
||||||
|
wgc_open :: (s: *Wgc_Source, screen: Record_Screen, region: RECT, cursor: bool, testing: Record_Testing = 0) -> ok: bool, error: string {
|
||||||
|
ok, error := gpu_open(s, screen, region);
|
||||||
|
if !ok return false, error;
|
||||||
|
|
||||||
|
session_statics: *Session_Statics;
|
||||||
|
hr := activation_factory("Windows.Graphics.Capture.GraphicsCaptureSession", IID_SESSION_STATICS, xx *session_statics);
|
||||||
|
if FAILED(hr) return false, hresult_text("Windows.Graphics.Capture is not on this system", hr);
|
||||||
|
supported: u8;
|
||||||
|
session_statics.vtable.IsSupported(session_statics, *supported);
|
||||||
|
com_release(*session_statics);
|
||||||
|
if !supported return false, "Windows.Graphics.Capture is not supported here";
|
||||||
|
|
||||||
|
interop: *Capture_Item_Interop;
|
||||||
|
hr = activation_factory("Windows.Graphics.Capture.GraphicsCaptureItem", IID_ITEM_INTEROP, xx *interop);
|
||||||
|
if FAILED(hr) return false, hresult_text("IGraphicsCaptureItemInterop", hr);
|
||||||
|
hr = interop.vtable.CreateForMonitor(interop, screen.monitor, *IID_ITEM, xx *s.item);
|
||||||
|
com_release(*interop);
|
||||||
|
if FAILED(hr) return false, hresult_text("a capture item for the screen", hr);
|
||||||
|
s.item.vtable.get_Size(s.item, *s.screen_size);
|
||||||
|
desktop_width := screen.desktop.right - screen.desktop.left;
|
||||||
|
desktop_height := screen.desktop.bottom - screen.desktop.top;
|
||||||
|
if s.screen_size.width != desktop_width || s.screen_size.height != desktop_height {
|
||||||
|
return false, tprint("the screen is % x %, but this process sees it as % x %: it must be aware of each monitor's DPI",
|
||||||
|
s.screen_size.width, s.screen_size.height, desktop_width, desktop_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
dxgi_device: *IDXGIDevice;
|
||||||
|
hr = IUnknown_QueryInterface(s.device, *uid(IDXGIDevice_UUID), xx *dxgi_device);
|
||||||
|
if FAILED(hr) return false, hresult_text("the device's IDXGIDevice", hr);
|
||||||
|
inspectable: *IInspectable;
|
||||||
|
hr = CreateDirect3D11DeviceFromDXGIDevice(dxgi_device, *inspectable);
|
||||||
|
com_release(*dxgi_device);
|
||||||
|
if FAILED(hr) return false, hresult_text("CreateDirect3D11DeviceFromDXGIDevice", hr);
|
||||||
|
hr = inspectable.vtable.QueryInterface(inspectable, *IID_DIRECT3D_DEVICE, xx *s.winrt_device);
|
||||||
|
com_release(*inspectable);
|
||||||
|
if FAILED(hr) return false, hresult_text("the device as IDirect3DDevice", hr);
|
||||||
|
|
||||||
|
pool_statics: *Frame_Pool_Statics2;
|
||||||
|
hr = activation_factory("Windows.Graphics.Capture.Direct3D11CaptureFramePool", IID_POOL_STATICS2, xx *pool_statics);
|
||||||
|
if FAILED(hr) return false, hresult_text("Direct3D11CaptureFramePool's statics", hr);
|
||||||
|
hr = pool_statics.vtable.CreateFreeThreaded(pool_statics, s.winrt_device, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS, size_packed(s.screen_size), *s.pool);
|
||||||
|
com_release(*pool_statics);
|
||||||
|
if FAILED(hr) return false, hresult_text("a free-threaded frame pool", hr);
|
||||||
|
hr = s.pool.vtable.CreateCaptureSession(s.pool, s.item, *s.session);
|
||||||
|
if FAILED(hr) return false, hresult_text("a capture session", hr);
|
||||||
|
|
||||||
|
// The cursor (Windows 10 2004 on) and the border (Windows 11): optional, by what the system has.
|
||||||
|
session2: *Capture_Session2;
|
||||||
|
if SUCCEEDED(s.session.vtable.QueryInterface(s.session, *IID_SESSION2, xx *session2)) {
|
||||||
|
session2.vtable.put_IsCursorCaptureEnabled(session2, xx cursor);
|
||||||
|
com_release(*session2);
|
||||||
|
}
|
||||||
|
session3: *Capture_Session3;
|
||||||
|
if testing & .KEEP_BORDER {
|
||||||
|
s.border = "border left on";
|
||||||
|
} else if SUCCEEDED(s.session.vtable.QueryInterface(s.session, *IID_SESSION3, xx *session3)) {
|
||||||
|
hr = session3.vtable.put_IsBorderRequired(session3, 0);
|
||||||
|
s.border = ifx SUCCEEDED(hr) then "border off" else hresult_text("the border could not be turned off", hr);
|
||||||
|
com_release(*session3);
|
||||||
|
} else {
|
||||||
|
s.border = "border always drawn (no IGraphicsCaptureSession3: Windows 10)";
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handler.vtable = *frame_handler_vtable;
|
||||||
|
s.handler.event = CreateEventW(null, 0, 0, null);
|
||||||
|
hr = ifx testing & .WGC_POLL then cast,no_check(HRESULT) 0x80004002 else s.pool.vtable.add_FrameArrived(s.pool, *s.handler, *s.handler_token);
|
||||||
|
if FAILED(hr) {
|
||||||
|
s.polling = true;
|
||||||
|
timeBeginPeriod(1); // so polling every millisecond is every millisecond
|
||||||
|
log("[record] FrameArrived would not take the handler (%); polling the frame pool.", hresult_text("add_FrameArrived", hr));
|
||||||
|
}
|
||||||
|
|
||||||
|
hr = s.session.vtable.StartCapture(s.session);
|
||||||
|
if FAILED(hr) return false, hresult_text("StartCapture", hr);
|
||||||
|
return true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits up to timeout_ms for a new frame; the newest's region goes into s.latest.
|
||||||
|
wgc_next :: (s: *Wgc_Source, timeout_ms: u32) -> Capture_Next, error: string {
|
||||||
|
frame := wgc_take_newest(s);
|
||||||
|
if !frame {
|
||||||
|
if s.polling {
|
||||||
|
start := ticks_now();
|
||||||
|
frequency: LARGE_INTEGER;
|
||||||
|
QueryPerformanceFrequency(*frequency);
|
||||||
|
while !frame && (ticks_now() - start) * 1000 / frequency.QuadPart < timeout_ms {
|
||||||
|
sleep_milliseconds(1);
|
||||||
|
frame = wgc_take_newest(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WaitForSingleObject(s.handler.event, timeout_ms);
|
||||||
|
frame = wgc_take_newest(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !frame return .NONE, "";
|
||||||
|
defer close_and_release(frame);
|
||||||
|
|
||||||
|
// The screen changed size (a new resolution): the pool is made again; the region must still fit.
|
||||||
|
size: Size_Int32;
|
||||||
|
frame.vtable.get_ContentSize(frame, *size);
|
||||||
|
if size.width != s.screen_size.width || size.height != s.screen_size.height {
|
||||||
|
s.screen_size = size;
|
||||||
|
hr := s.pool.vtable.Recreate(s.pool, s.winrt_device, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS, size_packed(size));
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("the frame pool could not be made again", hr);
|
||||||
|
if cast(s32) s.box.right > size.width || cast(s32) s.box.bottom > size.height
|
||||||
|
return .FAILED, tprint("the screen is now % x %, and the region is not on it", size.width, size.height);
|
||||||
|
return .NONE, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
surface: *IInspectable;
|
||||||
|
hr := frame.vtable.get_Surface(frame, *surface);
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("the frame's surface", hr);
|
||||||
|
defer com_release(*surface);
|
||||||
|
access: *Dxgi_Interface_Access;
|
||||||
|
hr = surface.vtable.QueryInterface(surface, *IID_DXGI_INTERFACE_ACCESS, xx *access);
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("the surface's IDirect3DDxgiInterfaceAccess", hr);
|
||||||
|
defer com_release(*access);
|
||||||
|
texture: *ID3D11Texture2D;
|
||||||
|
hr = access.vtable.GetInterface(access, *uid(ID3D11Texture2D_UUID), xx *texture);
|
||||||
|
if FAILED(hr) return .FAILED, hresult_text("the surface's texture", hr);
|
||||||
|
gpu_copy_region(s, texture);
|
||||||
|
IUnknown_Release(texture);
|
||||||
|
return .IMAGE, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
wgc_close :: (s: *Wgc_Source) {
|
||||||
|
if s.pool && s.handler_token s.pool.vtable.remove_FrameArrived(s.pool, s.handler_token);
|
||||||
|
if s.session close_and_release(s.session);
|
||||||
|
if s.pool close_and_release(s.pool);
|
||||||
|
s.session, s.pool = null, null;
|
||||||
|
com_release(*s.item);
|
||||||
|
com_release(*s.winrt_device);
|
||||||
|
if s.handler.event CloseHandle(s.handler.event);
|
||||||
|
if s.polling timeEndPeriod(1);
|
||||||
|
gpu_close(s);
|
||||||
|
s.* = .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The thread's apartment: WinRT on this thread from here on.
|
||||||
|
wgc_thread_start :: () -> ok: bool {
|
||||||
|
if !winrt_load() return false;
|
||||||
|
return SUCCEEDED(winrt.RoInitialize(RO_INIT_MULTITHREADED));
|
||||||
|
}
|
||||||
|
|
||||||
|
wgc_thread_end :: () {
|
||||||
|
if winrt.RoUninitialize winrt.RoUninitialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
POOL_BUFFERS :: 2;
|
||||||
|
PIXEL_FORMAT_B8G8R8A8 :: 87; // DirectXPixelFormat.B8G8R8A8UIntNormalized
|
||||||
|
RO_INIT_MULTITHREADED :: 1;
|
||||||
|
|
||||||
|
// The pool's newest frame, the older ones let go; null when there is none.
|
||||||
|
wgc_take_newest :: (s: *Wgc_Source) -> *Capture_Frame {
|
||||||
|
newest: *Capture_Frame;
|
||||||
|
while true {
|
||||||
|
frame: *Capture_Frame;
|
||||||
|
if FAILED(s.pool.vtable.TryGetNextFrame(s.pool, *frame)) || !frame break;
|
||||||
|
if newest close_and_release(newest);
|
||||||
|
newest = frame;
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IClosable.Close, which hands a frame back to the pool at once, then Release.
|
||||||
|
close_and_release :: (object: *$T) {
|
||||||
|
closable: *Closable;
|
||||||
|
if SUCCEEDED(object.vtable.QueryInterface(object, *IID_CLOSABLE, xx *closable)) {
|
||||||
|
closable.vtable.Close(closable);
|
||||||
|
closable.vtable.Release(closable);
|
||||||
|
}
|
||||||
|
object.vtable.Release(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
activation_factory :: (class_name: string, iid: GUID, factory: **void) -> HRESULT {
|
||||||
|
wide := utf8_to_wide(class_name,, temp);
|
||||||
|
length: u32;
|
||||||
|
while wide[length] length += 1;
|
||||||
|
name: HSTRING;
|
||||||
|
if !winrt.loaded return cast,no_check(HRESULT) 0x80004001; // E_NOTIMPL: no WinRT on this system
|
||||||
|
hr := winrt.WindowsCreateString(wide, length, *name);
|
||||||
|
if FAILED(hr) return hr;
|
||||||
|
defer winrt.WindowsDeleteString(name);
|
||||||
|
iid_copy := iid;
|
||||||
|
return winrt.RoGetActivationFactory(name, *iid_copy, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_packed :: (size: Size_Int32) -> u64 { // SizeInt32 by value: 8 bytes, in one register
|
||||||
|
width := cast(u64) cast,no_check(u32) size.width;
|
||||||
|
height := cast(u64) cast,no_check(u32) size.height;
|
||||||
|
return width | (height << 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the FrameArrived handler
|
||||||
|
|
||||||
|
// TypedEventHandler<Direct3D11CaptureFramePool, Object>: a COM object of our own whose Invoke sets an
|
||||||
|
// event. It lives in the Wgc_Source and is never freed by its count; it is unregistered first.
|
||||||
|
Frame_Handler :: struct {
|
||||||
|
vtable: *Frame_Handler_VTable;
|
||||||
|
event: HANDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Frame_Handler_VTable :: struct {
|
||||||
|
QueryInterface: (this: *Frame_Handler, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
AddRef: (this: *Frame_Handler) -> u32 #c_call;
|
||||||
|
Release: (this: *Frame_Handler) -> u32 #c_call;
|
||||||
|
Invoke: (this: *Frame_Handler, sender: *void, args: *void) -> HRESULT #c_call;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame_handler_vtable := Frame_Handler_VTable.{handler_query, handler_add_ref, handler_release, handler_invoke};
|
||||||
|
|
||||||
|
handler_query :: (this: *Frame_Handler, iid: *GUID, out: **void) -> HRESULT #c_call {
|
||||||
|
if guid_equal(iid, *IID_UNKNOWN) || guid_equal(iid, *IID_AGILE_OBJECT) || guid_equal(iid, *IID_FRAME_ARRIVED_HANDLER) {
|
||||||
|
out.* = this;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
out.* = null;
|
||||||
|
return cast,no_check(HRESULT) 0x80004002; // E_NOINTERFACE
|
||||||
|
}
|
||||||
|
|
||||||
|
handler_add_ref :: (this: *Frame_Handler) -> u32 #c_call { return 1; }
|
||||||
|
handler_release :: (this: *Frame_Handler) -> u32 #c_call { return 1; }
|
||||||
|
|
||||||
|
handler_invoke :: (this: *Frame_Handler, sender: *void, args: *void) -> HRESULT #c_call {
|
||||||
|
SetEvent(this.event);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
guid_equal :: (a: *GUID, b: *GUID) -> bool #c_call {
|
||||||
|
if a.Data1 != b.Data1 || a.Data2 != b.Data2 || a.Data3 != b.Data3 return false;
|
||||||
|
for 0..7 if a.Data4[it] != b.Data4[it] return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- bindings
|
||||||
|
|
||||||
|
d3d11_dll :: #library,system "d3d11";
|
||||||
|
kernel32_dll :: #library,system "kernel32";
|
||||||
|
|
||||||
|
HSTRING :: *void;
|
||||||
|
|
||||||
|
// WinRT's own calls, from combase.dll at run time: its import library is runtimeobject.lib, which
|
||||||
|
// names no DLL of its own for #library to find.
|
||||||
|
winrt: struct {
|
||||||
|
loaded: bool;
|
||||||
|
RoInitialize: (init_type: s32) -> HRESULT #c_call;
|
||||||
|
RoUninitialize: () #c_call;
|
||||||
|
RoGetActivationFactory: (class_id: HSTRING, iid: *GUID, factory: **void) -> HRESULT #c_call;
|
||||||
|
WindowsCreateString: (source: *u16, length: u32, out: *HSTRING) -> HRESULT #c_call;
|
||||||
|
WindowsDeleteString: (text: HSTRING) -> HRESULT #c_call;
|
||||||
|
}
|
||||||
|
|
||||||
|
winrt_load :: () -> bool {
|
||||||
|
if winrt.loaded return true;
|
||||||
|
library := LoadLibraryW(utf8_to_wide("combase.dll",, temp));
|
||||||
|
if !library return false;
|
||||||
|
winrt.RoInitialize = xx GetProcAddress(library, "RoInitialize");
|
||||||
|
winrt.RoUninitialize = xx GetProcAddress(library, "RoUninitialize");
|
||||||
|
winrt.RoGetActivationFactory = xx GetProcAddress(library, "RoGetActivationFactory");
|
||||||
|
winrt.WindowsCreateString = xx GetProcAddress(library, "WindowsCreateString");
|
||||||
|
winrt.WindowsDeleteString = xx GetProcAddress(library, "WindowsDeleteString");
|
||||||
|
winrt.loaded = winrt.RoInitialize && winrt.RoUninitialize && winrt.RoGetActivationFactory && winrt.WindowsCreateString && winrt.WindowsDeleteString;
|
||||||
|
return winrt.loaded;
|
||||||
|
}
|
||||||
|
CreateDirect3D11DeviceFromDXGIDevice :: (device: *IDXGIDevice, out: **IInspectable) -> HRESULT #foreign d3d11_dll;
|
||||||
|
SetEvent :: (event: HANDLE) -> s32 #foreign kernel32_dll;
|
||||||
|
|
||||||
|
Size_Int32 :: struct { width, height: s32; }
|
||||||
|
|
||||||
|
IID_UNKNOWN := #run guid("00000000-0000-0000-C000-000000000046");
|
||||||
|
IID_AGILE_OBJECT := #run guid("94ea2b94-e9cc-49e0-c0ff-ee64ca8f5b90");
|
||||||
|
IID_FRAME_ARRIVED_HANDLER := #run guid("51a947f7-79cf-5a3e-a3a5-1289cfa6dfe8"); // TypedEventHandler<Direct3D11CaptureFramePool, Object>
|
||||||
|
IID_ITEM_INTEROP := #run guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356");
|
||||||
|
IID_ITEM := #run guid("79C3F95B-31F7-4EC2-A464-632EF5D30760");
|
||||||
|
IID_SESSION_STATICS := #run guid("2224A540-5974-49AA-B232-0882536F4CB5");
|
||||||
|
IID_SESSION2 := #run guid("2C39AE40-7D2E-5044-804E-8B6799D4CF9E");
|
||||||
|
IID_SESSION3 := #run guid("F2CDD966-22AE-5EA1-9596-3A289344C3BE");
|
||||||
|
IID_POOL_STATICS2 := #run guid("589B103F-6BBC-5DF5-A991-02E28B3B66D5");
|
||||||
|
IID_DIRECT3D_DEVICE := #run guid("A37624AB-8D5F-4650-9D3E-9EAE3D9BC670");
|
||||||
|
IID_DXGI_INTERFACE_ACCESS := #run guid("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1");
|
||||||
|
IID_CLOSABLE := #run guid("30D5A829-7FA4-4026-83BB-D75BAE4EA99E");
|
||||||
|
|
||||||
|
|
||||||
|
// IUnknown, then IInspectable's three: every WinRT interface starts so.
|
||||||
|
Inspectable_Methods :: struct {
|
||||||
|
QueryInterface: (this: *void, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
AddRef: (this: *void) -> u32 #c_call;
|
||||||
|
Release: (this: *void) -> u32 #c_call;
|
||||||
|
GetIids: *void;
|
||||||
|
GetRuntimeClassName: *void;
|
||||||
|
GetTrustLevel: *void;
|
||||||
|
}
|
||||||
|
|
||||||
|
IInspectable :: struct { vtable: *Inspectable_Methods; }
|
||||||
|
|
||||||
|
Closable :: struct { vtable: *struct { using base: Inspectable_Methods; Close: (this: *Closable) -> HRESULT #c_call; }; }
|
||||||
|
|
||||||
|
Session_Statics :: struct { vtable: *struct { using base: Inspectable_Methods; IsSupported: (this: *Session_Statics, out: *u8) -> HRESULT #c_call; }; }
|
||||||
|
|
||||||
|
Capture_Item_Interop :: struct { vtable: *struct {
|
||||||
|
QueryInterface: (this: *void, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
AddRef: (this: *void) -> u32 #c_call;
|
||||||
|
Release: (this: *void) -> u32 #c_call;
|
||||||
|
CreateForWindow: (this: *Capture_Item_Interop, window: HWND, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
CreateForMonitor: (this: *Capture_Item_Interop, monitor: HMONITOR, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Capture_Item :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
get_DisplayName: *void;
|
||||||
|
get_Size: (this: *Capture_Item, out: *Size_Int32) -> HRESULT #c_call;
|
||||||
|
add_Closed: *void;
|
||||||
|
remove_Closed: *void;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Frame_Pool_Statics2 :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
CreateFreeThreaded: (this: *Frame_Pool_Statics2, device: *IInspectable, format: s32, buffers: s32, size: u64, out: **Frame_Pool) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Frame_Pool :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
Recreate: (this: *Frame_Pool, device: *IInspectable, format: s32, buffers: s32, size: u64) -> HRESULT #c_call;
|
||||||
|
TryGetNextFrame: (this: *Frame_Pool, out: **Capture_Frame) -> HRESULT #c_call;
|
||||||
|
add_FrameArrived: (this: *Frame_Pool, handler: *Frame_Handler, token: *s64) -> HRESULT #c_call;
|
||||||
|
remove_FrameArrived: (this: *Frame_Pool, token: s64) -> HRESULT #c_call;
|
||||||
|
CreateCaptureSession: (this: *Frame_Pool, item: *Capture_Item, out: **Capture_Session) -> HRESULT #c_call;
|
||||||
|
get_DispatcherQueue: *void;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Capture_Frame :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
get_Surface: (this: *Capture_Frame, out: **IInspectable) -> HRESULT #c_call;
|
||||||
|
get_SystemRelativeTime: (this: *Capture_Frame, out: *s64) -> HRESULT #c_call;
|
||||||
|
get_ContentSize: (this: *Capture_Frame, out: *Size_Int32) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Capture_Session :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
StartCapture: (this: *Capture_Session) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Capture_Session2 :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
get_IsCursorCaptureEnabled: (this: *Capture_Session2, out: *u8) -> HRESULT #c_call;
|
||||||
|
put_IsCursorCaptureEnabled: (this: *Capture_Session2, value: u8) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Capture_Session3 :: struct { vtable: *struct {
|
||||||
|
using base: Inspectable_Methods;
|
||||||
|
get_IsBorderRequired: (this: *Capture_Session3, out: *u8) -> HRESULT #c_call;
|
||||||
|
put_IsBorderRequired: (this: *Capture_Session3, value: u8) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
|
|
||||||
|
Dxgi_Interface_Access :: struct { vtable: *struct {
|
||||||
|
QueryInterface: (this: *void, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
AddRef: (this: *void) -> u32 #c_call;
|
||||||
|
Release: (this: *void) -> u32 #c_call;
|
||||||
|
GetInterface: (this: *Dxgi_Interface_Access, iid: *GUID, out: **void) -> HRESULT #c_call;
|
||||||
|
}; }
|
||||||
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);
|
||||||
@ -148,18 +151,36 @@ segment_create :: (models_folder: string, model_name: string, prefer_gpu := true
|
|||||||
if !ok { segment_destroy(engine); return null, false, error; }
|
if !ok { segment_destroy(engine); return null, false, error; }
|
||||||
|
|
||||||
api := ort.api;
|
api := ort.api;
|
||||||
ok, error = ort_check(api.CreateEnv(.ORT_LOGGING_LEVEL_WARNING, "segment", *engine.env));
|
ok, error = ort_check(api.CreateEnv(ort.log_level, "segment", *engine.env));
|
||||||
if !ok { segment_destroy(engine); return null, false, error; }
|
if !ok { segment_destroy(engine); return null, false, error; }
|
||||||
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
|
||||||
|
|
||||||
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,19 +70,31 @@ 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;
|
||||||
|
|
||||||
array_add(*libpaths, "windows");
|
#if OS == .MACOS {
|
||||||
|
// The five libraries build_macos.jai makes, as their major versions name them; with the
|
||||||
libraries := string.[
|
// extension, since the generator takes a name with a dot in it for a whole file name.
|
||||||
"avcodec-60",
|
array_add(*libpaths, "macos");
|
||||||
"avdevice-60",
|
library_names :: string.[
|
||||||
"avfilter-9",
|
"libavcodec.60.dylib",
|
||||||
"avformat-60",
|
"libavformat.60.dylib",
|
||||||
"avutil-58",
|
"libavutil.58.dylib",
|
||||||
"postproc-57",
|
"libswresample.4.dylib",
|
||||||
"swresample-4",
|
"libswscale.7.dylib",
|
||||||
"swscale-7",
|
];
|
||||||
];
|
} else {
|
||||||
for libraries array_add(*libnames, it);
|
array_add(*libpaths, "windows");
|
||||||
|
library_names :: string.[
|
||||||
|
"avcodec-60",
|
||||||
|
"avdevice-60",
|
||||||
|
"avfilter-9",
|
||||||
|
"avformat-60",
|
||||||
|
"avutil-58",
|
||||||
|
"postproc-57",
|
||||||
|
"swresample-4",
|
||||||
|
"swscale-7",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
for library_names array_add(*libnames, it);
|
||||||
|
|
||||||
array_add(*include_paths, INCLUDE_PATH);
|
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.
|
||||||
@ -9,17 +9,22 @@ ort: struct {
|
|||||||
api: *OrtApi;
|
api: *OrtApi;
|
||||||
library_path: string; // the library that was loaded
|
library_path: string; // the library that was loaded
|
||||||
version: string; // onnxruntime's own version string, such as 1.30.0
|
version: string; // onnxruntime's own version string, such as 1.30.0
|
||||||
|
// What the engines' environments log, to the console: set before the first engine is made, as
|
||||||
|
// onnxruntime keeps one environment per process. INFO shows each GPU arena extension and its size.
|
||||||
|
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;
|
||||||
|
|||||||
@ -20,17 +20,21 @@ ort_gpu_held: s32;
|
|||||||
|
|
||||||
Ort_Session :: struct {
|
Ort_Session :: struct {
|
||||||
session: *OrtSession;
|
session: *OrtSession;
|
||||||
|
shrink_options: *OrtRunOptions; // for a run that shrinks the arena afterwards (ort_run's shrink); null when the settings did not ask for it
|
||||||
input_name, output_name: *u8; // heap C strings
|
input_name, output_name: *u8; // heap C strings
|
||||||
input_shape: [4] s64; // as the model declares it: -1 for a size it leaves open
|
input_shape: [4] s64; // as the model declares it: -1 for a size it leaves open
|
||||||
input_rank: s64;
|
input_rank: s64;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How a CUDA session uses GPU memory. The defaults came from measuring OCR on the sample captures
|
// How a CUDA session uses GPU memory. Its arena takes blocks from CUDA as runs need them, and by
|
||||||
// (RTX 5080 laptop): with nearly every input a new size, onnxruntime's exact-size arena filled GPU
|
// default never joins or frees them: a model whose input size changes (OCR's) leaves a block of
|
||||||
// memory, and cuDNN's largest workspaces made it worse.
|
// every larger size behind, gigabytes of them, unless the arena is shrunk now and then. Shrinking
|
||||||
|
// after every run costs a third to a half of OCR's speed (CUDA frees and allocates again, waiting for
|
||||||
|
// the GPU), so OCR shrinks once per call (ocr_process).
|
||||||
Ort_Gpu_Settings :: struct {
|
Ort_Gpu_Settings :: struct {
|
||||||
arena_extend_strategy := "kNextPowerOfTwo"; // reuses freed blocks for other sizes; "kSameAsRequested" grows by exactly what is asked
|
arena_extend_strategy := "kNextPowerOfTwo"; // how much the arena takes when it runs short: twice as much each time; "kSameAsRequested" takes what is asked
|
||||||
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
|
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
|
||||||
|
shrink_arena := false; // a run with ort_run's shrink set then gives back the arena's blocks no run is using
|
||||||
}
|
}
|
||||||
|
|
||||||
// A session for the model file at path; with CUDA when gpu is set. output names the output that
|
// A session for the model file at path; with CUDA when gpu is set. output names the output that
|
||||||
@ -65,6 +69,12 @@ ort_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads:
|
|||||||
if !file_exists(path) return result, false, tprint("% is not there%.", path, ifx missing_hint then tprint("; %", missing_hint) else "");
|
if !file_exists(path) return result, false, tprint("% is not there%.", path, ifx missing_hint then tprint("; %", missing_hint) else "");
|
||||||
ok, error = ort_check(api.CreateSession(env, ort_path(path), options, *result.session));
|
ok, error = ort_check(api.CreateSession(env, ort_path(path), options, *result.session));
|
||||||
if !ok return result, false, error;
|
if !ok return result, false, error;
|
||||||
|
if gpu && settings.shrink_arena {
|
||||||
|
// Device 0's arena, as device_id above; the CPU's is left alone.
|
||||||
|
ok, error = ort_check(api.CreateRunOptions(*result.shrink_options));
|
||||||
|
if ok ok, error = ort_check(api.AddRunConfigEntry(result.shrink_options, "memory.enable_memory_arena_shrinkage", "gpu:0"));
|
||||||
|
if !ok { ort_release_session(*result); return result, false, error; }
|
||||||
|
}
|
||||||
|
|
||||||
allocator: *OrtAllocator;
|
allocator: *OrtAllocator;
|
||||||
ort_check(api.GetAllocatorWithDefaultOptions(*allocator));
|
ort_check(api.GetAllocatorWithDefaultOptions(*allocator));
|
||||||
@ -107,11 +117,56 @@ ort_create_session :: (env: *OrtEnv, path: string, gpu: bool, intra_op_threads:
|
|||||||
|
|
||||||
ort_release_session :: (s: *Ort_Session) {
|
ort_release_session :: (s: *Ort_Session) {
|
||||||
if s.session ort.api.ReleaseSession(s.session);
|
if s.session ort.api.ReleaseSession(s.session);
|
||||||
|
if s.shrink_options ort.api.ReleaseRunOptions(s.shrink_options);
|
||||||
free(s.input_name,, context.default_allocator);
|
free(s.input_name,, context.default_allocator);
|
||||||
free(s.output_name,, context.default_allocator);
|
free(s.output_name,, context.default_allocator);
|
||||||
s.* = .{};
|
s.* = .{};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The GPU's free and total memory in bytes, from the CUDA runtime onnxruntime's CUDA provider loads;
|
||||||
|
// ok false before a CUDA session has been made, or without CUDA.
|
||||||
|
ort_cuda_memory :: () -> free: s64, total: s64, ok: bool {
|
||||||
|
Get_Info :: #type (free: *u64, total: *u64) -> s32 #c_call;
|
||||||
|
get: Get_Info;
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
module := GetModuleHandleA("cudart64_13.dll");
|
||||||
|
if module get = cast(Get_Info) GetProcAddress(module, "cudaMemGetInfo");
|
||||||
|
} else #if OS == .LINUX {
|
||||||
|
handle := dlopen("libcudart.so.13", RTLD_LAZY);
|
||||||
|
if handle get = cast(Get_Info) dlsym(handle, "cudaMemGetInfo");
|
||||||
|
}
|
||||||
|
if !get return 0, 0, false;
|
||||||
|
free, total: u64;
|
||||||
|
if get(*free, *total) != 0 return 0, 0, false;
|
||||||
|
return cast(s64) free, cast(s64) total, true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CUDA session's GPU arena: the bytes its blocks hand out now, and the most they ever have (onnxruntime
|
||||||
|
// keeps the peak since the session was made). ok false when onnxruntime does not say.
|
||||||
|
ort_gpu_arena_stats :: (s: *Ort_Session) -> in_use: s64, max_in_use: s64, ok: bool {
|
||||||
|
api := ort.api;
|
||||||
|
info: *OrtMemoryInfo;
|
||||||
|
if !ort_check(api.CreateMemoryInfo("Cuda", .OrtArenaAllocator, 0, .OrtMemTypeDefault, *info)) return 0, 0, false;
|
||||||
|
defer api.ReleaseMemoryInfo(info);
|
||||||
|
allocator: *OrtAllocator;
|
||||||
|
if !ort_check(api.CreateAllocator(s.session, info, *allocator)) return 0, 0, false;
|
||||||
|
defer api.ReleaseAllocator(allocator);
|
||||||
|
stats: *OrtKeyValuePairs;
|
||||||
|
if !ort_check(api.AllocatorGetStats(allocator, *stats)) return 0, 0, false;
|
||||||
|
defer api.ReleaseKeyValuePairs(stats);
|
||||||
|
keys, values: **u8;
|
||||||
|
count: u64;
|
||||||
|
api.GetKeyValuePairs(stats, *keys, *values, *count);
|
||||||
|
in_use, max_in_use := -1, -1;
|
||||||
|
for 0..cast(s64) count - 1 {
|
||||||
|
key := to_string(keys[it]);
|
||||||
|
if key == "InUse" in_use = string_to_int(to_string(values[it]));
|
||||||
|
if key == "MaxInUse" max_in_use = string_to_int(to_string(values[it]));
|
||||||
|
}
|
||||||
|
if in_use < 0 || max_in_use < 0 return 0, 0, false;
|
||||||
|
return in_use, max_in_use, true;
|
||||||
|
}
|
||||||
|
|
||||||
// A run's output: its values stay valid until ort_release_output.
|
// A run's output: its values stay valid until ort_release_output.
|
||||||
Ort_Output :: struct {
|
Ort_Output :: struct {
|
||||||
value: *OrtValue;
|
value: *OrtValue;
|
||||||
@ -121,8 +176,9 @@ Ort_Output :: struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs the session on one input tensor of shape, bytes long at data, with element type element.
|
// Runs the session on one input tensor of shape, bytes long at data, with element type element.
|
||||||
// A session may be run from several threads at once.
|
// A session may be run from several threads at once. With shrink, and a session made with
|
||||||
ort_run :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: *void, bytes: s64, element: ONNXTensorElementDataType, shape: [] s64) -> Ort_Output, ok: bool, error: string {
|
// shrink_arena, the arena gives back the blocks no run is using once this run is done.
|
||||||
|
ort_run :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: *void, bytes: s64, element: ONNXTensorElementDataType, shape: [] s64, shrink := false) -> Ort_Output, ok: bool, error: string {
|
||||||
out: Ort_Output;
|
out: Ort_Output;
|
||||||
api := ort.api;
|
api := ort.api;
|
||||||
input: *OrtValue;
|
input: *OrtValue;
|
||||||
@ -130,7 +186,7 @@ ort_run :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: *void, bytes: s6
|
|||||||
if !ok return out, false, error;
|
if !ok return out, false, error;
|
||||||
defer api.ReleaseValue(input);
|
defer api.ReleaseValue(input);
|
||||||
|
|
||||||
ok, error = ort_check(api.Run(s.session, null, *s.input_name, *input, 1, *s.output_name, 1, *out.value));
|
ok, error = ort_check(api.Run(s.session, ifx shrink then s.shrink_options else null, *s.input_name, *input, 1, *s.output_name, 1, *out.value));
|
||||||
if !ok return out, false, error;
|
if !ok return out, false, error;
|
||||||
|
|
||||||
raw: *void;
|
raw: *void;
|
||||||
@ -146,8 +202,8 @@ ort_run :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: *void, bytes: s6
|
|||||||
return out, true, "";
|
return out, true, "";
|
||||||
}
|
}
|
||||||
|
|
||||||
ort_run_float :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: [] float, shape: [] s64) -> Ort_Output, ok: bool, error: string {
|
ort_run_float :: (s: *Ort_Session, memory_info: *OrtMemoryInfo, data: [] float, shape: [] s64, shrink := false) -> Ort_Output, ok: bool, error: string {
|
||||||
out, ok, error := ort_run(s, memory_info, data.data, data.count * size_of(float), .ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, shape);
|
out, ok, error := ort_run(s, memory_info, data.data, data.count * size_of(float), .ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, shape, shrink);
|
||||||
return out, ok, error;
|
return out, ok, error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
288
release.jai
Normal file
288
release.jai
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
// Makes a release of mcap for Windows x64: one folder with everything it needs to run (the program,
|
||||||
|
// onnxruntime, FFmpeg, the OCR, label and segmentation models, and NVIDIA's CUDA 13 runtime and
|
||||||
|
// cuDNN 9, so the GPU works with nothing but the NVIDIA driver installed), zipped:
|
||||||
|
//
|
||||||
|
// jai release.jai release/mcap-VERSION-windows-x64/ and release/mcap-VERSION-windows-x64.zip
|
||||||
|
// jai release.jai - no_cuda without the CUDA runtime (1.3 GB less): the GPU then needs CUDA 13 and cuDNN 9 installed
|
||||||
|
// jai release.jai - no_build package bin/ as it is, without building it again first
|
||||||
|
//
|
||||||
|
// VERSION comes from first.jai. A tree with changes, or a HEAD that is not tagged vVERSION, is warned
|
||||||
|
// about, not refused. The models must be in bin/ already: OCR's and the labels' from their
|
||||||
|
// fetch_models.jai, segmentation's from tools/export_mask2former.py (see each module's README).
|
||||||
|
|
||||||
|
#run,stallable {
|
||||||
|
set_build_options_dc(.{do_output = false});
|
||||||
|
args := get_build_options().compile_time_command_line;
|
||||||
|
with_cuda := !array_find(args, "no_cuda");
|
||||||
|
build := !array_find(args, "no_build");
|
||||||
|
set_working_directory(#filepath);
|
||||||
|
|
||||||
|
version := read_version();
|
||||||
|
if !version fail("Cannot find VERSION in first.jai.");
|
||||||
|
name := tprint("mcap-%-windows-x64", version);
|
||||||
|
log("Releasing % (%).", name, ifx with_cuda then "with the CUDA runtime" else "without the CUDA runtime");
|
||||||
|
|
||||||
|
// Where the source is: warned about, not refused.
|
||||||
|
status, status_ok := run("git", "status", "--porcelain");
|
||||||
|
if status_ok && trim(status) log("Warning: the working tree has changes; the release is built from them.");
|
||||||
|
tag, tag_ok := run("git", "describe", "--exact-match", "--tags", "HEAD");
|
||||||
|
if !tag_ok || trim(tag) != tprint("v%", version) log("Warning: HEAD is not tagged v%.", version);
|
||||||
|
|
||||||
|
if build {
|
||||||
|
log("Building the release build...");
|
||||||
|
_, built := run("jai", "first.jai", "-", "release", print = true);
|
||||||
|
if !built fail("The build failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folder, from nothing.
|
||||||
|
out := tprint("release/%", name);
|
||||||
|
if file_exists(out) && (!delete_directory(out) || file_exists(out)) fail(tprint("Cannot delete the last % (is a program or a shell in it?).", out));
|
||||||
|
make_directory_if_it_does_not_exist(out, recursive = true);
|
||||||
|
|
||||||
|
// The program and its libraries.
|
||||||
|
copy("bin/mcap.exe", out);
|
||||||
|
copy("bin/mcap.pdb", out); // symbols, for crash reports' stack traces
|
||||||
|
for file_list("bin") {
|
||||||
|
if ends_with_nocase(it, ".dll") copy(it, out);
|
||||||
|
}
|
||||||
|
for string.["data", "fonts", "images"] copy_folder(tprint("bin/%", it), tprint("%/%", out, it));
|
||||||
|
|
||||||
|
// The models: each folder as the build copied it, checked for the files its models.config names.
|
||||||
|
for string.["ocr-models", "label-models", "segment-models"] {
|
||||||
|
check_models(tprint("bin/%", it));
|
||||||
|
copy_folder(tprint("bin/%", it), tprint("%/%", out, it));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NVIDIA's runtime, beside the program, where Windows looks for a DLL first.
|
||||||
|
if with_cuda {
|
||||||
|
missing: [..] string;
|
||||||
|
for CUDA_FILES {
|
||||||
|
path := find_cuda_file(it);
|
||||||
|
if !path { array_add(*missing, it); continue; }
|
||||||
|
copy(path, out);
|
||||||
|
}
|
||||||
|
// All of cuDNN, not a list of it: it loads sub-libraries by name as it needs them (9.26 added
|
||||||
|
// cudnn_engines_tensor_ir64_9.dll), and one missing fails every run on the GPU.
|
||||||
|
cudnn := find_cuda_file("cudnn64_9.dll");
|
||||||
|
if !cudnn array_add(*missing, "cudnn64_9.dll");
|
||||||
|
else for file_list(path_strip_filename(cudnn)) {
|
||||||
|
name := path_filename(it);
|
||||||
|
if begins_with_nocase(name, "cudnn") && ends_with_nocase(name, ".dll") copy(it, out);
|
||||||
|
}
|
||||||
|
if missing fail(tprint("Cannot find % (on PATH, or under Program Files\\NVIDIA). Install the CUDA 13 toolkit and cuDNN 9 for CUDA 13, or release with `- no_cuda`.", join(..missing, ", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// print has no escape for %, which the README uses: the templates' blanks are filled by name.
|
||||||
|
readme := replace(RELEASE_README, "{VERSION}", version);
|
||||||
|
readme = replace(readme, "{GPU}", ifx with_cuda then CUDA_INCLUDED else CUDA_NOT_INCLUDED);
|
||||||
|
write_entire_file(tprint("%/README.txt", out), readme);
|
||||||
|
write_entire_file(tprint("%/THIRD-PARTY-NOTICES.txt", out), replace(NOTICES, "{CUDA}", ifx with_cuda then CUDA_NOTICE else ""));
|
||||||
|
if !copy_file("modules/ffmpeg/windows/LICENSE-BUILD.txt", tprint("%/FFMPEG-BUILD.txt", out)) fail("Cannot copy FFmpeg's build notes.");
|
||||||
|
|
||||||
|
// The zip, with Windows' own tar (bsdtar), which writes zips (and Zip64, past 4 GB).
|
||||||
|
zip := tprint("release/%.zip", name);
|
||||||
|
if file_exists(zip) file_delete(zip);
|
||||||
|
log("Zipping % ...", zip);
|
||||||
|
_, zipped := run("C:/Windows/System32/tar.exe", "-a", "-c", "-f", zip, "-C", "release", name);
|
||||||
|
if !zipped fail("Zipping failed.");
|
||||||
|
|
||||||
|
folder_bytes := folder_size(out);
|
||||||
|
zip_bytes := file_size(zip);
|
||||||
|
hash_text, hashed := run("certutil", "-hashfile", zip, "SHA256");
|
||||||
|
sha := "";
|
||||||
|
if hashed {
|
||||||
|
lines := split(hash_text, "\n");
|
||||||
|
if lines.count >= 2 sha = trim(lines[1]);
|
||||||
|
}
|
||||||
|
log("Done: % (% MB), from % (% MB).", zip, zip_bytes / (1024 * 1024), out, folder_bytes / (1024 * 1024));
|
||||||
|
if sha log("SHA-256 %", sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CUDA toolkit's files the CUDA provider loads (modules/onnxruntime/cuda.jai: CUDA_LIBRARIES, but
|
||||||
|
// the driver and cuDNN, which is copied whole), and NVRTC, which cuDNN's runtime-compiled engines load.
|
||||||
|
CUDA_FILES :: string.[
|
||||||
|
"cudart64_13.dll", "cublas64_13.dll", "cublasLt64_13.dll", "cufft64_12.dll",
|
||||||
|
"nvrtc64_130_0.dll", "nvrtc-builtins64_134.dll",
|
||||||
|
];
|
||||||
|
|
||||||
|
// A file of CUDA's or cuDNN's: on PATH (the toolkit's installer puts it there), else the newest cuDNN
|
||||||
|
// for CUDA 13 under Program Files\NVIDIA\CUDNN (its installer does not). "" when not found.
|
||||||
|
find_cuda_file :: (name: string) -> string {
|
||||||
|
for split(path_variable(), ";") {
|
||||||
|
if !it continue;
|
||||||
|
path := tprint("%/%", trim_right(it, "\\/"), name);
|
||||||
|
if file_exists(path) return path;
|
||||||
|
}
|
||||||
|
// cuDNN: Program Files/NVIDIA/CUDNN/v9.x/bin/13.y/x64/NAME, the newest (it sorts last).
|
||||||
|
best := "";
|
||||||
|
cudnn_root := "C:/Program Files/NVIDIA/CUDNN";
|
||||||
|
if file_exists(cudnn_root) {
|
||||||
|
for file_list(cudnn_root, recursive = true) {
|
||||||
|
normal := replace(it, "\\", "/");
|
||||||
|
if path_filename(normal) != name || !contains(normal, "/bin/13.") continue;
|
||||||
|
if compare(normal, best) > 0 best = normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file a models folder's models.config names in quotes is there.
|
||||||
|
check_models :: (folder: string) {
|
||||||
|
config, ok := read_entire_file(tprint("%/models.config", folder), log_errors = false);
|
||||||
|
if !ok fail(tprint("% has no models.config: build the app first.", folder));
|
||||||
|
for raw: split(config, "\n") {
|
||||||
|
line := trim(raw);
|
||||||
|
if !line || line[0] == #char "#" || line[0] == #char "[" || line[0] == #char "@" continue;
|
||||||
|
colon := find_index_from_left(line, #char ":");
|
||||||
|
if colon < 0 continue;
|
||||||
|
key := trim(slice(line, 0, colon));
|
||||||
|
value := trim(advance(line, colon + 1));
|
||||||
|
if key == "title" || value.count < 2 || value[0] != #char "\"" continue;
|
||||||
|
file := slice(value, 1, find_index_from_left(advance(value, 1), #char "\""));
|
||||||
|
if !file_exists(tprint("%/%", folder, file)) fail(tprint("% names %, which is not there; fetch or make the models (see the module's README).", folder, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This process's PATH.
|
||||||
|
path_variable :: () -> string {
|
||||||
|
kernel32 :: #system_library "kernel32";
|
||||||
|
GetEnvironmentVariableW :: (name: *u16, buffer: *u16, size: u32) -> u32 #foreign kernel32;
|
||||||
|
buffer: [32768] u16; // the longest an environment variable can be
|
||||||
|
count := GetEnvironmentVariableW(utf8_to_wide("PATH",, temp), buffer.data, buffer.count);
|
||||||
|
if !count || count >= buffer.count return "";
|
||||||
|
text, ok := wide_to_utf8(buffer.data, cast(s32) count);
|
||||||
|
return ifx ok then text else "";
|
||||||
|
}
|
||||||
|
|
||||||
|
read_version :: () -> string {
|
||||||
|
text, ok := read_entire_file("first.jai");
|
||||||
|
if !ok return "";
|
||||||
|
for raw: split(text, "\n") {
|
||||||
|
line := trim(raw);
|
||||||
|
if !begins_with(line, "VERSION ::") continue;
|
||||||
|
quote := find_index_from_left(line, #char "\"");
|
||||||
|
if quote < 0 return "";
|
||||||
|
rest := advance(line, quote + 1);
|
||||||
|
return slice(rest, 0, find_index_from_left(rest, #char "\""));
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a command; its output, and whether it ran and succeeded.
|
||||||
|
run :: (args: .. string, print := false) -> output: string, ok: bool {
|
||||||
|
result, output, error := run_command(..args, capture_and_return_output = !print, print_captured_output = print);
|
||||||
|
return output, result.type == .EXITED && result.exit_code == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
copy :: (source: string, folder: string) {
|
||||||
|
dest := tprint("%/%", folder, path_filename(source));
|
||||||
|
if !copy_file(source, dest) fail(tprint("Cannot copy % to %.", source, dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_folder :: (source: string, dest: string) {
|
||||||
|
if !file_exists(source) fail(tprint("% is not there: build the app first.", source));
|
||||||
|
if !copy_directory(source, dest) fail(tprint("Cannot copy % to %.", source, dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
file_size :: (path: string) -> s64 {
|
||||||
|
_, size, ok := file_modtime_and_size(path);
|
||||||
|
return ifx ok then size else 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
folder_size :: (folder: string) -> s64 {
|
||||||
|
total: s64;
|
||||||
|
for file_list(folder, recursive = true) total += file_size(it);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail :: (message: string) {
|
||||||
|
log_error("Release failed: %", message);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
RELEASE_README :: #string END
|
||||||
|
mcap {VERSION}, for Windows 10 and 11 (x64)
|
||||||
|
|
||||||
|
A library of screenshots, photos and videos, each kept as one .mcap file: imported from any folder
|
||||||
|
(and Snagit's .snagx), read with OCR, labelled, segmented, and searched.
|
||||||
|
|
||||||
|
Running it
|
||||||
|
|
||||||
|
Unzip this folder anywhere and run mcap.exe. Everything it needs is in this folder.
|
||||||
|
|
||||||
|
The library: mcap shows the .mcap files in D:\McapIndex\mcap. To use another folder, start it with
|
||||||
|
-library, for example from a shortcut whose target is:
|
||||||
|
|
||||||
|
"C:\Programs\mcap\mcap.exe" -library "E:\My library"
|
||||||
|
|
||||||
|
Import (above the grid) brings in a folder of screenshots, photos, videos or Snagit captures; OCR,
|
||||||
|
Labels and Segments read them. See README.md in the source for searching and the command line.
|
||||||
|
|
||||||
|
The GPU
|
||||||
|
|
||||||
|
{GPU}
|
||||||
|
|
||||||
|
Without a usable NVIDIA GPU, OCR, labels and segmentation run on the CPU: slower, but the same.
|
||||||
|
The OCR, Labels and Segments windows say whether the GPU is in use, and if not, why.
|
||||||
|
|
||||||
|
Where it keeps things
|
||||||
|
|
||||||
|
The library's .mcap files hold everything read from them. The app's settings, label list and
|
||||||
|
segment colours are in %APPDATA%\mcap.
|
||||||
|
|
||||||
|
See THIRD-PARTY-NOTICES.txt for the libraries and models included.
|
||||||
|
END
|
||||||
|
|
||||||
|
CUDA_INCLUDED :: #string END
|
||||||
|
NVIDIA's CUDA 13 runtime and cuDNN 9 are included, so OCR, labels and segmentation run on an
|
||||||
|
NVIDIA GPU (Turing or newer: GeForce RTX 20 series and up) with only the NVIDIA driver installed:
|
||||||
|
a recent Game Ready or Studio driver that supports CUDA 13.
|
||||||
|
END
|
||||||
|
|
||||||
|
CUDA_NOT_INCLUDED :: #string END
|
||||||
|
This release does not include NVIDIA's CUDA runtime. To use an NVIDIA GPU, install the CUDA 13
|
||||||
|
toolkit and cuDNN 9 for CUDA 13 from NVIDIA; mcap finds them where their installers put them.
|
||||||
|
END
|
||||||
|
|
||||||
|
NOTICES :: #string END
|
||||||
|
Third-party software and models in this release
|
||||||
|
|
||||||
|
onnxruntime 1.30.0 (onnxruntime.dll, onnxruntime_providers_*.dll)
|
||||||
|
Microsoft, MIT License. https://github.com/microsoft/onnxruntime
|
||||||
|
|
||||||
|
FFmpeg 6.0 (avcodec, avformat, avutil, swscale, swresample DLLs)
|
||||||
|
The FFmpeg developers. This is gyan.dev's "full" shared build, licensed GPL v3 as a whole: see
|
||||||
|
FFMPEG-BUILD.txt. Source: https://github.com/FFmpeg/FFmpeg (commit ea3d24bbe3).
|
||||||
|
|
||||||
|
PP-OCRv5 text detection and recognition, and PP-LCNet text line orientation (ocr-models)
|
||||||
|
PaddlePaddle (Baidu), Apache License 2.0. https://github.com/PaddlePaddle/PaddleOCR
|
||||||
|
|
||||||
|
SigLIP 2 base, patch 16, 384 (label-models)
|
||||||
|
Google, Apache License 2.0, as converted to ONNX by onnx-community.
|
||||||
|
https://huggingface.co/google/siglip2-base-patch16-384
|
||||||
|
|
||||||
|
Mask2Former, Swin-B, COCO panoptic (segment-models)
|
||||||
|
Meta (Facebook AI Research), from facebook/mask2former-swin-base-coco-panoptic, exported to ONNX by
|
||||||
|
mcap's tools/export_mask2former.py. https://github.com/facebookresearch/Mask2Former
|
||||||
|
Its class names are COCO panoptic's (https://cocodataset.org).
|
||||||
|
{CUDA}
|
||||||
|
Dear ImGui (in mcap.exe), Omar Cornut, MIT License. stb_image, stb_image_write and
|
||||||
|
stb_image_resize (in mcap.exe), Sean Barrett, public domain or MIT.
|
||||||
|
END
|
||||||
|
|
||||||
|
CUDA_NOTICE :: #string END
|
||||||
|
|
||||||
|
NVIDIA CUDA 13 runtime (cudart, cublas, cublasLt, cufft, nvrtc DLLs) and cuDNN 9 (cudnn DLLs)
|
||||||
|
NVIDIA Corporation, redistributed under the CUDA Toolkit EULA (Attachment A) and the cuDNN
|
||||||
|
Software License Agreement. https://docs.nvidia.com/cuda/eula/
|
||||||
|
https://docs.nvidia.com/deeplearning/cudnn/latest/reference/eula.html
|
||||||
|
END
|
||||||
|
|
||||||
|
#import "Basic";
|
||||||
|
#import "String";
|
||||||
|
#import "File";
|
||||||
|
#import "File_Utilities";
|
||||||
|
#import "Process";
|
||||||
|
#import "System";
|
||||||
|
#import "Compiler";
|
||||||
|
#import "Windows_Utf8";
|
||||||
43
src/App.jai
43
src/App.jai
@ -24,9 +24,18 @@ USING_WINDOWS :: OS == .WINDOWS;
|
|||||||
#load "app/background.jai";
|
#load "app/background.jai";
|
||||||
#load "app/labels_ui.jai";
|
#load "app/labels_ui.jai";
|
||||||
#load "app/import.jai";
|
#load "app/import.jai";
|
||||||
|
#load "app/open_file.jai";
|
||||||
#load "app/segment_ui.jai";
|
#load "app/segment_ui.jai";
|
||||||
|
#load "app/gpu_bench_ui.jai"; // the gpu-bench branch: the GPU Benchmark window
|
||||||
|
#load "../tools/gpu_bench/results.jai"; // its results files
|
||||||
#load "app/library_folders.jai";
|
#load "app/library_folders.jai";
|
||||||
#load "app/exit_shutdown.jai";
|
#load "app/exit_shutdown.jai";
|
||||||
|
#load "app/screen_capture.jai";
|
||||||
|
#load "app/screen_record.jai";
|
||||||
|
#if OS == .WINDOWS #load "app/screen_capture_win32.jai";
|
||||||
|
#if OS == .MACOS #load "app/screen_capture_macos.jai";
|
||||||
|
#if OS == .WINDOWS #load "app/screen_record_win32.jai";
|
||||||
|
#if OS == .MACOS #load "app/screen_record_macos.jai";
|
||||||
|
|
||||||
#load "base/circle_buffer.jai";
|
#load "base/circle_buffer.jai";
|
||||||
#load "base/debug.jai";
|
#load "base/debug.jai";
|
||||||
@ -59,6 +68,7 @@ USING_WINDOWS :: OS == .WINDOWS;
|
|||||||
#import "Thread";
|
#import "Thread";
|
||||||
#import "Hash_Table";
|
#import "Hash_Table";
|
||||||
#import "Hash"; // fnv1a_hash, for the label list's hash
|
#import "Hash"; // fnv1a_hash, for the label list's hash
|
||||||
|
Default_Allocator :: #import "Default_Allocator"; // rpmalloc (modules/Default_Allocator), for thread_idle at the workers' waits
|
||||||
Shared_Channel :: #import "Shared_Memory_Channel";
|
Shared_Channel :: #import "Shared_Memory_Channel";
|
||||||
#import "stb_image";
|
#import "stb_image";
|
||||||
#import "stb_image_resize";
|
#import "stb_image_resize";
|
||||||
@ -72,6 +82,7 @@ Shared_Channel :: #import "Shared_Memory_Channel";
|
|||||||
#import "Capture_Text";
|
#import "Capture_Text";
|
||||||
#import "Base64";
|
#import "Base64";
|
||||||
#import "onnxruntime";
|
#import "onnxruntime";
|
||||||
|
#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);
|
||||||
@ -180,21 +191,34 @@ App :: struct {
|
|||||||
show_logger_panel: bool = DEBUG;
|
show_logger_panel: bool = DEBUG;
|
||||||
show_frame_time_graph: bool = DEBUG;
|
show_frame_time_graph: bool = DEBUG;
|
||||||
show_style_editor: bool;
|
show_style_editor: bool;
|
||||||
|
show_help_panel: bool; // the Command Line window (base/debug.jai): -h, --help, or the Debug Panel
|
||||||
|
help_panel_focus: bool; // bring it forward next frame
|
||||||
minimize_to_tray_this_frame: bool;
|
minimize_to_tray_this_frame: bool;
|
||||||
restore_window_this_frame: bool;
|
restore_window_this_frame: bool;
|
||||||
|
capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai
|
||||||
|
record_region_this_frame: bool; // the record_region global hotkey fired: app/screen_record.jai
|
||||||
is_master: bool; // Are we the first instance of this app launched?
|
is_master: bool; // Are we the first instance of this app launched?
|
||||||
allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one.
|
allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one.
|
||||||
should_exit_early: bool;
|
should_exit_early: bool;
|
||||||
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)
|
||||||
|
cold_start: bool;
|
||||||
|
no_catalog_cache: bool; // -no-catalog-cache: read every capture from its file, as without the catalog cache (for measuring)
|
||||||
|
launch_time: Apollo_Time; // monotonic, when main() started: the [startup] log lines count from it // -cold: load the library as on a cold start, its files dropped from the system's file cache first
|
||||||
start_search: string; // -search QUERY: the search box's text at startup
|
start_search: string; // -search QUERY: the search box's text at startup
|
||||||
erase_and_exit: string; // -erase ocr|labels|segments|all: erase that from the whole library once it has loaded, then exit
|
erase_and_exit: string; // -erase ocr|labels|segments|all: erase that from the whole library once it has loaded, then exit
|
||||||
|
record_and_exit: float64; // -record SECONDS: record the screen under the cursor that long, put it in the library, then exit (0, or 1 if it failed)
|
||||||
|
#if OS == .WINDOWS record_region: RECT; // -record-region X Y W H: -record records that, on the virtual desktop, not the whole screen
|
||||||
segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed)
|
segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed)
|
||||||
import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit
|
import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit
|
||||||
|
open_file: string; // mcap.exe FILE: the file to show (app/open_file.jai); absolute, with / between its folders
|
||||||
|
view_mode: bool; // FILE was given while another instance runs: only it is shown, with Import (app/open_file.jai)
|
||||||
|
open_import_dest: string; // -open-import DEST: FILE imported into the library folder DEST as its Import button does, then exit (0, or 1 if it failed)
|
||||||
last_window_restore_time: Apollo_Time;
|
last_window_restore_time: Apollo_Time;
|
||||||
|
|
||||||
imgui_context: *ImGui.ImGuiContext;
|
imgui_context: *ImGui.ImGuiContext;
|
||||||
@ -260,6 +284,7 @@ App :: struct {
|
|||||||
|
|
||||||
#load "base/platform/platform_windows.jai";
|
#load "base/platform/platform_windows.jai";
|
||||||
#load "base/platform/win32_bindings.jai";
|
#load "base/platform/win32_bindings.jai";
|
||||||
|
#load "base/platform/win32_gdi_bindings.jai";
|
||||||
#load "graphics/DX11.jai";
|
#load "graphics/DX11.jai";
|
||||||
|
|
||||||
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
|
APP_TRAY_ICON_PATH :: "images/tray_icon.ico";
|
||||||
@ -288,6 +313,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;
|
||||||
@ -314,6 +347,8 @@ APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE :: 2; // 1 byte for message-or-done, 1 b
|
|||||||
App_Shared_Memory_Tag :: enum u8 {
|
App_Shared_Memory_Tag :: enum u8 {
|
||||||
Done :: 0;
|
Done :: 0;
|
||||||
Bring_To_Front :: 1;
|
Bring_To_Front :: 1;
|
||||||
|
Open_File :: 2; // the path follows the header: show it (open_file_request)
|
||||||
|
Show_Help :: 3; // mcap --help while this one runs: come forward with the Command Line window
|
||||||
}
|
}
|
||||||
|
|
||||||
APP_SHARED_MEMORY_CHANNEL_BYTES :: 4096; // only one page?
|
APP_SHARED_MEMORY_CHANNEL_BYTES :: 4096; // only one page?
|
||||||
@ -326,6 +361,12 @@ APP_WINDOW_TITLE :: #run -> string {
|
|||||||
git_info, append_debug);
|
git_info, append_debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The window's title, ending in a 0 for the OS: in view mode, the file's name first.
|
||||||
|
app_window_title :: () -> string {
|
||||||
|
if !app.view_mode return APP_WINDOW_TITLE;
|
||||||
|
return tprint("% - % (view)\0", path_filename(app.open_file), APP_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
APP_TRAY_TOOLTIP_TEXT :: #run -> string {
|
APP_TRAY_TOOLTIP_TEXT :: #run -> string {
|
||||||
return ifx DEBUG
|
return ifx DEBUG
|
||||||
tprint("% % (DEBUG)\0", APP_NAME, VERSION)
|
tprint("% % (DEBUG)\0", APP_NAME, VERSION)
|
||||||
|
|||||||
@ -6,21 +6,42 @@ APP_MAIN_WINDOW_LABEL :: "Main";
|
|||||||
|
|
||||||
// Once, after ImGui, the fonts, the theme and the settings are up and before the first frame.
|
// Once, after ImGui, the fonts, the theme and the settings are up and before the first frame.
|
||||||
app_ui_startup :: () {
|
app_ui_startup :: () {
|
||||||
|
// View mode (app/open_file.jai): the file alone. The library is not listed, and no model, scan,
|
||||||
|
// watcher or recording starts.
|
||||||
|
if app.view_mode {
|
||||||
|
import_startup();
|
||||||
|
open_file_startup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
viewer_startup();
|
viewer_startup();
|
||||||
import_startup();
|
import_startup();
|
||||||
ocr_ui_startup();
|
ocr_ui_startup();
|
||||||
labels_ui_startup();
|
labels_ui_startup();
|
||||||
segment_ui_startup(); // the library folders' options: after the viewer, which knows the library
|
segment_ui_startup(); // the library folders' options: after the viewer, which knows the library
|
||||||
|
screen_record_startup(); // recordings a crash left behind, to offer
|
||||||
|
open_file_startup(); // mcap.exe FILE: after the viewer, which knows whether it is in the library
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every frame, before input is handed to ImGui. Poll background work here.
|
// Every frame, before input is handed to ImGui. Poll background work here.
|
||||||
app_ui_frame_start :: () {
|
app_ui_frame_start :: () {
|
||||||
|
if app.view_mode return;
|
||||||
|
screen_capture_frame(); // also while hidden in the tray, where the frame ends before the UI
|
||||||
|
ocr_ui_unload_idle_models(); // models unused for a while, also while in the tray
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once, on the shutdown thread after the window is asked to close (exit_shutdown.jai): save and
|
// Once, on the shutdown thread after the window is asked to close (exit_shutdown.jai): save and
|
||||||
// stop background work here. Must not touch ImGui, since the main thread is still drawing frames
|
// stop background work here. Must not touch ImGui, since the main thread is still drawing frames
|
||||||
// (the overlay; app_main_window_ui stops running the app's frames once shutdown starts).
|
// (the overlay; app_main_window_ui stops running the app's frames once shutdown starts).
|
||||||
app_ui_shutdown :: () {
|
app_ui_shutdown :: () {
|
||||||
|
if app.view_mode {
|
||||||
|
app_shutdown_step(.IMPORT);
|
||||||
|
import_shutdown();
|
||||||
|
open_file_shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gpu_bench_ui_shutdown(); // ends a benchmark run still going
|
||||||
|
app_shutdown_step(.CAPTURE);
|
||||||
|
screen_capture_shutdown();
|
||||||
app_shutdown_step(.IMPORT);
|
app_shutdown_step(.IMPORT);
|
||||||
import_shutdown(); // before the viewer: it hands the viewer what it writes
|
import_shutdown(); // before the viewer: it hands the viewer what it writes
|
||||||
app_shutdown_step(.VIEWER);
|
app_shutdown_step(.VIEWER);
|
||||||
@ -33,10 +54,11 @@ app_ui_shutdown :: () {
|
|||||||
labels_ui_shutdown(); // after the OCR thread and the background workers have stopped
|
labels_ui_shutdown(); // after the OCR thread and the background workers have stopped
|
||||||
app_shutdown_step(.STATS);
|
app_shutdown_step(.STATS);
|
||||||
stats_ui_shutdown();
|
stats_ui_shutdown();
|
||||||
|
open_file_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which step of app_ui_shutdown is running, and since when, for the overlay a slow shutdown shows.
|
// Which step of app_ui_shutdown is running, and since when, for the overlay a slow shutdown shows.
|
||||||
App_Shutdown_Step :: enum s32 { NONE; IMPORT; VIEWER; OCR; SEGMENT; LABELS; STATS; }
|
App_Shutdown_Step :: enum s32 { NONE; CAPTURE; IMPORT; VIEWER; OCR; SEGMENT; LABELS; STATS; }
|
||||||
app_shutdown: struct {
|
app_shutdown: struct {
|
||||||
step: App_Shutdown_Step;
|
step: App_Shutdown_Step;
|
||||||
step_started_ns: s64; // monotonic
|
step_started_ns: s64; // monotonic
|
||||||
@ -54,6 +76,7 @@ app_ui_shutdown_status :: () -> string {
|
|||||||
text: string;
|
text: string;
|
||||||
if step == {
|
if step == {
|
||||||
case .NONE; return "";
|
case .NONE; return "";
|
||||||
|
case .CAPTURE; text = "Saving a screen capture.";
|
||||||
case .IMPORT;
|
case .IMPORT;
|
||||||
if importer.phase == .INDEXING text = "Stopping the import: it is indexing the library, and stops after the files it is on.";
|
if importer.phase == .INDEXING text = "Stopping the import: it is indexing the library, and stops after the files it is on.";
|
||||||
else if importer.running text = "Stopping the import: finishing the files it is on (a large video takes a while to copy).";
|
else if importer.running text = "Stopping the import: finishing the files it is on (a large video takes a while to copy).";
|
||||||
@ -72,6 +95,7 @@ app_ui_shutdown_status :: () -> string {
|
|||||||
case .LABELS_CREATE; text = "Waiting for the label model to finish loading.";
|
case .LABELS_CREATE; text = "Waiting for the label model to finish loading.";
|
||||||
case .LABEL_SET; text = "Waiting for the label list to finish.";
|
case .LABEL_SET; text = "Waiting for the label list to finish.";
|
||||||
case .SEGMENT; text = "Stopping the segmentation scan: finishing the images it is segmenting.";
|
case .SEGMENT; text = "Stopping the segmentation scan: finishing the images it is segmenting.";
|
||||||
|
case .UNLOAD; text = "Waiting for the models unused for a while to unload.";
|
||||||
case; text = "Stopping OCR.";
|
case; text = "Stopping OCR.";
|
||||||
}
|
}
|
||||||
case .SEGMENT; text = "Closing the segmentation model.";
|
case .SEGMENT; text = "Closing the segmentation model.";
|
||||||
@ -84,6 +108,10 @@ app_ui_shutdown_status :: () -> string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app_main_window_ui :: () {
|
app_main_window_ui :: () {
|
||||||
|
if app.view_mode {
|
||||||
|
if !app_exit_shutdown_started() open_file_view_mode_ui(); // the whole window (app/open_file.jai)
|
||||||
|
return;
|
||||||
|
}
|
||||||
push_imgui_window(APP_MAIN_WINDOW_LABEL);
|
push_imgui_window(APP_MAIN_WINDOW_LABEL);
|
||||||
// Once shutdown has started, its thread is stopping the threads these frames hand work to and take
|
// Once shutdown has started, its thread is stopping the threads these frames hand work to and take
|
||||||
// it from (the viewer's workers, the search group, the import, OCR), and freeing what they share:
|
// it from (the viewer's workers, the search group, the import, OCR), and freeing what they share:
|
||||||
@ -94,5 +122,8 @@ app_main_window_ui :: () {
|
|||||||
labels_ui_frame();
|
labels_ui_frame();
|
||||||
segment_ui_frame();
|
segment_ui_frame();
|
||||||
stats_ui_frame();
|
stats_ui_frame();
|
||||||
|
gpu_bench_ui_frame(); // app/gpu_bench_ui.jai
|
||||||
import_frame();
|
import_frame();
|
||||||
|
open_file_frame(); // after import_frame, which hands the grid what an import of it wrote
|
||||||
|
screen_record_leftovers_popup();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,24 @@
|
|||||||
|
|
||||||
BACKGROUND_SEARCH_CHUNK :: 256; // captures matched per job
|
BACKGROUND_SEARCH_CHUNK :: 256; // captures matched per job
|
||||||
|
|
||||||
|
// Captures whose catalog entries are wanted, shared by the jobs loading them. Heap; freed by the UI
|
||||||
|
// thread when the last job is back.
|
||||||
|
Catalog_Batch :: struct {
|
||||||
|
items: [] Catalog_Item;
|
||||||
|
cache: string; // the catalog cache's bytes, for items with an offset; heap, freed with the batch
|
||||||
|
next: s64; // the next item a job takes (atomic)
|
||||||
|
jobs_left: s64; // UI thread
|
||||||
|
}
|
||||||
|
|
||||||
|
// A capture whose catalog entry is wanted. path is the capture's, never freed.
|
||||||
|
Catalog_Item :: struct {
|
||||||
|
index: s32;
|
||||||
|
path: string;
|
||||||
|
size: s64 = -1; // its file's, as listed; -1 when not known (catalog_load looks)
|
||||||
|
modified: s64; // nanoseconds
|
||||||
|
offset: s64 = -1; // where its entry starts in the batch's cache; -1: read it from the file
|
||||||
|
}
|
||||||
|
|
||||||
Search_Item :: struct {
|
Search_Item :: struct {
|
||||||
index: s32;
|
index: s32;
|
||||||
entry: *Catalog_Entry; // null while not loaded
|
entry: *Catalog_Entry; // null while not loaded
|
||||||
@ -30,11 +48,14 @@ background: struct {
|
|||||||
stats_generation: s64; // the newest Statistics job
|
stats_generation: s64; // the newest Statistics job
|
||||||
stats_out: bool;
|
stats_out: bool;
|
||||||
retired: [..] *Catalog_Entry; // replaced while jobs were out
|
retired: [..] *Catalog_Entry; // replaced while jobs were out
|
||||||
|
catalog_jobs_out: s64; // UI thread: catalog loads handed out; they read no entries, so jobs_out leaves them out
|
||||||
|
threads: s64;
|
||||||
};
|
};
|
||||||
|
|
||||||
background_startup :: () {
|
background_startup :: () {
|
||||||
background.retired.allocator = context.default_allocator;
|
background.retired.allocator = context.default_allocator;
|
||||||
threads := clamp(get_number_of_processors(.ALL_LOGICAL) - 2, 2, 16);
|
threads := clamp(get_number_of_processors(.ALL_LOGICAL) - 2, 2, 16);
|
||||||
|
background.threads = threads;
|
||||||
// Statistics count words in a table in temporary storage: a few MB on the full library.
|
// Statistics count words in a table in temporary storage: a few MB on the full library.
|
||||||
init(*background.group, cast(s32) threads, background_proc, temporary_storage_size = 4 * 1024 * 1024, group_name = "background");
|
init(*background.group, cast(s32) threads, background_proc, temporary_storage_size = 4 * 1024 * 1024, group_name = "background");
|
||||||
background.group.logging = false;
|
background.group.logging = false;
|
||||||
@ -71,8 +92,18 @@ background_frame :: () {
|
|||||||
if !background.started return;
|
if !background.started return;
|
||||||
for work: get_completed_work(*background.group) {
|
for work: get_completed_work(*background.group) {
|
||||||
job := cast(*Background_Job) work;
|
job := cast(*Background_Job) work;
|
||||||
background.jobs_out -= 1;
|
if job.kind == .CATALOG_LOAD || job.kind == .CATALOG_CACHE background.catalog_jobs_out -= 1;
|
||||||
|
else background.jobs_out -= 1;
|
||||||
if job.kind == {
|
if job.kind == {
|
||||||
|
case .CATALOG_CACHE; catalog_cache_indexed(job.cache_index);
|
||||||
|
case .CATALOG_SAVE; catalog_cache_save_done(job.cache_save);
|
||||||
|
case .CATALOG_LOAD;
|
||||||
|
job.catalog.jobs_left -= 1;
|
||||||
|
if !job.catalog.jobs_left {
|
||||||
|
array_free(job.catalog.items,, context.default_allocator);
|
||||||
|
free(job.catalog.cache,, context.default_allocator);
|
||||||
|
free(job.catalog,, context.default_allocator);
|
||||||
|
}
|
||||||
case .SEARCH_EMBED;
|
case .SEARCH_EMBED;
|
||||||
request := job.request;
|
request := job.request;
|
||||||
if request.generation == background.search_generation search_dispatch_chunks(request);
|
if request.generation == background.search_generation search_dispatch_chunks(request);
|
||||||
@ -149,6 +180,7 @@ background_search_start :: () {
|
|||||||
likes: [..] string;
|
likes: [..] string;
|
||||||
likes.allocator = temp;
|
likes.allocator = temp;
|
||||||
for request.terms if it.field == .LIKE array_add(*likes, it.text);
|
for request.terms if it.field == .LIKE array_add(*likes, it.text);
|
||||||
|
if likes.count labels_ui_want(); // when it is not up, the search is worked out again once it is
|
||||||
if likes.count && labels_ui.engine {
|
if likes.count && labels_ui.engine {
|
||||||
request.like_texts = NewArray(likes.count, string);
|
request.like_texts = NewArray(likes.count, string);
|
||||||
for likes request.like_texts[it_index] = copy_string(it);
|
for likes request.like_texts[it_index] = copy_string(it);
|
||||||
@ -184,9 +216,47 @@ background_stats_start :: (items: [] Search_Item, model: string) {
|
|||||||
background_add(job);
|
background_add(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The catalog entries of these captures, loaded by background_catalog_threads() jobs at once
|
||||||
|
// (app/catalog.jai, catalog_load_job). The entries come back through viewer.catalog_updates.
|
||||||
|
background_catalog_load :: (items: [] Catalog_Item, cache := "") {
|
||||||
|
if !items.count return;
|
||||||
|
batch := New(Catalog_Batch,, context.default_allocator);
|
||||||
|
batch.items = array_copy(items,, context.default_allocator);
|
||||||
|
batch.cache = cache;
|
||||||
|
jobs := min(background_catalog_threads(), items.count);
|
||||||
|
batch.jobs_left = jobs;
|
||||||
|
for 1..jobs {
|
||||||
|
job := New(Background_Job,, context.default_allocator);
|
||||||
|
job.* = .{kind = .CATALOG_LOAD, catalog = batch};
|
||||||
|
background.catalog_jobs_out += 1;
|
||||||
|
add_work(*background.group, job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup job reading the catalog cache (app/catalog.jai); catalog_cache_indexed takes it back.
|
||||||
|
background_catalog_cache :: (index: *Catalog_Cache_Index) {
|
||||||
|
job := New(Background_Job,, context.default_allocator);
|
||||||
|
job.* = .{kind = .CATALOG_CACHE, cache_index = index};
|
||||||
|
background.catalog_jobs_out += 1;
|
||||||
|
add_work(*background.group, job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A save of the catalog cache. It reads entries, so it counts in jobs_out.
|
||||||
|
background_catalog_save :: (save: *Catalog_Cache_Save) {
|
||||||
|
job := New(Background_Job,, context.default_allocator);
|
||||||
|
job.* = .{kind = .CATALOG_SAVE, cache_save = save};
|
||||||
|
background_add(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every worker of the group by default: with rpmalloc's per-thread heaps, catalog loads scale with
|
||||||
|
// threads (reading 10k files cold: 4 threads 850 ms, 8 450 ms, 16 250 ms). -catalog-threads N, for measuring.
|
||||||
|
background_catalog_threads :: () -> s64 {
|
||||||
|
return clamp(ifx app.catalog_threads > 0 then app.catalog_threads else background.threads, 1, background.threads);
|
||||||
|
}
|
||||||
|
|
||||||
#scope_file
|
#scope_file
|
||||||
|
|
||||||
Background_Kind :: enum u8 { SEARCH_EMBED; SEARCH_CHUNK; SEARCH_MERGE; STATS; }
|
Background_Kind :: enum u8 { SEARCH_EMBED; SEARCH_CHUNK; SEARCH_MERGE; STATS; CATALOG_LOAD; CATALOG_CACHE; CATALOG_SAVE; }
|
||||||
|
|
||||||
Background_Job :: struct {
|
Background_Job :: struct {
|
||||||
kind: Background_Kind;
|
kind: Background_Kind;
|
||||||
@ -199,6 +269,11 @@ Background_Job :: struct {
|
|||||||
matches: [..] Search_Item;
|
matches: [..] Search_Item;
|
||||||
visible: [..] s32;
|
visible: [..] s32;
|
||||||
|
|
||||||
|
// CATALOG_LOAD, CATALOG_CACHE, CATALOG_SAVE
|
||||||
|
catalog: *Catalog_Batch;
|
||||||
|
cache_index: *Catalog_Cache_Index;
|
||||||
|
cache_save: *Catalog_Cache_Save;
|
||||||
|
|
||||||
// STATS
|
// STATS
|
||||||
items: [] Search_Item;
|
items: [] Search_Item;
|
||||||
model: string;
|
model: string;
|
||||||
@ -248,7 +323,14 @@ background_proc :: (group: *Thread_Group, thread: *Thread, work: *void) -> Threa
|
|||||||
case .SEARCH_CHUNK; search_chunk(job);
|
case .SEARCH_CHUNK; search_chunk(job);
|
||||||
case .SEARCH_MERGE; search_merge(job);
|
case .SEARCH_MERGE; search_merge(job);
|
||||||
case .STATS; job.stats = stats_compute(job.items, job.model);
|
case .STATS; job.stats = stats_compute(job.items, job.model);
|
||||||
|
case .CATALOG_LOAD; catalog_load_job(job.catalog);
|
||||||
|
case .CATALOG_CACHE; catalog_cache_index_job(job.cache_index);
|
||||||
|
case .CATALOG_SAVE; catalog_cache_save_job(job.cache_save);
|
||||||
}
|
}
|
||||||
|
// The catalog's jobs are long and come in bursts (startup, a save), after which the workers idle:
|
||||||
|
// what this thread's heap holds free goes back now. Not after each search chunk, which come many
|
||||||
|
// a second.
|
||||||
|
if job.kind == .CATALOG_LOAD || job.kind == .CATALOG_CACHE || job.kind == .CATALOG_SAVE Default_Allocator.thread_idle();
|
||||||
reset_temporary_storage();
|
reset_temporary_storage();
|
||||||
return .CONTINUE;
|
return .CONTINUE;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
// The catalog: what search and sorting need from every capture, loaded once from the front of each
|
// The catalog: what search and sorting need from every capture, loaded once from the front of each
|
||||||
// file (its Capture Text and OCR text, not its images) on the viewer's worker threads.
|
// file (its Capture Text and OCR text, not its images) on the background thread group.
|
||||||
|
//
|
||||||
|
// The catalog cache (CATALOG_CACHE_NAME, in the library folder) keeps every entry with the size and
|
||||||
|
// modified time of the file it was read from, so a start reads one file instead of every capture:
|
||||||
|
// an entry whose file is unchanged comes from the cache, the rest from their files. It is written
|
||||||
|
// once the catalog is complete and has changed, at most every CATALOG_CACHE_SAVE_SECONDS, and at
|
||||||
|
// shutdown.
|
||||||
//
|
//
|
||||||
// Entries are made on the workers and handed over through viewer.catalog_done; only the main thread
|
// Entries are made on the workers and handed over through viewer.catalog_done; only the main thread
|
||||||
// sets or frees viewer.captures[i].catalog, and only it reads them, so no entry is freed while in use.
|
// sets or frees viewer.captures[i].catalog, and only it reads them, so no entry is freed while in use.
|
||||||
@ -25,6 +31,11 @@ Catalog_Entry :: struct {
|
|||||||
segment_count: s32;
|
segment_count: s32;
|
||||||
segments: [..] Catalog_Segment; // largest first
|
segments: [..] Catalog_Segment; // largest first
|
||||||
|
|
||||||
|
// The file it was read from, when it was listed or just before it was read: the catalog cache
|
||||||
|
// gives this entry again only while the file's size and modified time are still these.
|
||||||
|
file_size: s64;
|
||||||
|
file_modified: s64; // nanoseconds
|
||||||
|
|
||||||
// Lowercased copies, for matching without case.
|
// Lowercased copies, for matching without case.
|
||||||
find_title, find_url, find_app, find_notes, find_ocr, find_labels, find_folder: string;
|
find_title, find_url, find_app, find_notes, find_ocr, find_labels, find_folder: string;
|
||||||
find_segments: string; // each class in it once, between newlines: "|person|dog|" with | a newline, for has:
|
find_segments: string; // each class in it once, between newlines: "|person|dog|" with | a newline, for has:
|
||||||
@ -67,8 +78,14 @@ catalog_entry_free :: (entry: *Catalog_Entry) {
|
|||||||
free(entry);
|
free(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The entry for the .mcap at path, or null with why. On any thread.
|
// The entry for the .mcap at path, or null with why. On any thread. size and modified are the file's
|
||||||
catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
// as listed, when known; otherwise it is looked at first.
|
||||||
|
catalog_load :: (path: string, size: s64 = -1, modified: s64 = 0) -> *Catalog_Entry, error: string {
|
||||||
|
if size < 0 {
|
||||||
|
time, stat_size, stat_ok := file_modtime_and_size(path);
|
||||||
|
if stat_ok { size = stat_size; modified = to_nanoseconds(time); }
|
||||||
|
else { size = 0; modified = 0; } // matches no listing: never given again by the cache
|
||||||
|
}
|
||||||
front: Mcap;
|
front: Mcap;
|
||||||
ok: bool;
|
ok: bool;
|
||||||
error: string;
|
error: string;
|
||||||
@ -80,6 +97,7 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
|||||||
|
|
||||||
push_allocator(context.default_allocator);
|
push_allocator(context.default_allocator);
|
||||||
entry := New(Catalog_Entry);
|
entry := New(Catalog_Entry);
|
||||||
|
entry.file_size, entry.file_modified = size, modified;
|
||||||
|
|
||||||
// The metadata and the annotations, from the Capture Text.
|
// The metadata and the annotations, from the Capture Text.
|
||||||
doc: Capture_Document;
|
doc: Capture_Document;
|
||||||
@ -92,7 +110,9 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
|||||||
entry.url = catalog_string(metadata.root, "WebURL");
|
entry.url = catalog_string(metadata.root, "WebURL");
|
||||||
entry.app = catalog_string(metadata.root, "AppName");
|
entry.app = catalog_string(metadata.root, "AppName");
|
||||||
}
|
}
|
||||||
|
// An import's import.json, or a screen recording's capture.json, says what kind of media it is.
|
||||||
imported := capture_find_file(*doc, "import.json");
|
imported := capture_find_file(*doc, "import.json");
|
||||||
|
if !imported imported = capture_find_file(*doc, "capture.json");
|
||||||
if imported {
|
if imported {
|
||||||
entry.video_codec = catalog_string(imported.root, "VideoCodec");
|
entry.video_codec = catalog_string(imported.root, "VideoCodec");
|
||||||
entry.duration = catalog_number(imported.root, "Duration");
|
entry.duration = catalog_number(imported.root, "Duration");
|
||||||
@ -156,6 +176,14 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catalog_finish(entry, path);
|
||||||
|
return entry, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an entry derives from the rest and from where its file is: its folder, and the lowercased
|
||||||
|
// copies search matches. For entries read from files and from the cache alike.
|
||||||
|
catalog_finish :: (entry: *Catalog_Entry, path: string) {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
library := viewer_folder();
|
library := viewer_folder();
|
||||||
if path.count > library.count + 1 && begins_with_nocase(path, library) {
|
if path.count > library.count + 1 && begins_with_nocase(path, library) {
|
||||||
folder := path_strip_filename(advance(path, library.count + 1));
|
folder := path_strip_filename(advance(path, library.count + 1));
|
||||||
@ -180,7 +208,41 @@ catalog_load :: (path: string) -> *Catalog_Entry, error: string {
|
|||||||
append(*all_labels, "\n");
|
append(*all_labels, "\n");
|
||||||
}
|
}
|
||||||
entry.find_labels = to_lower_copy(builder_to_string(*all_labels,, temp));
|
entry.find_labels = to_lower_copy(builder_to_string(*all_labels,, temp));
|
||||||
return entry, "";
|
}
|
||||||
|
|
||||||
|
// On a background worker (app/background.jai): the batch's entries, taken one at a time with the
|
||||||
|
// other jobs on it, each handed to the main thread as it is made. Stops early when the viewer is
|
||||||
|
// shutting down.
|
||||||
|
catalog_load_job :: (batch: *Catalog_Batch) {
|
||||||
|
while true {
|
||||||
|
n := atomic_add(*batch.next, 1);
|
||||||
|
if n >= batch.items.count return;
|
||||||
|
it := batch.items[n];
|
||||||
|
entry: *Catalog_Entry;
|
||||||
|
from_cache := false;
|
||||||
|
if it.offset >= 0 {
|
||||||
|
d := Deserializer.{count = batch.cache.count, data = batch.cache.data, cursor = it.offset};
|
||||||
|
entry = catalog_cache_read_entry(*d);
|
||||||
|
if entry {
|
||||||
|
entry.file_size, entry.file_modified = it.size, it.modified;
|
||||||
|
catalog_finish(entry, it.path);
|
||||||
|
from_cache = true;
|
||||||
|
} else {
|
||||||
|
log_error("[catalog] The cache's entry for % is damaged; reading the file.", it.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !entry {
|
||||||
|
error: string;
|
||||||
|
entry, error = catalog_load(it.path, it.size, it.modified);
|
||||||
|
if !entry log_error("[catalog] % %", it.path, error);
|
||||||
|
}
|
||||||
|
reset_temporary_storage();
|
||||||
|
lock(*viewer.mutex);
|
||||||
|
array_add(*viewer.catalog_updates, .{it.index, entry, from_cache});
|
||||||
|
quit := viewer.quit;
|
||||||
|
unlock(*viewer.mutex);
|
||||||
|
if quit return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
catalog_section :: (entry: *Catalog_Entry, model: string) -> *Mcap_Ocr_Section {
|
catalog_section :: (entry: *Catalog_Entry, model: string) -> *Mcap_Ocr_Section {
|
||||||
@ -189,6 +251,288 @@ catalog_section :: (entry: *Catalog_Entry, model: string) -> *Mcap_Ocr_Section {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the catalog cache
|
||||||
|
//
|
||||||
|
// "MCAPCATL", u32 CATALOG_CACHE_VERSION, LABELS_MODEL and SEGMENT_MODEL (u32-sized strings), u32 count,
|
||||||
|
// then count records: u32 length of what follows, the path relative to the library, the file's
|
||||||
|
// size and modified time (s64, s64), and the entry's own fields (catalog_cache_add_entry). Its
|
||||||
|
// folder and lowercased copies are not kept: catalog_finish makes them again.
|
||||||
|
|
||||||
|
CATALOG_CACHE_NAME :: ".catalog-cache"; // in the library folder; hidden, so the watcher and listing leave it out
|
||||||
|
CATALOG_CACHE_MAGIC :: "MCAPCATL";
|
||||||
|
CATALOG_CACHE_VERSION :: 1; // raise whenever Catalog_Entry, or what catalog_load takes from a file, changes
|
||||||
|
CATALOG_CACHE_SAVE_SECONDS :: 30.0; // the least time between saves while the app runs
|
||||||
|
|
||||||
|
catalog_cache: struct {
|
||||||
|
dirty: bool; // UI thread: entries changed since the cache was last written
|
||||||
|
saving: bool; // UI thread: a save job is out
|
||||||
|
saved_at: Apollo_Time; // monotonic; zero before the first save
|
||||||
|
};
|
||||||
|
|
||||||
|
catalog_cache_path :: () -> string {
|
||||||
|
return tprint("%/%", viewer_folder(), CATALOG_CACHE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup job's: the cache read, and the captures sorted into those it has a current entry for
|
||||||
|
// (items, each with where its entry starts in data) and the rest (misses). All heap.
|
||||||
|
Catalog_Cache_Index :: struct {
|
||||||
|
path: string;
|
||||||
|
captures: [] Catalog_Item; // every capture, with its file's size and modified time as listed
|
||||||
|
data: string; // the cache file; handed to the batch that reads items
|
||||||
|
items: [..] Catalog_Item;
|
||||||
|
misses: [..] Catalog_Item;
|
||||||
|
records, stale: s64; // records in the cache, and those for files changed or gone
|
||||||
|
read_ms, index_ms: float64;
|
||||||
|
problem: string; // why some or all of it was not used (a literal); "" when all was
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a background worker.
|
||||||
|
catalog_cache_index_job :: (job: *Catalog_Cache_Index) {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
job.items.allocator = context.default_allocator;
|
||||||
|
job.misses.allocator = context.default_allocator;
|
||||||
|
start := current_time_monotonic();
|
||||||
|
data, read_ok := read_entire_file(job.path, log_errors = false);
|
||||||
|
job.read_ms = to_float64_seconds(current_time_monotonic() - start) * 1000.0;
|
||||||
|
|
||||||
|
start = current_time_monotonic();
|
||||||
|
matched := NewArray(job.captures.count, bool,, temp);
|
||||||
|
if read_ok {
|
||||||
|
job.data = data;
|
||||||
|
job.problem = catalog_cache_index(job, matched);
|
||||||
|
} else {
|
||||||
|
job.problem = "there is none yet";
|
||||||
|
}
|
||||||
|
for job.captures if !matched[it_index] array_add(*job.misses, it);
|
||||||
|
job.index_ms = to_float64_seconds(current_time_monotonic() - start) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The records whose file is unchanged, into job.items. A damaged record ends the walk: those before
|
||||||
|
// it are good, as each says how long it is.
|
||||||
|
catalog_cache_index :: (job: *Catalog_Cache_Index, matched: [] bool) -> problem: string {
|
||||||
|
d := to_deserializer(job.data);
|
||||||
|
if ReadStringView(*d, CATALOG_CACHE_MAGIC.count) != CATALOG_CACHE_MAGIC return "it is not a catalog cache";
|
||||||
|
if Read(*d, u32) != CATALOG_CACHE_VERSION return "another version of mcap wrote it";
|
||||||
|
labels_model := ReadSizedString(*d, u32);
|
||||||
|
segment_model := ReadSizedString(*d, u32);
|
||||||
|
if labels_model != LABELS_MODEL || segment_model != SEGMENT_MODEL return "it was made with other label or segmentation models";
|
||||||
|
count := Read(*d, u32);
|
||||||
|
if !d.ok return "it is damaged";
|
||||||
|
|
||||||
|
folder := viewer_folder();
|
||||||
|
by_path: Table(string, s64);
|
||||||
|
by_path.allocator = temp;
|
||||||
|
for job.captures table_set(*by_path, advance(it.path, folder.count + 1), it_index);
|
||||||
|
for 1..count {
|
||||||
|
length := Read(*d, u32);
|
||||||
|
end := d.cursor + length;
|
||||||
|
relative := ReadSizedString(*d, u32);
|
||||||
|
size := Read(*d, s64);
|
||||||
|
modified := Read(*d, s64);
|
||||||
|
if !d.ok || end > d.count return "it ends early; the rest is read from the files";
|
||||||
|
job.records += 1;
|
||||||
|
found, position := table_find(*by_path, relative);
|
||||||
|
if found && !matched[position] && job.captures[position].size == size && job.captures[position].modified == modified {
|
||||||
|
matched[position] = true;
|
||||||
|
item := job.captures[position];
|
||||||
|
item.offset = d.cursor;
|
||||||
|
array_add(*job.items, item);
|
||||||
|
} else {
|
||||||
|
job.stale += 1;
|
||||||
|
}
|
||||||
|
d.cursor = end;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI thread: the startup job is back. The current entries are read from the cache and the rest from
|
||||||
|
// their files, both on the workers.
|
||||||
|
catalog_cache_indexed :: (job: *Catalog_Cache_Index) {
|
||||||
|
log("[startup] Catalog cache at % ms: % MB read in % ms, sorted in % ms; % of % captures from it, % to read from their files (% of its records stale or gone)%.",
|
||||||
|
FF(startup_ms(), 1), FF(job.data.count / (1024.0 * 1024.0), 1), FF(job.read_ms, 1), FF(job.index_ms, 1),
|
||||||
|
job.items.count, job.captures.count, job.misses.count, job.stale, ifx job.problem then tprint("; %", job.problem) else "");
|
||||||
|
if job.stale || job.problem catalog_cache.dirty = true; // the next save leaves the stale ones out
|
||||||
|
background_catalog_load(job.misses);
|
||||||
|
if job.items.count background_catalog_load(job.items, job.data); // the batch frees data
|
||||||
|
else free(job.data,, context.default_allocator);
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
free(job.path);
|
||||||
|
array_free(job.captures);
|
||||||
|
array_free(job.items);
|
||||||
|
array_free(job.misses);
|
||||||
|
free(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entry at d's cursor, from the default allocator, without its folder and lowercased copies
|
||||||
|
// (catalog_finish). Null when d runs out.
|
||||||
|
catalog_cache_read_entry :: (d: *Deserializer) -> *Catalog_Entry {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
read_string :: () -> string #expand { return copy_string(ReadSizedString(d, u32)); }
|
||||||
|
entry := New(Catalog_Entry);
|
||||||
|
entry.date = read_string();
|
||||||
|
entry.title = read_string();
|
||||||
|
entry.url = read_string();
|
||||||
|
entry.app = read_string();
|
||||||
|
entry.notes = read_string();
|
||||||
|
entry.label_list = read_string();
|
||||||
|
entry.video_codec = read_string();
|
||||||
|
entry.segment_classes = read_string();
|
||||||
|
entry.media = Read(d, Catalog_Media);
|
||||||
|
entry.duration = Read(d, float64);
|
||||||
|
entry.frame_rate = Read(d, float64);
|
||||||
|
entry.segment_count = Read(d, s32);
|
||||||
|
entry.embedding = array_copy(ReadSizedArray(d, u16, u32));
|
||||||
|
|
||||||
|
entry.labels.allocator = context.default_allocator;
|
||||||
|
array_resize(*entry.labels, Read(d, u32));
|
||||||
|
for * entry.labels { it.score = Read(d, float); it.name = read_string(); }
|
||||||
|
entry.segments.allocator = context.default_allocator;
|
||||||
|
array_resize(*entry.segments, Read(d, u32));
|
||||||
|
for * entry.segments { it.class = read_string(); it.score = Read(d, float); it.area = Read(d, float); }
|
||||||
|
entry.ocr.allocator = context.default_allocator;
|
||||||
|
array_resize(*entry.ocr, Read(d, u32));
|
||||||
|
for * section: entry.ocr {
|
||||||
|
section.model = read_string();
|
||||||
|
section.engine = read_string();
|
||||||
|
section.image = read_string();
|
||||||
|
section.date = read_string();
|
||||||
|
section.image_width = Read(d, s32);
|
||||||
|
section.image_height = Read(d, s32);
|
||||||
|
section.lines.allocator = context.default_allocator;
|
||||||
|
array_resize(*section.lines, Read(d, u32));
|
||||||
|
for * line: section.lines {
|
||||||
|
line.x = Read(d, s32);
|
||||||
|
line.y = Read(d, s32);
|
||||||
|
line.width = Read(d, s32);
|
||||||
|
line.height = Read(d, s32);
|
||||||
|
line.confidence = Read(d, float);
|
||||||
|
line.corners = Read(d, [8] s32);
|
||||||
|
line.text = read_string();
|
||||||
|
if !d.ok break;
|
||||||
|
}
|
||||||
|
if !d.ok break;
|
||||||
|
}
|
||||||
|
if !d.ok { catalog_entry_free(entry); return null; }
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog_cache_add_entry :: (s: *Serializer, entry: *Catalog_Entry) {
|
||||||
|
for string.[entry.date, entry.title, entry.url, entry.app, entry.notes, entry.label_list, entry.video_codec, entry.segment_classes] AddString(s, it, u32);
|
||||||
|
Add(s, entry.media);
|
||||||
|
Add(s, entry.duration);
|
||||||
|
Add(s, entry.frame_rate);
|
||||||
|
Add(s, entry.segment_count);
|
||||||
|
AddSizedArray(s, entry.embedding, u32);
|
||||||
|
Add(s, cast(u32) entry.labels.count);
|
||||||
|
for entry.labels { Add(s, it.score); AddString(s, it.name, u32); }
|
||||||
|
Add(s, cast(u32) entry.segments.count);
|
||||||
|
for entry.segments { AddString(s, it.class, u32); Add(s, it.score); Add(s, it.area); }
|
||||||
|
Add(s, cast(u32) entry.ocr.count);
|
||||||
|
for section: entry.ocr {
|
||||||
|
for string.[section.model, section.engine, section.image, section.date] AddString(s, it, u32);
|
||||||
|
Add(s, section.image_width);
|
||||||
|
Add(s, section.image_height);
|
||||||
|
Add(s, cast(u32) section.lines.count);
|
||||||
|
for line: section.lines {
|
||||||
|
Add(s, line.x); Add(s, line.y); Add(s, line.width); Add(s, line.height);
|
||||||
|
Add(s, line.confidence);
|
||||||
|
Add(s, line.corners);
|
||||||
|
AddString(s, line.text, u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Catalog_Cache_Record :: struct {
|
||||||
|
relative: string; // the capture's path relative to the library: a view into it, never freed
|
||||||
|
entry: *Catalog_Entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache file's bytes for these records. Default allocator.
|
||||||
|
catalog_cache_serialize :: (records: [] Catalog_Cache_Record) -> Serializer {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
s := new_serializer(records.count * 4096 + 1024);
|
||||||
|
AddArray_NoSize(*s, cast([] u8) CATALOG_CACHE_MAGIC);
|
||||||
|
Add(*s, cast(u32) CATALOG_CACHE_VERSION);
|
||||||
|
AddString(*s, LABELS_MODEL, u32);
|
||||||
|
AddString(*s, SEGMENT_MODEL, u32);
|
||||||
|
Add(*s, cast(u32) records.count);
|
||||||
|
for records {
|
||||||
|
length_at := s.count;
|
||||||
|
Add(*s, cast(u32) 0); // its length, filled in below
|
||||||
|
AddString(*s, it.relative, u32);
|
||||||
|
Add(*s, it.entry.file_size);
|
||||||
|
Add(*s, it.entry.file_modified);
|
||||||
|
catalog_cache_add_entry(*s, it.entry);
|
||||||
|
length := cast(u32) (s.count - length_at - size_of(u32));
|
||||||
|
memcpy(s.data + length_at, *length, size_of(u32));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A save: the entries as they were when it started. Heap. A job holding one counts in
|
||||||
|
// background.jobs_out, so no entry it reads is freed while it runs (background_retire).
|
||||||
|
Catalog_Cache_Save :: struct {
|
||||||
|
path: string;
|
||||||
|
records: [] Catalog_Cache_Record;
|
||||||
|
bytes: s64;
|
||||||
|
ms: float64;
|
||||||
|
ok: bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI thread, each frame: saves once the catalog is complete and has changed.
|
||||||
|
catalog_cache_frame :: () {
|
||||||
|
if !catalog_cache.dirty || catalog_cache.saving || viewer.catalog_loaded < viewer.captures.count return;
|
||||||
|
if catalog_cache.saved_at != .{} && to_float64_seconds(current_time_monotonic() - catalog_cache.saved_at) < CATALOG_CACHE_SAVE_SECONDS return;
|
||||||
|
catalog_cache.dirty = false;
|
||||||
|
catalog_cache.saving = true;
|
||||||
|
background_catalog_save(catalog_cache_snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog_cache_snapshot :: () -> *Catalog_Cache_Save {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
save := New(Catalog_Cache_Save);
|
||||||
|
save.path = copy_string(catalog_cache_path());
|
||||||
|
records: [..] Catalog_Cache_Record;
|
||||||
|
array_reserve(*records, viewer.captures.count);
|
||||||
|
folder := viewer_folder();
|
||||||
|
for viewer.captures if !it.removed && it.catalog array_add(*records, .{advance(it.path, folder.count + 1), it.catalog});
|
||||||
|
save.records = records;
|
||||||
|
return save;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a background worker, or the shutdown thread: written beside itself, then swapped in.
|
||||||
|
catalog_cache_save_job :: (save: *Catalog_Cache_Save) {
|
||||||
|
start := current_time_monotonic();
|
||||||
|
s := catalog_cache_serialize(save.records);
|
||||||
|
defer array_free(s);
|
||||||
|
temp_path := tprint("%.tmp", save.path);
|
||||||
|
save.ok = write_entire_file(temp_path, s.data, s.count) && atomic_file_replace_or_just_rename_if_destination_file_does_not_exist(temp_path, save.path);
|
||||||
|
save.bytes = s.count;
|
||||||
|
save.ms = to_float64_seconds(current_time_monotonic() - start) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI thread (or the shutdown thread): a save is done.
|
||||||
|
catalog_cache_save_done :: (save: *Catalog_Cache_Save) {
|
||||||
|
catalog_cache.saving = false;
|
||||||
|
catalog_cache.saved_at = current_time_monotonic();
|
||||||
|
if save.ok log("[catalog] Saved the cache: % entries, % MB in % ms.", save.records.count, FF(save.bytes / (1024.0 * 1024.0), 1), FF(save.ms, 1));
|
||||||
|
else log_error("[catalog] Could not write %; the next start reads the captures from their files.", save.path);
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
free(save.path);
|
||||||
|
array_free(save.records);
|
||||||
|
free(save);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the shutdown thread, once the background group has stopped: the last changes, when the
|
||||||
|
// catalog is complete (an incomplete one would drop what the cache holds of the rest).
|
||||||
|
catalog_cache_save_at_shutdown :: () {
|
||||||
|
if !catalog_cache.dirty && !catalog_cache.saving return;
|
||||||
|
if viewer.catalog_loaded < viewer.captures.count return;
|
||||||
|
save := catalog_cache_snapshot();
|
||||||
|
catalog_cache_save_job(save);
|
||||||
|
catalog_cache_save_done(save);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- search
|
// ---------------------------------------------------------------- search
|
||||||
|
|
||||||
// LIKE is by meaning: the captures whose image is nearest the phrase, ranked by the label model
|
// LIKE is by meaning: the captures whose image is nearest the phrase, ranked by the label model
|
||||||
|
|||||||
525
src/app/gpu_bench_ui.jai
Normal file
525
src/app/gpu_bench_ui.jai
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
// The GPU Benchmark window: runs gpu-bench (tools/gpu_bench) in a process of
|
||||||
|
// its own, so the app's engines and memory do not touch its numbers, shows how far it has got, and
|
||||||
|
// lays the results files in gpu-bench-results side by side. To compare with another machine, copy
|
||||||
|
// its results file into that folder (Open folder).
|
||||||
|
|
||||||
|
GPU_BENCH_WINDOW :: "GPU Benchmark";
|
||||||
|
GPU_BENCH_LOG_LINES :: 2000;
|
||||||
|
|
||||||
|
gpu_bench_ui: struct {
|
||||||
|
window_open: bool;
|
||||||
|
run_ocr := true;
|
||||||
|
run_labels := true;
|
||||||
|
run_segment := true;
|
||||||
|
cpu: bool;
|
||||||
|
repeats: s32 = 3;
|
||||||
|
note: [128] u8;
|
||||||
|
|
||||||
|
// A run: the reader thread owns the child process and appends what it prints to output.
|
||||||
|
running: bool;
|
||||||
|
mutex_ready: bool;
|
||||||
|
mutex: Mutex;
|
||||||
|
thread: Thread;
|
||||||
|
command: [..] string; // heap; for the thread
|
||||||
|
output: [..] u8; // under mutex; heap
|
||||||
|
finished: bool; // under mutex: the child has exited, or could not start
|
||||||
|
exit_code: s32; // under mutex
|
||||||
|
start_error: string; // under mutex; heap
|
||||||
|
cancelled: bool;
|
||||||
|
#if OS == .WINDOWS process: HANDLE; // under mutex
|
||||||
|
else process: Process;
|
||||||
|
started_at: Apollo_Time;
|
||||||
|
parsed: s64; // bytes of output made into lines
|
||||||
|
log: [..] string; // heap
|
||||||
|
scroll_log: bool;
|
||||||
|
status: string; // heap: where the run is, or how it ended
|
||||||
|
|
||||||
|
reports: [..] Bench_Report; // newest first
|
||||||
|
shown: [..] bool; // per report: a column in the comparison
|
||||||
|
baseline: string; // heap: the path of the report the others are compared with
|
||||||
|
loaded: bool;
|
||||||
|
};
|
||||||
|
|
||||||
|
gpu_bench_results_folder :: () -> string {
|
||||||
|
return tprint("%gpu-bench-results", path_strip_filename(get_path_of_running_executable()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The release build, else the debug one; "" when neither is beside the app.
|
||||||
|
gpu_bench_executable :: () -> path: string, debug: bool {
|
||||||
|
folder := path_strip_filename(get_path_of_running_executable());
|
||||||
|
extension := ifx OS == .WINDOWS then ".exe" else "";
|
||||||
|
release := tprint("%gpu-bench%", folder, extension);
|
||||||
|
if file_exists(release) return release, false;
|
||||||
|
debug := tprint("%gpu-bench-debug%", folder, extension);
|
||||||
|
if file_exists(debug) return debug, true;
|
||||||
|
return "", false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_ui_frame :: () { using ImGui;
|
||||||
|
if gpu_bench_ui.running gpu_bench_poll();
|
||||||
|
if !gpu_bench_ui.window_open return;
|
||||||
|
if !gpu_bench_ui.loaded gpu_bench_load_reports();
|
||||||
|
SetNextWindowSize(.{980, 680}, .FirstUseEver);
|
||||||
|
if Begin(GPU_BENCH_WINDOW, *gpu_bench_ui.window_open) gpu_bench_window_contents();
|
||||||
|
End();
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_ui_shutdown :: () {
|
||||||
|
if gpu_bench_ui.running {
|
||||||
|
gpu_bench_cancel();
|
||||||
|
thread_is_done(*gpu_bench_ui.thread, -1);
|
||||||
|
thread_deinit(*gpu_bench_ui.thread);
|
||||||
|
gpu_bench_ui.running = false;
|
||||||
|
}
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
for * gpu_bench_ui.reports bench_report_free(it);
|
||||||
|
array_free(gpu_bench_ui.reports);
|
||||||
|
array_free(gpu_bench_ui.shown);
|
||||||
|
for gpu_bench_ui.log free(it);
|
||||||
|
array_free(gpu_bench_ui.log);
|
||||||
|
array_free(gpu_bench_ui.output);
|
||||||
|
for gpu_bench_ui.command free(it);
|
||||||
|
array_free(gpu_bench_ui.command);
|
||||||
|
free(gpu_bench_ui.status);
|
||||||
|
free(gpu_bench_ui.baseline);
|
||||||
|
free(gpu_bench_ui.start_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
gpu_bench_window_contents :: () { using ImGui;
|
||||||
|
TextWrapped("Times each model mcap runs on the GPU (OCR, labels, segmentation) over a fixed corpus of 30 images that every machine has, each checked against its SHA-256 before anything runs. Each model's files and settings are fingerprinted too, so a comparison shows whether it is like for like.");
|
||||||
|
exe, debug := gpu_bench_executable();
|
||||||
|
|
||||||
|
if gpu_bench_ui.running {
|
||||||
|
Text("%", gpu_bench_ui.status);
|
||||||
|
SameLine();
|
||||||
|
TextColored(app_theme.palette.dim, "(% s)", formatFloat(to_float64_seconds(current_time_monotonic() - gpu_bench_ui.started_at), trailing_width = 0));
|
||||||
|
SameLine();
|
||||||
|
if Button("Cancel") gpu_bench_cancel();
|
||||||
|
} else {
|
||||||
|
Checkbox("OCR", *gpu_bench_ui.run_ocr);
|
||||||
|
SameLine(); Checkbox("Labels", *gpu_bench_ui.run_labels);
|
||||||
|
SameLine(); Checkbox("Segmentation", *gpu_bench_ui.run_segment);
|
||||||
|
SameLine(); Checkbox("CPU too", *gpu_bench_ui.cpu);
|
||||||
|
if IsItemHovered() SetTooltip("Each model on the CPU as well, after the GPU, and the label model's text encoder (a like: search). Slow: minutes for segmentation.");
|
||||||
|
SameLine();
|
||||||
|
SetNextItemWidth(120);
|
||||||
|
SliderInt("Timed passes", *gpu_bench_ui.repeats, 1, 10);
|
||||||
|
if IsItemHovered() SetTooltip("Passes over the corpus that are timed, after one untimed warm-up pass; the median is reported");
|
||||||
|
SetNextItemWidth(-FLOAT32_MIN);
|
||||||
|
InputTextWithHint("##note", "A note kept with the results, such as: plugged in, best performance mode", gpu_bench_ui.note.data, gpu_bench_ui.note.count);
|
||||||
|
|
||||||
|
reason := "";
|
||||||
|
if !exe reason = "gpu-bench is not beside the app: build with jai first.jai - release.";
|
||||||
|
else if ocr_ui.thread_busy reason = "A scan or a reading is running on the GPU: let it finish, or pause or cancel it, first.";
|
||||||
|
else if !gpu_bench_ui.run_ocr && !gpu_bench_ui.run_labels && !gpu_bench_ui.run_segment reason = "Choose a kind of model to run.";
|
||||||
|
if reason BeginDisabled();
|
||||||
|
if Button("Run the benchmark") gpu_bench_start(exe);
|
||||||
|
if reason EndDisabled();
|
||||||
|
if reason { SameLine(); TextColored(app_theme.palette.warning, "%", reason); }
|
||||||
|
else if debug { SameLine(); TextColored(app_theme.palette.warning, "Only the debug gpu-bench is built: its CPU work is slow, so build with - release to compare."); }
|
||||||
|
else if gpu_bench_ui.status { SameLine(); TextColored(app_theme.palette.dim, "%", gpu_bench_ui.status); }
|
||||||
|
TextColored(app_theme.palette.dim, "mcap's own models stay loaded while it runs. For the cleanest numbers, close mcap and run gpu-bench in a terminal; its results land in the same folder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if CollapsingHeader(temp_c_string(tprint("Output (% lines)###output", gpu_bench_ui.log.count))) {
|
||||||
|
if BeginChild("##log", .{0, 220}, .Borders, .HorizontalScrollbar) {
|
||||||
|
for gpu_bench_ui.log TextUnformatted(it);
|
||||||
|
if gpu_bench_ui.scroll_log { SetScrollHereY(1); gpu_bench_ui.scroll_log = false; }
|
||||||
|
}
|
||||||
|
EndChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator();
|
||||||
|
folder := gpu_bench_results_folder();
|
||||||
|
Text("Results (% files)", gpu_bench_ui.reports.count);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Open folder") {
|
||||||
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||||
|
platform_open_file_in_default_program(folder, folder);
|
||||||
|
}
|
||||||
|
if IsItemHovered() SetTooltip("% : copy another machine's results files here to compare", folder);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Reload") gpu_bench_load_reports();
|
||||||
|
if !gpu_bench_ui.reports.count {
|
||||||
|
TextColored(app_theme.palette.dim, "None yet: run the benchmark.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gpu_bench_reports_table();
|
||||||
|
Spacing();
|
||||||
|
gpu_bench_comparison_table();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The results files: which are shown, and which is the baseline.
|
||||||
|
gpu_bench_reports_table :: () { using ImGui;
|
||||||
|
height := min(cast(float) gpu_bench_ui.reports.count + 1, 7) * GetFrameHeightWithSpacing();
|
||||||
|
if !BeginTable("##reports", 7, .Borders | .RowBg | .SizingFixedFit | .ScrollY, .{0, height}) return;
|
||||||
|
TableSetupScrollFreeze(0, 1);
|
||||||
|
TableSetupColumn("Show");
|
||||||
|
TableSetupColumn("Baseline");
|
||||||
|
TableSetupColumn("Date");
|
||||||
|
TableSetupColumn("Machine");
|
||||||
|
TableSetupColumn("GPU");
|
||||||
|
TableSetupColumn("Power");
|
||||||
|
TableSetupColumn("Note", .WidthStretch);
|
||||||
|
TableHeadersRow();
|
||||||
|
for * report, n: gpu_bench_ui.reports {
|
||||||
|
PushID(cast(s32) n);
|
||||||
|
TableNextRow();
|
||||||
|
TableNextColumn(); Checkbox("##show", *gpu_bench_ui.shown[n]);
|
||||||
|
TableNextColumn();
|
||||||
|
if RadioButton("##baseline", report.path == gpu_bench_ui.baseline) {
|
||||||
|
free(gpu_bench_ui.baseline,, context.default_allocator);
|
||||||
|
gpu_bench_ui.baseline = copy_string(report.path,, context.default_allocator);
|
||||||
|
gpu_bench_ui.shown[n] = true;
|
||||||
|
}
|
||||||
|
TableNextColumn(); TextUnformatted(bench_header(report, "date"));
|
||||||
|
if IsItemHovered() SetTooltip("%", report.path);
|
||||||
|
TableNextColumn(); TextUnformatted(bench_header(report, "machine"));
|
||||||
|
TableNextColumn(); TextUnformatted(bench_header(report, "gpu"));
|
||||||
|
TableNextColumn();
|
||||||
|
limit := bench_header(report, "power_limit_w");
|
||||||
|
power := bench_header(report, "power");
|
||||||
|
TextUnformatted(tprint("%%%", ifx limit then tprint("% W", limit) else "", ifx limit && power then ", " else "", power));
|
||||||
|
TableNextColumn(); TextUnformatted(bench_header(report, "note"));
|
||||||
|
PopID();
|
||||||
|
}
|
||||||
|
EndTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shown reports side by side: what each machine is, then each model's time, against the baseline's.
|
||||||
|
gpu_bench_comparison_table :: () { using ImGui;
|
||||||
|
columns: [..] *Bench_Report;
|
||||||
|
columns.allocator = temp;
|
||||||
|
baseline: *Bench_Report;
|
||||||
|
for * gpu_bench_ui.reports {
|
||||||
|
if gpu_bench_ui.shown[it_index] array_add(*columns, it);
|
||||||
|
if it.path == gpu_bench_ui.baseline baseline = it;
|
||||||
|
}
|
||||||
|
if !columns.count { TextColored(app_theme.palette.dim, "Tick Show for the results to compare."); return; }
|
||||||
|
if !baseline baseline = columns[0];
|
||||||
|
|
||||||
|
// Every task and provider any of them ran, in the order they ran.
|
||||||
|
Bench_Key :: struct { task, provider: string; }
|
||||||
|
keys: [..] Bench_Key;
|
||||||
|
keys.allocator = temp;
|
||||||
|
for report: columns for report.rows {
|
||||||
|
found := false;
|
||||||
|
for key: keys if key.task == it.task && key.provider == it.provider found = true;
|
||||||
|
if !found array_add(*keys, .{it.task, it.provider});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !BeginTable("##compare", cast(s32) columns.count + 1, .Borders | .RowBg | .SizingFixedFit | .ScrollX | .ScrollY, .{0, -FLOAT32_MIN}) return;
|
||||||
|
TableSetupScrollFreeze(1, 1);
|
||||||
|
TableSetupColumn("");
|
||||||
|
for columns TableSetupColumn(temp_c_string(tprint("%##%", bench_header(it, "machine"), it_index)));
|
||||||
|
TableHeadersRow();
|
||||||
|
|
||||||
|
header_row :: (label: string, key: string, tooltip := "") #expand {
|
||||||
|
TableNextRow();
|
||||||
|
TableNextColumn(); TextColored(app_theme.palette.dim, "%", label);
|
||||||
|
if tooltip && IsItemHovered() SetTooltip("%", tooltip);
|
||||||
|
for columns {
|
||||||
|
TableNextColumn();
|
||||||
|
value := bench_header(it, key);
|
||||||
|
if it != baseline && value != bench_header(baseline, key) TextColored(app_theme.palette.accent, "%", value);
|
||||||
|
else TextUnformatted(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header_row("GPU", "gpu");
|
||||||
|
header_row("Memory", "vram_gb", "GB of GPU memory");
|
||||||
|
header_row("Power limit", "power_limit_w", "The board's enforced limit in watts: a laptop's GPU is often far below the desktop part's");
|
||||||
|
header_row("Power", "power");
|
||||||
|
header_row("Driver", "driver");
|
||||||
|
header_row("cuDNN", "cudnn");
|
||||||
|
header_row("onnxruntime", "onnxruntime");
|
||||||
|
header_row("CPU", "cpu");
|
||||||
|
header_row("Date", "date");
|
||||||
|
header_row("Note", "note");
|
||||||
|
|
||||||
|
// The corpus: different images make every number not comparable.
|
||||||
|
TableNextRow();
|
||||||
|
TableNextColumn(); TextColored(app_theme.palette.dim, "Corpus");
|
||||||
|
for columns {
|
||||||
|
TableNextColumn();
|
||||||
|
corpus := bench_header(it, "corpus");
|
||||||
|
if corpus == bench_header(baseline, "corpus") TextColored(app_theme.palette.success, "same (%)", slice(corpus, 0, min(corpus.count, 12)));
|
||||||
|
else TextColored(app_theme.palette.error, "DIFFERENT: not comparable");
|
||||||
|
}
|
||||||
|
|
||||||
|
for key: keys {
|
||||||
|
TableNextRow();
|
||||||
|
TableNextColumn();
|
||||||
|
TextUnformatted(tprint("% %", key.task, ifx key.provider == "CUDA" then "(GPU)" else "(CPU)"));
|
||||||
|
base_row := bench_row(baseline, key.task, key.provider);
|
||||||
|
base_ms := ifx base_row then string_to_float64(bench_field(base_row, "ms")) else 0;
|
||||||
|
for report: columns {
|
||||||
|
TableNextColumn();
|
||||||
|
row := bench_row(report, key.task, key.provider);
|
||||||
|
if !row {
|
||||||
|
error := bench_header(report, tprint("error % %", key.task, key.provider));
|
||||||
|
if !error error = bench_header(report, tprint("error % CPU", key.task)); // a GPU run that fell back
|
||||||
|
if error { TextColored(app_theme.palette.error, "failed"); if IsItemHovered() SetTooltip("%", error); }
|
||||||
|
else TextColored(app_theme.palette.dim, "-");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ms := string_to_float64(bench_field(row, "ms"));
|
||||||
|
unit := bench_field(row, "unit");
|
||||||
|
same_model := bench_header(report, tprint("model %", key.task)) == bench_header(baseline, tprint("model %", key.task));
|
||||||
|
text := tprint("% ms/%", formatFloat(ms, trailing_width = ifx ms < 10 then 2 else 1), unit);
|
||||||
|
if report != baseline && base_ms > 0 && ms > 0 {
|
||||||
|
ratio := base_ms / ms; // above 1: faster than the baseline
|
||||||
|
colour := ifx ratio > 1.05 then app_theme.palette.success else ifx ratio < 0.95 then app_theme.palette.warning else app_theme.palette.neutral;
|
||||||
|
TextColored(colour, "% (%x)%", text, formatFloat(ratio, trailing_width = 2), ifx same_model then "" else " *");
|
||||||
|
} else {
|
||||||
|
TextUnformatted(tprint("%%", text, ifx same_model then "" else " *"));
|
||||||
|
}
|
||||||
|
if IsItemHovered() {
|
||||||
|
builder: String_Builder;
|
||||||
|
builder.allocator = temp;
|
||||||
|
if !same_model append(*builder, "* Different model files or models.config settings from the baseline's: not like for like.\n\n");
|
||||||
|
print_to_builder(*builder, "% on %: % %s per second", key.task, report.path, bench_field(row, "per_s"), unit);
|
||||||
|
mp := bench_field(row, "mp_per_s");
|
||||||
|
if mp print_to_builder(*builder, ", % megapixels per second", mp);
|
||||||
|
print_to_builder(*builder, "\nmedian of the timed passes; best % ms, worst % ms\n", bench_field(row, "best_ms"), bench_field(row, "worst_ms"));
|
||||||
|
print_to_builder(*builder, "loading % s, the first % % s, batch %\n", bench_field(row, "load_s"), unit, bench_field(row, "first_s"), bench_field(row, "batch"));
|
||||||
|
vram := bench_field(row, "vram_mb");
|
||||||
|
if vram print_to_builder(*builder, "GPU: % MB more memory at the peak, % percent busy, % W, % MHz, up to % C\n", vram,
|
||||||
|
bench_field(row, "gpu_util"), bench_field(row, "power_w"), bench_field(row, "sm_mhz"), bench_field(row, "temp_c"));
|
||||||
|
stages := bench_field(row, "stages");
|
||||||
|
if stages print_to_builder(*builder, "ms per %, summed over threads: %\n", unit, stages);
|
||||||
|
print_to_builder(*builder, "found %", bench_field(row, "output"));
|
||||||
|
if report != baseline && base_row {
|
||||||
|
same_output := bench_field(row, "digest") == bench_field(base_row, "digest");
|
||||||
|
print_to_builder(*builder, ", % the baseline's", ifx same_output then "the same as" else "not quite the same as (GPUs round differently)");
|
||||||
|
}
|
||||||
|
SetTooltip("%", builder_to_string(*builder,, temp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EndTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_load_reports :: () {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
for * gpu_bench_ui.reports bench_report_free(it);
|
||||||
|
array_reset(*gpu_bench_ui.reports);
|
||||||
|
array_reset(*gpu_bench_ui.shown);
|
||||||
|
gpu_bench_ui.loaded = true;
|
||||||
|
folder := gpu_bench_results_folder();
|
||||||
|
if !file_exists(folder) return;
|
||||||
|
for file_list(folder,, temp) {
|
||||||
|
if !ends_with_nocase(it, ".txt") continue;
|
||||||
|
text, ok := read_entire_file(it, log_errors = false,, temp);
|
||||||
|
if !ok continue;
|
||||||
|
report, parsed, error := bench_parse(text, it);
|
||||||
|
if !parsed { log_error("[gpu-bench] %", error); bench_report_free(*report); continue; }
|
||||||
|
array_add(*gpu_bench_ui.reports, report);
|
||||||
|
}
|
||||||
|
quick_sort(gpu_bench_ui.reports, (a: Bench_Report, b: Bench_Report) -> s64 { return compare(bench_header(*b, "date"), bench_header(*a, "date")); });
|
||||||
|
array_resize(*gpu_bench_ui.shown, gpu_bench_ui.reports.count);
|
||||||
|
for * gpu_bench_ui.shown it.* = true;
|
||||||
|
found := false;
|
||||||
|
for gpu_bench_ui.reports if it.path == gpu_bench_ui.baseline found = true;
|
||||||
|
if !found && gpu_bench_ui.reports.count {
|
||||||
|
free(gpu_bench_ui.baseline);
|
||||||
|
gpu_bench_ui.baseline = copy_string(gpu_bench_ui.reports[gpu_bench_ui.reports.count - 1].path); // the oldest: what later runs are measured against
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_start :: (exe: string) {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
if !gpu_bench_ui.mutex_ready { init(*gpu_bench_ui.mutex); gpu_bench_ui.mutex_ready = true; }
|
||||||
|
for gpu_bench_ui.command free(it);
|
||||||
|
array_reset(*gpu_bench_ui.command);
|
||||||
|
add :: (s: string) #expand { array_add(*gpu_bench_ui.command, copy_string(s)); }
|
||||||
|
add(exe);
|
||||||
|
add("-progress");
|
||||||
|
add("-repeats"); add(tprint("%", gpu_bench_ui.repeats));
|
||||||
|
add("-out"); add(gpu_bench_results_folder());
|
||||||
|
only: [..] string;
|
||||||
|
only.allocator = temp;
|
||||||
|
if gpu_bench_ui.run_ocr array_add(*only, "ocr");
|
||||||
|
if gpu_bench_ui.run_labels array_add(*only, "labels");
|
||||||
|
if gpu_bench_ui.run_segment array_add(*only, "segment");
|
||||||
|
add("-only"); add(join(..only, ",",, temp));
|
||||||
|
if gpu_bench_ui.cpu add("-cpu");
|
||||||
|
note := trim(to_string(gpu_bench_ui.note.data));
|
||||||
|
if note { add("-note"); add(note); }
|
||||||
|
|
||||||
|
for gpu_bench_ui.log free(it);
|
||||||
|
array_reset(*gpu_bench_ui.log);
|
||||||
|
array_reset(*gpu_bench_ui.output);
|
||||||
|
free(gpu_bench_ui.start_error);
|
||||||
|
gpu_bench_ui.start_error = "";
|
||||||
|
gpu_bench_ui.finished = false;
|
||||||
|
gpu_bench_ui.cancelled = false;
|
||||||
|
gpu_bench_ui.exit_code = 0;
|
||||||
|
gpu_bench_ui.parsed = 0;
|
||||||
|
gpu_bench_set_status("Starting...");
|
||||||
|
gpu_bench_ui.started_at = current_time_monotonic();
|
||||||
|
thread_init(*gpu_bench_ui.thread, gpu_bench_thread_proc, name = "gpu-bench");
|
||||||
|
thread_start(*gpu_bench_ui.thread);
|
||||||
|
gpu_bench_ui.running = true;
|
||||||
|
log("[gpu-bench] Running %", join(..gpu_bench_ui.command, " ",, temp));
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_cancel :: () {
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
gpu_bench_ui.cancelled = true;
|
||||||
|
#if OS == .WINDOWS { if gpu_bench_ui.process TerminateProcess(gpu_bench_ui.process, 1); }
|
||||||
|
else kill_process(*gpu_bench_ui.process);
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the child printed since the last frame, made into lines; and the end of the run.
|
||||||
|
gpu_bench_poll :: () {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
finished := gpu_bench_ui.finished;
|
||||||
|
exit_code := gpu_bench_ui.exit_code;
|
||||||
|
new_text := copy_string(cast(string) array_view(gpu_bench_ui.output, gpu_bench_ui.parsed, gpu_bench_ui.output.count - gpu_bench_ui.parsed),, temp);
|
||||||
|
start_error := copy_string(gpu_bench_ui.start_error,, temp);
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
|
||||||
|
// Whole lines only, until the end.
|
||||||
|
end := find_index_from_right(new_text, #char "\n");
|
||||||
|
if finished end = new_text.count;
|
||||||
|
if end > 0 {
|
||||||
|
gpu_bench_ui.parsed += end;
|
||||||
|
for raw: split(slice(new_text, 0, end), "\n",, temp) {
|
||||||
|
line := trim_right(raw, "\r");
|
||||||
|
if !line.count continue;
|
||||||
|
if line[0] == 0x1b continue; // onnxruntime's coloured warnings
|
||||||
|
if begins_with(line, "@progress ") { gpu_bench_set_status(gpu_bench_progress_words(advance(line, 10))); continue; }
|
||||||
|
if begins_with(line, "@results ") continue;
|
||||||
|
array_add(*gpu_bench_ui.log, copy_string(line));
|
||||||
|
gpu_bench_ui.scroll_log = true;
|
||||||
|
}
|
||||||
|
while gpu_bench_ui.log.count > GPU_BENCH_LOG_LINES {
|
||||||
|
free(gpu_bench_ui.log[0]);
|
||||||
|
array_ordered_remove_by_index(*gpu_bench_ui.log, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !finished return;
|
||||||
|
|
||||||
|
thread_is_done(*gpu_bench_ui.thread, -1);
|
||||||
|
thread_deinit(*gpu_bench_ui.thread);
|
||||||
|
gpu_bench_ui.running = false;
|
||||||
|
seconds := formatFloat(to_float64_seconds(current_time_monotonic() - gpu_bench_ui.started_at), trailing_width = 0);
|
||||||
|
if start_error gpu_bench_set_status(start_error);
|
||||||
|
else if gpu_bench_ui.cancelled gpu_bench_set_status("Cancelled.");
|
||||||
|
else if exit_code == 0 gpu_bench_set_status(tprint("Done in % s.", seconds));
|
||||||
|
else gpu_bench_set_status(tprint("gpu-bench failed (exit code %) after % s: Output says why.", exit_code, seconds));
|
||||||
|
log("[gpu-bench] %", gpu_bench_ui.status);
|
||||||
|
gpu_bench_load_reports();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "@progress ocr/ppocr-v5-mobile CUDA timing 2 3" in words.
|
||||||
|
gpu_bench_progress_words :: (text: string) -> string {
|
||||||
|
parts := split(text, " ",, temp);
|
||||||
|
if parts.count < 5 return text;
|
||||||
|
task, provider, phase, done, total := parts[0], parts[1], parts[2], parts[3], parts[4];
|
||||||
|
on := ifx provider == "CUDA" then "on the GPU" else ifx provider == "CPU" then "on the CPU" else "";
|
||||||
|
if task == "corpus" return tprint("The corpus: % %...", phase, ifx done != "0" then tprint("image %", done) else "");
|
||||||
|
if task == "hashing" return tprint("Fingerprinting %: % (% of %)", provider, phase, done, total);
|
||||||
|
if phase == {
|
||||||
|
case "loading"; return tprint("% %: loading the model...", task, on);
|
||||||
|
case "first"; return tprint("% %: the first image...", task, on);
|
||||||
|
case "warm-up"; return tprint("% %: warm-up pass % of %...", task, on, done, total);
|
||||||
|
case "timing"; return tprint("% %: timed pass % of %...", task, on, done, total);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_bench_set_status :: (text: string) {
|
||||||
|
free(gpu_bench_ui.status,, context.default_allocator);
|
||||||
|
gpu_bench_ui.status = copy_string(text,, context.default_allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reader thread: starts gpu-bench, takes what it prints until it exits.
|
||||||
|
gpu_bench_thread_proc :: (thread: *Thread) -> s64 {
|
||||||
|
append_output :: (data: *u8, count: s64) {
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
old := gpu_bench_ui.output.count;
|
||||||
|
array_resize(*gpu_bench_ui.output, old + count, initialize = false,, context.default_allocator);
|
||||||
|
memcpy(gpu_bench_ui.output.data + old, data, count);
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
}
|
||||||
|
finish :: (exit_code: s32, error := "") {
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
gpu_bench_ui.exit_code = exit_code;
|
||||||
|
if error gpu_bench_ui.start_error = copy_string(error,, context.default_allocator);
|
||||||
|
gpu_bench_ui.finished = true;
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
}
|
||||||
|
buffer: [4096] u8;
|
||||||
|
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
// Not the Process module: from a GUI program, it would open a console window for gpu-bench.
|
||||||
|
attributes: SECURITY_ATTRIBUTES;
|
||||||
|
attributes.nLength = size_of(SECURITY_ATTRIBUTES);
|
||||||
|
attributes.bInheritHandle = 1;
|
||||||
|
read_end, write_end: HANDLE;
|
||||||
|
if !CreatePipe(*read_end, *write_end, *attributes, 0) { finish(-1, tprint("Could not start gpu-bench: no pipe (Windows error %).", GetLastError())); return 0; }
|
||||||
|
SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
startup: STARTUPINFOW;
|
||||||
|
startup.cb = size_of(STARTUPINFOW);
|
||||||
|
startup.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
startup.hStdOutput = write_end;
|
||||||
|
startup.hStdError = write_end;
|
||||||
|
builder: String_Builder;
|
||||||
|
builder.allocator = temp;
|
||||||
|
for gpu_bench_ui.command { if it_index append(*builder, " "); append(*builder, windows_quote_argument(it)); }
|
||||||
|
command_line := builder_to_string(*builder,, temp);
|
||||||
|
info: PROCESS_INFORMATION;
|
||||||
|
working := path_strip_filename(gpu_bench_ui.command[0]);
|
||||||
|
started := CreateProcessW(null, utf8_to_wide(command_line,, temp), null, null, 1, CREATE_NO_WINDOW, null, utf8_to_wide(working,, temp), *startup, *info);
|
||||||
|
CloseHandle(write_end); // the child's copy is the only one left: reading ends when it exits
|
||||||
|
if !started {
|
||||||
|
error := GetLastError();
|
||||||
|
CloseHandle(read_end);
|
||||||
|
finish(-1, tprint("Could not start % (Windows error %).", gpu_bench_ui.command[0], error));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
CloseHandle(info.hThread);
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
gpu_bench_ui.process = info.hProcess;
|
||||||
|
cancelled := gpu_bench_ui.cancelled;
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
if cancelled TerminateProcess(info.hProcess, 1);
|
||||||
|
while true {
|
||||||
|
read: u32;
|
||||||
|
if !ReadFile(read_end, buffer.data, buffer.count, *read) || !read break;
|
||||||
|
append_output(buffer.data, read);
|
||||||
|
}
|
||||||
|
CloseHandle(read_end);
|
||||||
|
WaitForSingleObject(info.hProcess, INFINITE);
|
||||||
|
exit_code: u32;
|
||||||
|
GetExitCodeProcess(info.hProcess, *exit_code);
|
||||||
|
lock(*gpu_bench_ui.mutex);
|
||||||
|
gpu_bench_ui.process = null;
|
||||||
|
unlock(*gpu_bench_ui.mutex);
|
||||||
|
CloseHandle(info.hProcess);
|
||||||
|
finish(cast,no_check(s32) exit_code);
|
||||||
|
} else {
|
||||||
|
errors: [4096] u8;
|
||||||
|
if !create_process(*gpu_bench_ui.process, ..gpu_bench_ui.command, capture_and_return_output = true) {
|
||||||
|
finish(-1, tprint("Could not start %.", gpu_bench_ui.command[0]));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
while true {
|
||||||
|
ok, output_bytes, error_bytes := read_from_process(*gpu_bench_ui.process, buffer, errors, -1);
|
||||||
|
if output_bytes append_output(buffer.data, output_bytes);
|
||||||
|
if error_bytes append_output(errors.data, error_bytes);
|
||||||
|
if !ok || (gpu_bench_ui.process.output.eof && gpu_bench_ui.process.error.eof) break;
|
||||||
|
}
|
||||||
|
_, result := get_process_result(*gpu_bench_ui.process, -1);
|
||||||
|
deinit(*gpu_bench_ui.process);
|
||||||
|
finish(cast(s32) result.exit_code);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#import "Process";
|
||||||
@ -16,6 +16,9 @@
|
|||||||
// folder) holds a hash of every .mcap's original image, made once for the files already there and kept
|
// folder) holds a hash of every .mcap's original image, made once for the files already there and kept
|
||||||
// up to date after that; an image whose hash is in it is reported as a duplicate, with where it is.
|
// up to date after that; an image whose hash is in it is reported as a duplicate, with where it is.
|
||||||
//
|
//
|
||||||
|
// The source may also be a single file (app/open_file.jai, or -import FILE DEST): it alone is imported,
|
||||||
|
// and an .mcap, which a folder's import skips, is copied in as it is.
|
||||||
|
//
|
||||||
// The import runs on a thread of its own, which lists the files, brings the index up to date, and then
|
// The import runs on a thread of its own, which lists the files, brings the index up to date, and then
|
||||||
// imports with IMPORT_WORKERS threads. New captures go into the grid as they are written; when it ends,
|
// imports with IMPORT_WORKERS threads. New captures go into the grid as they are written; when it ends,
|
||||||
// the OCR scan reads and labels them, if asked to.
|
// the OCR scan reads and labels them, if asked to.
|
||||||
@ -60,6 +63,7 @@ importer: struct {
|
|||||||
|
|
||||||
// Set by the main thread before the thread starts; read-only while it runs.
|
// Set by the main thread before the thread starts; read-only while it runs.
|
||||||
source: string;
|
source: string;
|
||||||
|
single_file: bool; // source is a file, not a folder
|
||||||
dest_name: string;
|
dest_name: string;
|
||||||
dest_folder: string;
|
dest_folder: string;
|
||||||
library: string;
|
library: string;
|
||||||
@ -137,13 +141,15 @@ import_start :: (source: string, dest_name: string) -> bool {
|
|||||||
importer.library = copy_string(viewer_folder());
|
importer.library = copy_string(viewer_folder());
|
||||||
importer.dest_folder = sprint("%/%", importer.library, dest);
|
importer.dest_folder = sprint("%/%", importer.library, dest);
|
||||||
importer.error = "";
|
importer.error = "";
|
||||||
|
importer.single_file = !is_directory(source_path);
|
||||||
|
|
||||||
// The library is never imported into itself.
|
// The library is never imported into itself.
|
||||||
lower_source := to_lower_copy(importer.source,, temp);
|
lower_source := to_lower_copy(importer.source,, temp);
|
||||||
lower_library := to_lower_copy(importer.library,, temp);
|
lower_library := to_lower_copy(importer.library,, temp);
|
||||||
path_overwrite_separators(lower_library, #char "/");
|
path_overwrite_separators(lower_library, #char "/");
|
||||||
if lower_source == lower_library || begins_with(lower_source, tprint("%/", lower_library)) || begins_with(lower_library, tprint("%/", lower_source)) {
|
if lower_source == lower_library || begins_with(lower_source, tprint("%/", lower_library)) || begins_with(lower_library, tprint("%/", lower_source)) {
|
||||||
toast_show(.Error, "The folder to import holds the library, or is inside it");
|
if importer.single_file toast_show(.Error, "% is in the library already", path_filename(importer.source));
|
||||||
|
else toast_show(.Error, "The folder to import holds the library, or is inside it");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,8 +172,11 @@ import_start :: (source: string, dest_name: string) -> bool {
|
|||||||
importer.start = current_time_monotonic();
|
importer.start = current_time_monotonic();
|
||||||
importer.scan_pending = false;
|
importer.scan_pending = false;
|
||||||
|
|
||||||
table_set(*importer.sources, copy_string(dest), copy_string(importer.source));
|
// Where a library folder's captures come from: a folder, not each file opened on its own.
|
||||||
import_sources_save();
|
if !importer.single_file {
|
||||||
|
table_set(*importer.sources, copy_string(dest), copy_string(importer.source));
|
||||||
|
import_sources_save();
|
||||||
|
}
|
||||||
|
|
||||||
importer.running = true;
|
importer.running = true;
|
||||||
if importer.thread_started thread_deinit(*importer.thread);
|
if importer.thread_started thread_deinit(*importer.thread);
|
||||||
@ -197,7 +206,11 @@ import_thread_proc :: (thread: *Thread) -> s64 {
|
|||||||
// The files to import, sorted so a second run goes through them in the same order.
|
// The files to import, sorted so a second run goes through them in the same order.
|
||||||
Found :: struct { files: *[..] string; }
|
Found :: struct { files: *[..] string; }
|
||||||
found := Found.{*importer.files};
|
found := Found.{*importer.files};
|
||||||
visited := visit_files(importer.source, true, *found, (info: *File_Visit_Info, found: *Found) {
|
visited := true;
|
||||||
|
if importer.single_file {
|
||||||
|
array_add(*importer.files, copy_string(importer.source));
|
||||||
|
importer.listed = 1;
|
||||||
|
} else visited = visit_files(importer.source, true, *found, (info: *File_Visit_Info, found: *Found) {
|
||||||
if info.is_directory {
|
if info.is_directory {
|
||||||
name := info.short_name;
|
name := info.short_name;
|
||||||
if name.count && (name[0] == #char "." || name[0] == #char "$" || name == "System Volume Information") info.descend_into_directory = false;
|
if name.count && (name[0] == #char "." || name[0] == #char "$" || name == "System Volume Information") info.descend_into_directory = false;
|
||||||
@ -295,6 +308,10 @@ import_worker :: (thread: *Thread) -> s64 {
|
|||||||
import_file :: (path: string) -> Import_Outcome, text: string, new_path: string {
|
import_file :: (path: string) -> Import_Outcome, text: string, new_path: string {
|
||||||
name := path_filename(path);
|
name := path_filename(path);
|
||||||
extension := to_lower_copy(path_extension(name),, temp);
|
extension := to_lower_copy(path_extension(name),, temp);
|
||||||
|
if extension == MCAP_EXTENSION && importer.single_file {
|
||||||
|
outcome, text, new_path := import_mcap_file(path, name);
|
||||||
|
return outcome, text, new_path;
|
||||||
|
}
|
||||||
if array_find(IMPORT_OTHER_EXTENSIONS, extension) return .SKIPPED, extension, "";
|
if array_find(IMPORT_OTHER_EXTENSIONS, extension) return .SKIPPED, extension, "";
|
||||||
|
|
||||||
modified, size, stat_ok := file_modtime_and_size(path);
|
modified, size, stat_ok := file_modtime_and_size(path);
|
||||||
@ -359,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", "";
|
||||||
@ -378,6 +395,28 @@ import_video_file :: (path: string, name: string, head: [] u8, size: s64, modifi
|
|||||||
return .IMPORTED, "", new_path;
|
return .IMPORTED, "", new_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An .mcap imported on its own: copied into the library folder as it is, under its own name, a few MB
|
||||||
|
// at a time (it may hold a video). A duplicate when the library has its original.
|
||||||
|
import_mcap_file :: (path: string, name: string) -> Import_Outcome, text: string, new_path: string {
|
||||||
|
_, original, parsed, error := mcap_read_without_original(path,, temp);
|
||||||
|
if !parsed return .FAILED, error, "";
|
||||||
|
hash, hashed := import_hash_file(original);
|
||||||
|
if !hashed return .FAILED, "its original cannot be read", "";
|
||||||
|
claimed, where := import_claim(hash);
|
||||||
|
if !claimed return .DUPLICATE, where, "";
|
||||||
|
let_go := true;
|
||||||
|
defer if let_go import_let_go(hash);
|
||||||
|
|
||||||
|
_, size, stat_ok := file_modtime_and_size(path);
|
||||||
|
if !stat_ok return .FAILED, "cannot be read", "";
|
||||||
|
out_path := import_reserve_path(name);
|
||||||
|
written, write_error := mcap_extract_original(.{path = path, offset = 0, size = size}, out_path);
|
||||||
|
new_path := import_written(out_path, hash, written);
|
||||||
|
if !written return .FAILED, write_error, "";
|
||||||
|
let_go = false;
|
||||||
|
return .IMPORTED, "", new_path;
|
||||||
|
}
|
||||||
|
|
||||||
// Holds hash for a file being imported, so no other worker imports the same bytes; false, with where
|
// Holds hash for a file being imported, so no other worker imports the same bytes; false, with where
|
||||||
// they already are, when the library has them, or another file of this import does.
|
// they already are, when the library has them, or another file of this import does.
|
||||||
import_claim :: (hash: u64) -> claimed: bool, where: string {
|
import_claim :: (hash: u64) -> claimed: bool, where: string {
|
||||||
@ -853,6 +892,20 @@ import_index_worker :: (thread: *Thread) -> s64 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where the library holds the original whose hash is given, by the index as last saved in library: a
|
||||||
|
// path inside it ("Phone/2024-05-12_10-15-30_1a2b3c4d.mcap"), or "" for nowhere. Any thread; temp.
|
||||||
|
import_index_find :: (library: string, hash: u64) -> string {
|
||||||
|
data, ok := read_entire_file(tprint("%/%", library, IMPORT_INDEX_NAME), log_errors = false,, temp);
|
||||||
|
if !ok return "";
|
||||||
|
wanted := import_hash_text(hash);
|
||||||
|
for split(data, "\n",, temp) {
|
||||||
|
if !begins_with(it, wanted) continue;
|
||||||
|
parts := split(trim_right(it, "\r"), "\t",, temp);
|
||||||
|
if parts.count == 4 && parts[0] == wanted && file_exists(tprint("%/%", library, parts[3])) return parts[3];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
import_record_set :: (relative: string, record: Index_Record) {
|
import_record_set :: (relative: string, record: Index_Record) {
|
||||||
pointer := table_find_pointer(*importer.records, relative);
|
pointer := table_find_pointer(*importer.records, relative);
|
||||||
if pointer { pointer.* = record; return; }
|
if pointer { pointer.* = record; return; }
|
||||||
@ -921,7 +974,7 @@ import_frame :: () { using ImGui;
|
|||||||
importer.scan_pending = importer.read_after && importer.new_indices.count > 0 && !app.import_source;
|
importer.scan_pending = importer.read_after && importer.new_indices.count > 0 && !app.import_source;
|
||||||
importer.segment_pending = library_folder_segments(importer.dest_name) && importer.new_indices.count > 0 && !app.import_source;
|
importer.segment_pending = library_folder_segments(importer.dest_name) && importer.new_indices.count > 0 && !app.import_source;
|
||||||
}
|
}
|
||||||
if importer.scan_pending && !ocr_ui.thread_busy && ocr_ui.engine && labels_ui_settled() {
|
if importer.scan_pending && ocr_ui_models_ready(labels_ui.scan_labels) && ocr_ui.engine {
|
||||||
importer.scan_pending = false;
|
importer.scan_pending = false;
|
||||||
ocr_scan_start(importer.new_indices);
|
ocr_scan_start(importer.new_indices);
|
||||||
}
|
}
|
||||||
@ -985,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);
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
// vectors and a hash of it, made on the OCR thread (app/ocr_ui.jai) and never changed after, so a
|
// vectors and a hash of it, made on the OCR thread (app/ocr_ui.jai) and never changed after, so a
|
||||||
// scan can hold the set it started with while the list is edited.
|
// scan can hold the set it started with while the list is edited.
|
||||||
//
|
//
|
||||||
// The engine is made on the OCR thread too, once, after the OCR engine; it is destroyed only at
|
// The engine is made on the OCR thread too, when a reading, a scan or a like: search first needs it
|
||||||
// shutdown, after the background workers (app/background.jai), which use its text model for like:
|
// (ocr_ui_models_ready, labels_ui_want), and destroyed there once it has gone unused for a while
|
||||||
// searches, have stopped.
|
// (ocr_ui_unload_idle_models) with no search job holding it; else at shutdown, after the background
|
||||||
|
// workers (app/background.jai), which use its text model for like: searches, have stopped.
|
||||||
|
|
||||||
LABELS_WINDOW :: "Labels";
|
LABELS_WINDOW :: "Labels";
|
||||||
LABELS_MODEL :: "siglip2-base-patch16-384"; // the one label model; its chunks are keyed by this
|
LABELS_MODEL :: "siglip2-base-patch16-384"; // the one label model; its chunks are keyed by this
|
||||||
@ -29,8 +30,10 @@ labels_ui: struct {
|
|||||||
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
|
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
|
||||||
engine: *Labels_Engine;
|
engine: *Labels_Engine;
|
||||||
engine_error: string;
|
engine_error: string;
|
||||||
engine_tried: bool;
|
engine_tried: bool; // made, or failed; cleared when it is unloaded
|
||||||
set: *Label_Set; // the newest list's
|
last_used: Apollo_Time; // main thread: when a task or a like: search last used it
|
||||||
|
wanted: bool; // main thread: a like: search asked for it
|
||||||
|
set: *Label_Set; // the newest list's
|
||||||
retired_sets: [..] *Label_Set; // replaced while a scan might hold them; freed when the OCR thread is idle
|
retired_sets: [..] *Label_Set; // replaced while a scan might hold them; freed when the OCR thread is idle
|
||||||
|
|
||||||
// Main thread.
|
// Main thread.
|
||||||
@ -50,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 {
|
||||||
@ -77,11 +80,11 @@ labels_ui_shutdown :: () {
|
|||||||
free(labels_ui.counted_hash,, context.default_allocator);
|
free(labels_ui.counted_hash,, context.default_allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a scan can start with labels: the engine has been tried and, when it is up, the list's
|
// A like: search needs the engine: it is made when the OCR thread is free (labels_ui_frame), and the
|
||||||
// set is made.
|
// search worked out again once it is up.
|
||||||
labels_ui_settled :: () -> bool {
|
labels_ui_want :: () {
|
||||||
if ocr_ui.thread_busy || !labels_ui.engine_tried return false;
|
labels_ui.last_used = current_time_monotonic();
|
||||||
return !labels_ui.engine || (labels_ui.set && labels_ui.set.hash == labels_ui.list_hash);
|
if !labels_ui.engine_tried labels_ui.wanted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- the label list
|
// ---------------------------------------------------------------- the label list
|
||||||
@ -218,8 +221,9 @@ labels_ui_frame :: () { using ImGui;
|
|||||||
if !ocr_ui.thread_busy {
|
if !ocr_ui.thread_busy {
|
||||||
for labels_ui.retired_sets label_set_free(it);
|
for labels_ui.retired_sets label_set_free(it);
|
||||||
array_reset_keeping_memory(*labels_ui.retired_sets);
|
array_reset_keeping_memory(*labels_ui.retired_sets);
|
||||||
if !labels_ui.engine_tried && ocr_ui.engine_tried { // after the OCR engine
|
if labels_ui.wanted {
|
||||||
ocr_ui_start(.{kind = .LABELS_CREATE});
|
labels_ui.wanted = false;
|
||||||
|
if !labels_ui.engine_tried ocr_ui_start(.{kind = .LABELS_CREATE});
|
||||||
} else if labels_ui.engine && (!labels_ui.set || labels_ui.set.hash != labels_ui.list_hash) && labels_ui.list.count {
|
} else if labels_ui.engine && (!labels_ui.set || labels_ui.set.hash != labels_ui.list_hash) && labels_ui.list.count {
|
||||||
ocr_ui_start(.{kind = .LABEL_SET});
|
ocr_ui_start(.{kind = .LABEL_SET});
|
||||||
}
|
}
|
||||||
@ -240,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;
|
||||||
@ -269,10 +273,10 @@ 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, "Starts after OCR.");
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// How much of the library is labelled, and with which list: counted again only when the catalog
|
// How much of the library is labelled, and with which list: counted again only when the catalog
|
||||||
|
|||||||
118
src/app/main.jai
118
src/app/main.jai
@ -1,6 +1,7 @@
|
|||||||
main :: () {
|
main :: () {
|
||||||
using platform; using ImGui;
|
using platform; using ImGui;
|
||||||
// #Startup: Program Startup
|
// #Startup: Program Startup
|
||||||
|
app.launch_time = current_time_monotonic();
|
||||||
app.args = get_command_line_arguments();
|
app.args = get_command_line_arguments();
|
||||||
print_version_and_exit := array_find(app.args, "-version");
|
print_version_and_exit := array_find(app.args, "-version");
|
||||||
|
|
||||||
@ -17,18 +18,27 @@ 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);
|
||||||
|
#if OS == .WINDOWS for app.args if it == "-record-region" && it_index + 4 < app.args.count {
|
||||||
|
x, y := cast(s32) string_to_int(app.args[it_index + 1]), cast(s32) string_to_int(app.args[it_index + 2]);
|
||||||
|
app.record_region = .{x, y, x + cast(s32) string_to_int(app.args[it_index + 3]), y + cast(s32) string_to_int(app.args[it_index + 4])};
|
||||||
|
}
|
||||||
app.stats_and_exit = array_find(app.args, "-stats");
|
app.stats_and_exit = array_find(app.args, "-stats");
|
||||||
|
app.cold_start = array_find(app.args, "-cold");
|
||||||
|
app.no_catalog_cache = array_find(app.args, "-no-catalog-cache");
|
||||||
|
for app.args if it == "-catalog-threads" && it_index + 1 < app.args.count app.catalog_threads = string_to_int(app.args[it_index + 1]);
|
||||||
for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1];
|
for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1];
|
||||||
for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1];
|
for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1];
|
||||||
for app.args if it == "-import" && it_index + 2 < app.args.count { app.import_source = app.args[it_index + 1]; app.import_dest = app.args[it_index + 2]; }
|
for app.args if it == "-import" && it_index + 2 < app.args.count { app.import_source = app.args[it_index + 1]; app.import_dest = app.args[it_index + 2]; }
|
||||||
|
// mcap.exe FILE, as Explorer's Open with passes it: an .mcap, or an image or video (app/open_file.jai).
|
||||||
|
if app.args.count > 1 && app.args[1].count && app.args[1][0] != #char "-" && app.args[1] != "run_tests" {
|
||||||
|
app.open_file = open_file_normalize(app.args[1]);
|
||||||
|
}
|
||||||
|
for app.args if it == "-open-import" && it_index + 1 < app.args.count app.open_import_dest = app.args[it_index + 1];
|
||||||
|
for app.args if it == "-h" || it == "--help" || it == "-help" { app.show_help_panel = true; app.help_panel_focus = true; } // the Command Line window (base/debug.jai)
|
||||||
|
|
||||||
defer report_memory_leaks();
|
defer report_memory_leaks();
|
||||||
|
|
||||||
@ -96,7 +106,7 @@ main :: () {
|
|||||||
another_instance_owns_channel := false;
|
another_instance_owns_channel := false;
|
||||||
#if USING_SDL another_instance_owns_channel = app_instance_running;
|
#if USING_SDL another_instance_owns_channel = app_instance_running;
|
||||||
|
|
||||||
if !another_instance_owns_channel && app_bring_running_instance_to_front(channel_key, attempts=1) return;
|
if !another_instance_owns_channel && app_running_instance_reachable(channel_key) another_instance_owns_channel = true;
|
||||||
|
|
||||||
if !another_instance_owns_channel {
|
if !another_instance_owns_channel {
|
||||||
read_channel_success, read_channel, already_exists :=
|
read_channel_success, read_channel, already_exists :=
|
||||||
@ -112,7 +122,19 @@ main :: () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if another_instance_owns_channel {
|
if another_instance_owns_channel && app.open_file {
|
||||||
|
// A file opened while mcap runs: shown here, on its own, in view mode (app/open_file.jai).
|
||||||
|
app.view_mode = true;
|
||||||
|
app.show_debug_panel = false;
|
||||||
|
app.show_logger_panel = false;
|
||||||
|
app.show_frame_time_graph = false;
|
||||||
|
} else if another_instance_owns_channel && app.show_help_panel {
|
||||||
|
// mcap --help while mcap runs: that one comes forward with the Command Line window.
|
||||||
|
if !app_send_to_running_instance(channel_key, 20, .Show_Help) {
|
||||||
|
log_error("Another instance is running, but its shared memory channel could not be reached (it may be running as administrator); exiting.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if another_instance_owns_channel {
|
||||||
if !app_bring_running_instance_to_front(channel_key, attempts=20) {
|
if !app_bring_running_instance_to_front(channel_key, attempts=20) {
|
||||||
log_error("Another instance is running, but its shared memory channel could not be reached (it may be running as administrator); exiting.");
|
log_error("Another instance is running, but its shared memory channel could not be reached (it may be running as administrator); exiting.");
|
||||||
}
|
}
|
||||||
@ -133,6 +155,7 @@ main :: () {
|
|||||||
log(" - GIT_HASH: %", GIT_HASH);
|
log(" - GIT_HASH: %", GIT_HASH);
|
||||||
log(" - COMPILE_ARGS: %", COMPILE_ARGS);
|
log(" - COMPILE_ARGS: %", COMPILE_ARGS);
|
||||||
log(" - COMMAND_LINE_ARGUMENTS: %", args_combined);
|
log(" - COMMAND_LINE_ARGUMENTS: %", args_combined);
|
||||||
|
if app.view_mode log(" - VIEW MODE: another instance is running; showing % on its own.", app.open_file);
|
||||||
log(" --- \\STARTUP INFO/ --- ");
|
log(" --- \\STARTUP INFO/ --- ");
|
||||||
|
|
||||||
app.startup_time = current_time_monotonic();
|
app.startup_time = current_time_monotonic();
|
||||||
@ -163,8 +186,10 @@ main :: () {
|
|||||||
app.username = copy_string(get_username());
|
app.username = copy_string(get_username());
|
||||||
log("Current PC name: %, username: %", app.machine_name, app.username);
|
log("Current PC name: %, username: %", app.machine_name, app.username);
|
||||||
|
|
||||||
settings_write_back := app_load_user_settings_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 {
|
||||||
@ -187,7 +212,7 @@ main :: () {
|
|||||||
|
|
||||||
window_flags : SDL_WindowFlags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN | SDL_WINDOW_HIGH_PIXEL_DENSITY;
|
window_flags : SDL_WindowFlags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN | SDL_WINDOW_HIGH_PIXEL_DENSITY;
|
||||||
|
|
||||||
platform.window = SDL_CreateWindow(APP_WINDOW_TITLE, w, h, window_flags);
|
platform.window = SDL_CreateWindow(app_window_title().data, w, h, window_flags);
|
||||||
ok_or_exit(platform.window != null, "failed to create window with error = %", consume_sdl_error());
|
ok_or_exit(platform.window != null, "failed to create window with error = %", consume_sdl_error());
|
||||||
|
|
||||||
centered := SDL_WINDOWPOS_CENTERED_DISPLAY(display);
|
centered := SDL_WINDOWPOS_CENTERED_DISPLAY(display);
|
||||||
@ -232,7 +257,7 @@ main :: () {
|
|||||||
|
|
||||||
hwnd = CreateWindowExW(WS_EX_APPWINDOW,
|
hwnd = CreateWindowExW(WS_EX_APPWINDOW,
|
||||||
utf8_to_wide(lpszClassName,,temp),
|
utf8_to_wide(lpszClassName,,temp),
|
||||||
utf8_to_wide(APP_WINDOW_TITLE,,temp),
|
utf8_to_wide(app_window_title(),,temp),
|
||||||
WS_OVERLAPPEDWINDOW,
|
WS_OVERLAPPEDWINDOW,
|
||||||
window_x, window_y, win_width, win_height,
|
window_x, window_y, win_width, win_height,
|
||||||
null, null, null, null);
|
null, null, null, null);
|
||||||
@ -249,14 +274,20 @@ main :: () {
|
|||||||
#if OS == .LINUX {
|
#if OS == .LINUX {
|
||||||
linux_platform_setup();
|
linux_platform_setup();
|
||||||
}
|
}
|
||||||
|
// The tray icon stays for as long as the app runs, not only while the window is hidden. View
|
||||||
|
// mode has no tray: closing its window ends it.
|
||||||
|
if !app.view_mode platform_show_tray_icon();
|
||||||
|
defer platform_hide_tray_icon();
|
||||||
}
|
}
|
||||||
#if USING_WINDOWS { // :Win32_Initialize_Extended
|
#if USING_WINDOWS { // :Win32_Initialize_Extended
|
||||||
Win32_Set_Tray_Icon();
|
// The tray icon stays for as long as the app runs, not only while the window is hidden. View
|
||||||
Win32_Hide_Tray_Icon();
|
// mode has no tray: closing its window ends it.
|
||||||
defer Win32_Hide_Tray_Icon(); // exiting from the tray would otherwise leave the icon behind
|
if !app.view_mode Win32_Set_Tray_Icon();
|
||||||
|
defer Win32_Hide_Tray_Icon(); // Windows would otherwise leave the icon behind until hovered
|
||||||
}
|
}
|
||||||
// [global_hotkeys] was read with the settings, before there was a window to register them for.
|
// [global_hotkeys] was read with the settings, before there was a window to register them for.
|
||||||
global_hotkeys_apply();
|
// The running instance has them; view mode leaves them alone.
|
||||||
|
if !app.view_mode global_hotkeys_apply();
|
||||||
defer platform_unregister_global_hotkeys();
|
defer platform_unregister_global_hotkeys();
|
||||||
// #Startup:Graphics
|
// #Startup:Graphics
|
||||||
#if USING_SDL {
|
#if USING_SDL {
|
||||||
@ -361,6 +392,11 @@ main :: () {
|
|||||||
if app.exit_code exit(app.exit_code); // e.g. a headless test run with failures
|
if app.exit_code exit(app.exit_code); // e.g. a headless test run with failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Milliseconds since main() started, for the [startup] log lines.
|
||||||
|
startup_ms :: () -> float64 {
|
||||||
|
return to_float64_seconds(current_time_monotonic() - app.launch_time) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
// Returns false when the main loop should exit.
|
// Returns false when the main loop should exit.
|
||||||
app_frame :: (in_live_resize := false) -> bool {
|
app_frame :: (in_live_resize := false) -> bool {
|
||||||
using platform; using ImGui;
|
using platform; using ImGui;
|
||||||
@ -392,6 +428,15 @@ app_frame :: (in_live_resize := false) -> bool {
|
|||||||
command := cast(App_Shared_Memory_Tag)message.user_read_pointer[0];
|
command := cast(App_Shared_Memory_Tag)message.user_read_pointer[0];
|
||||||
if command == .Bring_To_Front {
|
if command == .Bring_To_Front {
|
||||||
app.restore_window_this_frame = true;
|
app.restore_window_this_frame = true;
|
||||||
|
} else if command == .Show_Help {
|
||||||
|
app.restore_window_this_frame = true;
|
||||||
|
app.show_help_panel = true;
|
||||||
|
app.help_panel_focus = true;
|
||||||
|
} else if command == .Open_File && message.user_read_bytes > APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE {
|
||||||
|
// Open in mcap, from a copy in view mode (app/open_file.jai): the path follows the header.
|
||||||
|
path := string.{message.user_read_bytes - APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE, message.user_read_pointer + APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE};
|
||||||
|
app.restore_window_this_frame = true;
|
||||||
|
if !app_exit_shutdown_started() open_file_request(path);
|
||||||
}
|
}
|
||||||
Shared_Channel.reader_done_with_message(*message);
|
Shared_Channel.reader_done_with_message(*message);
|
||||||
}
|
}
|
||||||
@ -420,13 +465,14 @@ app_frame :: (in_live_resize := false) -> bool {
|
|||||||
|
|
||||||
if ev.type.(SDL_EventType) == {
|
if ev.type.(SDL_EventType) == {
|
||||||
case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED;
|
case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED;
|
||||||
app.minimize_to_tray_this_frame = true;
|
if !app.view_mode app.minimize_to_tray_this_frame = true;
|
||||||
|
|
||||||
case SDL_EventType.QUIT;
|
case SDL_EventType.QUIT;
|
||||||
app.should_exit = true;
|
app.should_exit = true;
|
||||||
|
|
||||||
case SDL_EventType.WINDOW_CLOSE_REQUESTED;
|
case SDL_EventType.WINDOW_CLOSE_REQUESTED;
|
||||||
app.minimize_to_tray_this_frame = true;
|
if app.view_mode app.should_exit = true; // view mode has no tray
|
||||||
|
else app.minimize_to_tray_this_frame = true;
|
||||||
|
|
||||||
case;
|
case;
|
||||||
}
|
}
|
||||||
@ -466,6 +512,19 @@ app_frame :: (in_live_resize := false) -> bool {
|
|||||||
platform_restore_window();
|
platform_restore_window();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not from a live-resize frame: choosing the region runs a modal loop of its own.
|
||||||
|
if app.capture_region_this_frame && !in_live_resize {
|
||||||
|
app.capture_region_this_frame = false;
|
||||||
|
screen_capture_take();
|
||||||
|
app.capture_region_this_frame = false; // the hotkey pressed again while choosing
|
||||||
|
}
|
||||||
|
if app.record_region_this_frame && !in_live_resize {
|
||||||
|
app.record_region_this_frame = false;
|
||||||
|
screen_record_toggle();
|
||||||
|
app.record_region_this_frame = false;
|
||||||
|
app.capture_region_this_frame = false;
|
||||||
|
}
|
||||||
|
|
||||||
if app.should_exit {
|
if app.should_exit {
|
||||||
app_exit_shutdown_start();
|
app_exit_shutdown_start();
|
||||||
if app_exit_shutdown_poll() return false;
|
if app_exit_shutdown_poll() return false;
|
||||||
@ -521,10 +580,11 @@ app_frame :: (in_live_resize := false) -> bool {
|
|||||||
|
|
||||||
|
|
||||||
{ // #UI Start
|
{ // #UI Start
|
||||||
ImGui.DockSpaceOverViewport();
|
if !app.view_mode ImGui.DockSpaceOverViewport(); // view mode is one window filling the main one
|
||||||
|
|
||||||
app_debug_panel_ui();
|
app_debug_panel_ui();
|
||||||
app_frame_times_ui();
|
app_frame_times_ui();
|
||||||
|
app_help_panel_ui();
|
||||||
app_style_editor_ui();
|
app_style_editor_ui();
|
||||||
app_logger_panel_ui();
|
app_logger_panel_ui();
|
||||||
|
|
||||||
@ -673,8 +733,21 @@ app_shared_memory_channel_key :: () -> string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether a running instance's channel can be connected to; nothing is sent.
|
||||||
|
app_running_instance_reachable :: (channel_key: string) -> bool {
|
||||||
|
connected, channel := Shared_Channel.writer_connect(channel_key, name_not_found_is_quiet=true);
|
||||||
|
if connected Shared_Channel.writer_disconnect(*channel);
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
|
|
||||||
// Returns true once connected to a running instance, whether or not the message got through.
|
// Returns true once connected to a running instance, whether or not the message got through.
|
||||||
app_bring_running_instance_to_front :: (channel_key: string, attempts: int) -> bool {
|
app_bring_running_instance_to_front :: (channel_key: string, attempts: int) -> bool {
|
||||||
|
return app_send_to_running_instance(channel_key, attempts, .Bring_To_Front);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends tag, and payload after the header, to the running instance. True once connected to it,
|
||||||
|
// whether or not the message got through.
|
||||||
|
app_send_to_running_instance :: (channel_key: string, attempts: int, tag: App_Shared_Memory_Tag, payload := "") -> bool {
|
||||||
connected := false;
|
connected := false;
|
||||||
for 1..attempts {
|
for 1..attempts {
|
||||||
connected, app.write_channel = Shared_Channel.writer_connect(channel_key, name_not_found_is_quiet=true);
|
connected, app.write_channel = Shared_Channel.writer_connect(channel_key, name_not_found_is_quiet=true);
|
||||||
@ -685,18 +758,19 @@ app_bring_running_instance_to_front :: (channel_key: string, attempts: int) -> b
|
|||||||
defer Shared_Channel.writer_disconnect(*app.write_channel);
|
defer Shared_Channel.writer_disconnect(*app.write_channel);
|
||||||
|
|
||||||
// Reserving fails when the queue is full, e.g. the running instance has stopped polling.
|
// Reserving fails when the queue is full, e.g. the running instance has stopped polling.
|
||||||
message_success, message := Shared_Channel.writer_reserve_message(*app.write_channel, APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE);
|
message_success, message := Shared_Channel.writer_reserve_message(*app.write_channel, APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE + payload.count);
|
||||||
if !message_success {
|
if !message_success {
|
||||||
log_error("Another instance is running, but no space could be reserved in its message queue; it was not brought to the front.");
|
log_error("Another instance is running, but no space could be reserved in its message queue; % was not sent.", tag);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
message.user_write_pointer[0] = cast(u8)App_Shared_Memory_Tag.Bring_To_Front;
|
message.user_write_pointer[0] = cast(u8) tag;
|
||||||
message.user_write_pointer[1] = 0;
|
message.user_write_pointer[1] = 0;
|
||||||
|
if payload.count memcpy(message.user_write_pointer + APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE, payload.data, payload.count);
|
||||||
|
|
||||||
#if USING_WINDOWS AllowSetForegroundWindow(ASFW_ANY);
|
#if USING_WINDOWS AllowSetForegroundWindow(ASFW_ANY);
|
||||||
|
|
||||||
if !Shared_Channel.writer_commit_message(*app.write_channel, message) {
|
if !Shared_Channel.writer_commit_message(*app.write_channel, message) {
|
||||||
log_error("Another instance is running, but the bring-to-front message could not be committed.");
|
log_error("Another instance is running, but the % message could not be committed.", tag);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,15 @@
|
|||||||
// Making the engines (OCR, then labels), the label list's vectors, reading one capture and the scan
|
// Making the engines (OCR, then labels), the label list's vectors, reading one capture and the scan
|
||||||
// all run on one background thread, one at a time; the UI only looks at what it has finished, and
|
// all run on one background thread, one at a time; the UI only looks at what it has finished, and
|
||||||
// at the scan's progress counters.
|
// at the scan's progress counters.
|
||||||
|
//
|
||||||
|
// No engine is made at startup: each is made when something first needs it (ocr_ui_models_ready),
|
||||||
|
// and destroyed again once it has gone MODELS_IDLE_UNLOAD_SECONDS unused (ocr_ui_unload_idle_models).
|
||||||
|
|
||||||
OCR_WINDOW :: "OCR";
|
OCR_WINDOW :: "OCR";
|
||||||
OCR_DEFAULT_MODEL :: "ppocr-v5-mobile";
|
OCR_DEFAULT_MODEL :: "ppocr-v5-mobile";
|
||||||
OCR_SCAN_BATCH :: 8; // captures per ocr_process call in a scan
|
OCR_SCAN_BATCH :: 8; // captures per ocr_process call in a scan
|
||||||
OCR_STORE_ATTEMPTS :: 4; // a file the viewer has open at that moment is tried again, 250 ms apart
|
OCR_STORE_ATTEMPTS :: 4; // a file the viewer has open at that moment is tried again, 250 ms apart
|
||||||
|
MODELS_IDLE_UNLOAD_SECONDS :: 10 * 60; // an engine unused this long is destroyed, giving its memory back
|
||||||
|
|
||||||
ocr_ui: struct {
|
ocr_ui: struct {
|
||||||
models: [] Ocr_Model_Info;
|
models: [] Ocr_Model_Info;
|
||||||
@ -24,7 +28,8 @@ ocr_ui: struct {
|
|||||||
// Owned by the main thread while thread_busy is false; by the background thread while it is true.
|
// Owned by the main thread while thread_busy is false; by the background thread while it is true.
|
||||||
engine: *Ocr_Engine;
|
engine: *Ocr_Engine;
|
||||||
engine_error: string; // why the last engine could not be made
|
engine_error: string; // why the last engine could not be made
|
||||||
engine_tried: bool; // the first engine has been made, or failed: labels start after it
|
engine_tried: bool; // the engine has been made, or failed; cleared when it is unloaded
|
||||||
|
last_used: Apollo_Time; // main thread: when a task last used the engine
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
thread_started: bool;
|
thread_started: bool;
|
||||||
thread_busy: bool; // set by the main thread, cleared by the background one
|
thread_busy: bool; // set by the main thread, cleared by the background one
|
||||||
@ -33,6 +38,10 @@ ocr_ui: struct {
|
|||||||
read_error: string; // why the last single read failed
|
read_error: string; // why the last single read failed
|
||||||
read_index: s32 = -1;
|
read_index: s32 = -1;
|
||||||
read_seconds: float64;
|
read_seconds: float64;
|
||||||
|
read_wanted: bool; // the OCR button was pressed: it reads once the models are up
|
||||||
|
read_wanted_index: s32;
|
||||||
|
read_wanted_path: string; // heap
|
||||||
|
scan_wanted: bool; // the scan button was pressed: it scans once the models are up
|
||||||
|
|
||||||
scan: Ocr_Scan;
|
scan: Ocr_Scan;
|
||||||
scan_requested: bool; // -scan has started its scan
|
scan_requested: bool; // -scan has started its scan
|
||||||
@ -44,12 +53,13 @@ ocr_ui: struct {
|
|||||||
show_boxes := true;
|
show_boxes := true;
|
||||||
match_cursor: s64 = -1; // which match Next and Previous last went to
|
match_cursor: s64 = -1; // which match Next and Previous last went to
|
||||||
scroll_to_match: bool;
|
scroll_to_match: bool;
|
||||||
|
menu_text: string; // heap: the line right-clicked, for its menu's Copy
|
||||||
|
|
||||||
window_open := false;
|
window_open := false;
|
||||||
toast_shown := false;
|
toast_shown := false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ocr_Task_Kind :: enum { NONE; CREATE; READ; SCAN; LABELS_CREATE; LABEL_SET; ERASE; SEGMENT; }
|
Ocr_Task_Kind :: enum { NONE; CREATE; READ; SCAN; LABELS_CREATE; LABEL_SET; ERASE; SEGMENT; UNLOAD; }
|
||||||
|
|
||||||
Ocr_Task :: struct {
|
Ocr_Task :: struct {
|
||||||
kind: Ocr_Task_Kind;
|
kind: Ocr_Task_Kind;
|
||||||
@ -59,6 +69,10 @@ Ocr_Task :: struct {
|
|||||||
// LABELS_CREATE and LABEL_SET: the label list as it was when the task started (heap).
|
// LABELS_CREATE and LABEL_SET: the label list as it was when the task started (heap).
|
||||||
label_names, label_prompts: [] string;
|
label_names, label_prompts: [] string;
|
||||||
label_hash: string;
|
label_hash: string;
|
||||||
|
// UNLOAD: the engines to destroy, already taken out of ocr_ui, labels_ui and segment_ui.
|
||||||
|
unload_ocr: *Ocr_Engine;
|
||||||
|
unload_labels: *Labels_Engine;
|
||||||
|
unload_segment: *Segment_Engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The library scan. The counters and flags are shared with the background thread.
|
// The library scan. The counters and flags are shared with the background thread.
|
||||||
@ -92,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
|
||||||
@ -103,7 +117,7 @@ ocr_ui_startup :: () {
|
|||||||
ocr_ui.models = models;
|
ocr_ui.models = models;
|
||||||
if error ocr_ui.models_error = copy_string(error);
|
if error ocr_ui.models_error = copy_string(error);
|
||||||
ocr_ui.model_index = ocr_model_index(app_user_settings.ocr_model);
|
ocr_ui.model_index = ocr_model_index(app_user_settings.ocr_model);
|
||||||
if ocr_ui.models.count ocr_ui_start(.{kind = .CREATE, model = ocr_ui.models[ocr_ui.model_index].name});
|
// The engine is made when something first needs it: ocr_ui_models_ready.
|
||||||
}
|
}
|
||||||
|
|
||||||
ocr_ui_shutdown :: () {
|
ocr_ui_shutdown :: () {
|
||||||
@ -111,6 +125,7 @@ ocr_ui_shutdown :: () {
|
|||||||
atomic_swap(*ocr_ui.scan.paused, false);
|
atomic_swap(*ocr_ui.scan.paused, false);
|
||||||
if ocr_ui.thread_started thread_is_done(*ocr_ui.thread, -1);
|
if ocr_ui.thread_started thread_is_done(*ocr_ui.thread, -1);
|
||||||
ocr_destroy(ocr_ui.engine);
|
ocr_destroy(ocr_ui.engine);
|
||||||
|
free(ocr_ui.read_wanted_path,, context.default_allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The model the settings name, or the default one, or the first; with a log line when the settings
|
// The model the settings name, or the default one, or the first; with a log line when the settings
|
||||||
@ -135,7 +150,13 @@ ocr_ui_choose_model :: (index: s32, save := true) {
|
|||||||
if ocr_ui.thread_busy return;
|
if ocr_ui.thread_busy return;
|
||||||
ocr_ui.model_index = index;
|
ocr_ui.model_index = index;
|
||||||
ocr_ui.toast_shown = true; // said once already, or not needed
|
ocr_ui.toast_shown = true; // said once already, or not needed
|
||||||
ocr_ui_start(.{kind = .CREATE, model = ocr_ui.models[index].name});
|
if ocr_ui.engine {
|
||||||
|
ocr_ui_start(.{kind = .CREATE, model = ocr_ui.models[index].name});
|
||||||
|
} else { // not loaded, or failed: the next use makes the one chosen
|
||||||
|
ocr_ui.engine_tried = false;
|
||||||
|
free(ocr_ui.engine_error,, context.default_allocator);
|
||||||
|
ocr_ui.engine_error = "";
|
||||||
|
}
|
||||||
if save {
|
if save {
|
||||||
free(app_user_settings.ocr_model,, context.default_allocator);
|
free(app_user_settings.ocr_model,, context.default_allocator);
|
||||||
app_user_settings.ocr_model = copy_string(ocr_ui.models[index].name,, context.default_allocator);
|
app_user_settings.ocr_model = copy_string(ocr_ui.models[index].name,, context.default_allocator);
|
||||||
@ -160,6 +181,7 @@ ocr_ui_start :: (task: Ocr_Task) -> bool {
|
|||||||
}
|
}
|
||||||
ocr_ui.task.label_hash = copy_string(labels_ui.list_hash);
|
ocr_ui.task.label_hash = copy_string(labels_ui.list_hash);
|
||||||
}
|
}
|
||||||
|
ocr_ui_note_use(task.kind, current_time_monotonic());
|
||||||
ocr_ui.thread_busy = true;
|
ocr_ui.thread_busy = true;
|
||||||
if ocr_ui.thread_started thread_deinit(*ocr_ui.thread);
|
if ocr_ui.thread_started thread_deinit(*ocr_ui.thread);
|
||||||
ocr_ui.thread_started = thread_init(*ocr_ui.thread, ocr_ui_thread_proc, temporary_storage_size = 4 * 1024 * 1024, name = "ocr");
|
ocr_ui.thread_started = thread_init(*ocr_ui.thread, ocr_ui_thread_proc, temporary_storage_size = 4 * 1024 * 1024, name = "ocr");
|
||||||
@ -168,6 +190,86 @@ ocr_ui_start :: (task: Ocr_Task) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the models: made when needed, unloaded when idle
|
||||||
|
|
||||||
|
// Whether the OCR engine (and, with labels, the label engine and the list's set) are up, or could
|
||||||
|
// not be made, so a reading or a scan can start. When not, starts making the next of them on the OCR
|
||||||
|
// thread, if it is free. Called each frame by what waits on them. After it returns true, a null
|
||||||
|
// ocr_ui.engine means OCR could not start.
|
||||||
|
ocr_ui_models_ready :: (labels := true) -> bool {
|
||||||
|
if ocr_ui.thread_busy return false;
|
||||||
|
if !ocr_ui.engine_tried {
|
||||||
|
if !ocr_ui.models.count return true; // ocr_ui.models_error says why
|
||||||
|
ocr_ui_start(.{kind = .CREATE, model = ocr_ui.models[ocr_ui.model_index].name});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !labels return true;
|
||||||
|
if !labels_ui.engine_tried {
|
||||||
|
ocr_ui_start(.{kind = .LABELS_CREATE});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if labels_ui.engine && labels_ui.list.count && (!labels_ui.set || labels_ui.set.hash != labels_ui.list_hash) {
|
||||||
|
ocr_ui_start(.{kind = .LABEL_SET});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each frame, also while the window is in the tray: notes which engines the OCR thread's task uses,
|
||||||
|
// and destroys those unused for MODELS_IDLE_UNLOAD_SECONDS, on the OCR thread.
|
||||||
|
ocr_ui_unload_idle_models :: () {
|
||||||
|
if app_exit_shutdown_started() return; // the shutdown thread destroys them
|
||||||
|
now := current_time_monotonic();
|
||||||
|
if ocr_ui.thread_busy { ocr_ui_note_use(ocr_ui.task.kind, now); return; }
|
||||||
|
if ocr_ui.scan.running || ocr_ui.read_wanted || ocr_ui.scan_wanted return;
|
||||||
|
|
||||||
|
idle :: (last_used: Apollo_Time, now: Apollo_Time) -> bool {
|
||||||
|
return to_float64_seconds(now - last_used) >= MODELS_IDLE_UNLOAD_SECONDS;
|
||||||
|
}
|
||||||
|
task := Ocr_Task.{kind = .UNLOAD};
|
||||||
|
if ocr_ui.engine && idle(ocr_ui.last_used, now) {
|
||||||
|
task.unload_ocr = ocr_ui.engine;
|
||||||
|
ocr_ui.engine = null;
|
||||||
|
ocr_ui.engine_tried = false;
|
||||||
|
}
|
||||||
|
// A like: search's jobs hold the label engine until they are back.
|
||||||
|
if labels_ui.engine && idle(labels_ui.last_used, now) && !background.jobs_out {
|
||||||
|
task.unload_labels = labels_ui.engine;
|
||||||
|
labels_ui.engine = null;
|
||||||
|
labels_ui.engine_tried = false;
|
||||||
|
labels_ui.search_ready = false; // the list's set stays: the same model makes the same vectors
|
||||||
|
}
|
||||||
|
if segment_ui.engine && idle(segment_ui.last_used, now) {
|
||||||
|
task.unload_segment = segment_ui.engine;
|
||||||
|
segment_ui.engine = null;
|
||||||
|
}
|
||||||
|
if !task.unload_ocr && !task.unload_labels && !task.unload_segment return;
|
||||||
|
|
||||||
|
names: [..] string;
|
||||||
|
names.allocator = temp;
|
||||||
|
if task.unload_ocr array_add(*names, task.unload_ocr.model.name);
|
||||||
|
if task.unload_labels array_add(*names, LABELS_MODEL);
|
||||||
|
if task.unload_segment array_add(*names, SEGMENT_MODEL);
|
||||||
|
log("[Models] Unloading % after % minutes unused.", join(..names, separator = ", ",, temp), MODELS_IDLE_UNLOAD_SECONDS / 60);
|
||||||
|
if !ocr_ui_start(task) { // the thread could not start: destroy them here instead
|
||||||
|
ocr_destroy(task.unload_ocr);
|
||||||
|
labels_destroy(task.unload_labels);
|
||||||
|
segment_destroy(task.unload_segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engines a task of this kind uses, as used at now: when it starts, and each frame it runs.
|
||||||
|
ocr_ui_note_use :: (kind: Ocr_Task_Kind, now: Apollo_Time) {
|
||||||
|
if kind == {
|
||||||
|
case .CREATE; ocr_ui.last_used = now;
|
||||||
|
case .LABELS_CREATE; labels_ui.last_used = now;
|
||||||
|
case .LABEL_SET; labels_ui.last_used = now;
|
||||||
|
case .READ; ocr_ui.last_used = now; labels_ui.last_used = now;
|
||||||
|
case .SCAN; ocr_ui.last_used = now; if ocr_ui.scan.label_set labels_ui.last_used = now;
|
||||||
|
case .SEGMENT; segment_ui.last_used = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- the background thread
|
// ---------------------------------------------------------------- the background thread
|
||||||
|
|
||||||
ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
|
ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
|
||||||
@ -181,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;
|
||||||
|
|
||||||
@ -229,6 +331,11 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
|
|||||||
case .SEGMENT;
|
case .SEGMENT;
|
||||||
segment_ui_scan_task(); // app/segment_ui.jai
|
segment_ui_scan_task(); // app/segment_ui.jai
|
||||||
|
|
||||||
|
case .UNLOAD;
|
||||||
|
ocr_destroy(task.unload_ocr);
|
||||||
|
labels_destroy(task.unload_labels);
|
||||||
|
segment_destroy(task.unload_segment);
|
||||||
|
|
||||||
case .ERASE;
|
case .ERASE;
|
||||||
scan := *ocr_ui.scan;
|
scan := *ocr_ui.scan;
|
||||||
// Not in temporary storage, which is reset after each capture.
|
// Not in temporary storage, which is reset after each capture.
|
||||||
@ -256,7 +363,7 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
|
|||||||
entry, load_error := catalog_load(item.path);
|
entry, load_error := catalog_load(item.path);
|
||||||
if entry {
|
if entry {
|
||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
array_add(*viewer.catalog_updates, .{item.index, entry});
|
array_add(*viewer.catalog_updates, .{item.index, entry, false});
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
} else {
|
} else {
|
||||||
log_error("[Erase] % was erased, but could not be read back: %", item.path, load_error);
|
log_error("[Erase] % was erased, but could not be read back: %", item.path, load_error);
|
||||||
@ -339,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;
|
||||||
@ -356,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;
|
||||||
@ -402,7 +509,7 @@ ocr_read_and_store :: (items: [] Scan_Item, set: *Label_Set) -> [] string {
|
|||||||
entry, load_error := catalog_load(items[n].path);
|
entry, load_error := catalog_load(items[n].path);
|
||||||
if !entry { errors[n] = sprint("stored, but could not be read back: %", load_error); continue; }
|
if !entry { errors[n] = sprint("stored, but could not be read back: %", load_error); continue; }
|
||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
array_add(*viewer.catalog_updates, .{items[n].index, entry});
|
array_add(*viewer.catalog_updates, .{items[n].index, entry, false});
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
@ -512,7 +619,8 @@ ocr_ui_scan_and_exit :: (busy: bool) {
|
|||||||
if ocr_ui.models_error { finish(1, "[OCR scan] %", ocr_ui.models_error); return; }
|
if ocr_ui.models_error { finish(1, "[OCR scan] %", ocr_ui.models_error); return; }
|
||||||
if viewer.scan_error { finish(1, "[OCR scan] %", viewer.scan_error); return; }
|
if viewer.scan_error { finish(1, "[OCR scan] %", viewer.scan_error); return; }
|
||||||
if !ocr_ui.scan_requested {
|
if !ocr_ui.scan_requested {
|
||||||
if busy || !ocr_ui.engine || viewer.catalog_loaded < viewer.captures.count || !labels_ui_settled() return;
|
if busy || viewer.catalog_loaded < viewer.captures.count || !ocr_ui_models_ready(labels_ui.scan_labels) return;
|
||||||
|
if !ocr_ui.engine { finish(1, "[OCR scan] OCR could not start: %", ocr_ui.engine_error); return; }
|
||||||
ocr_ui.scan_requested = true;
|
ocr_ui.scan_requested = true;
|
||||||
ocr_ui.window_open = true; // its progress bar
|
ocr_ui.window_open = true; // its progress bar
|
||||||
ocr_scan_start();
|
ocr_scan_start();
|
||||||
@ -570,17 +678,18 @@ ocr_scan_ui :: () { using ImGui;
|
|||||||
catalog_ready := viewer.catalog_loaded >= viewer.captures.count;
|
catalog_ready := viewer.catalog_loaded >= viewer.captures.count;
|
||||||
|
|
||||||
if !scan.running {
|
if !scan.running {
|
||||||
can_scan := ocr_ui.engine && !busy && catalog_ready;
|
can_scan := !busy && !ocr_ui.scan_wanted && catalog_ready;
|
||||||
if !can_scan BeginDisabled();
|
if !can_scan BeginDisabled();
|
||||||
label := ifx viewer.search_terms.count then "Scan the captures shown" else "Scan the library";
|
label := ifx viewer.search_terms.count then "Scan the captures shown" else "Scan the library";
|
||||||
if Button(temp_c_string(label)) ocr_scan_start();
|
if Button(temp_c_string(label)) ocr_ui.scan_wanted = true; // it starts once the models are up: ocr_ui_frame
|
||||||
if !can_scan EndDisabled();
|
if !can_scan EndDisabled();
|
||||||
SameLine();
|
SameLine();
|
||||||
labels_ready := labels_ui.engine && labels_ui.set && labels_ui.set.hash == labels_ui.list_hash;
|
labels_failed := labels_ui.engine_tried && !labels_ui.engine;
|
||||||
if !labels_ready BeginDisabled();
|
if labels_failed BeginDisabled();
|
||||||
Checkbox("Labels too", *labels_ui.scan_labels);
|
Checkbox("Labels too", *labels_ui.scan_labels);
|
||||||
if !labels_ready EndDisabled();
|
if labels_failed EndDisabled();
|
||||||
if IsItemHovered(.AllowWhenDisabled) SetTooltip(ifx labels_ready then tprint("Also labels each capture with %, storing its labels and image vector", LABELS_MODEL) else "Labelling is not ready: see the Labels window");
|
if IsItemHovered(.AllowWhenDisabled) SetTooltip(ifx labels_failed then "Labelling could not start: see the Labels window" else tprint("Also labels each capture with %, storing its labels and image vector", LABELS_MODEL));
|
||||||
|
if ocr_ui.scan_wanted TextColored(app_theme.palette.dim, "Loading the models...");
|
||||||
if !catalog_ready TextColored(app_theme.palette.dim, "(once the catalog has loaded)");
|
if !catalog_ready TextColored(app_theme.palette.dim, "(once the catalog has loaded)");
|
||||||
else TextColored(app_theme.palette.dim, "Reads every capture this model has not read, and labels those not yet labelled with this list. Each is stored as it is read, so a scan can stop and start again.");
|
else TextColored(app_theme.palette.dim, "Reads every capture this model has not read, and labels those not yet labelled with this list. Each is stored as it is read, so a scan can stop and start again.");
|
||||||
if scan.total && scan.done {
|
if scan.total && scan.done {
|
||||||
@ -606,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 {
|
||||||
@ -652,6 +761,17 @@ ocr_ui_frame :: () { using ImGui;
|
|||||||
if app.scan_and_exit ocr_ui_scan_and_exit(busy);
|
if app.scan_and_exit ocr_ui_scan_and_exit(busy);
|
||||||
if app.erase_and_exit ocr_ui_erase_and_exit(busy);
|
if app.erase_and_exit ocr_ui_erase_and_exit(busy);
|
||||||
ocr_erase_popup();
|
ocr_erase_popup();
|
||||||
|
// The OCR and scan buttons wait for the models they load.
|
||||||
|
if ocr_ui.read_wanted && ocr_ui_models_ready() {
|
||||||
|
ocr_ui.read_wanted = false;
|
||||||
|
ocr_ui_start(.{kind = .READ, path = ocr_ui.read_wanted_path, index = ocr_ui.read_wanted_index});
|
||||||
|
busy = ocr_ui.thread_busy;
|
||||||
|
}
|
||||||
|
if ocr_ui.scan_wanted && ocr_ui_models_ready(labels_ui.scan_labels) {
|
||||||
|
ocr_ui.scan_wanted = false;
|
||||||
|
if ocr_ui.engine ocr_scan_start();
|
||||||
|
busy = ocr_ui.thread_busy;
|
||||||
|
}
|
||||||
if !busy && ocr_ui.engine && !ocr_ui.toast_shown {
|
if !busy && ocr_ui.engine && !ocr_ui.toast_shown {
|
||||||
ocr_ui.toast_shown = true;
|
ocr_ui.toast_shown = true;
|
||||||
in_use, explanation := ocr_gpu_status(ocr_ui.engine);
|
in_use, explanation := ocr_gpu_status(ocr_ui.engine);
|
||||||
@ -685,9 +805,13 @@ ocr_ui_frame :: () { using ImGui;
|
|||||||
} else if ocr_ui.engine_error {
|
} else if ocr_ui.engine_error {
|
||||||
TextColored(app_theme.palette.warning, "OCR could not start:");
|
TextColored(app_theme.palette.warning, "OCR could not start:");
|
||||||
TextWrapped("%", ocr_ui.engine_error);
|
TextWrapped("%", ocr_ui.engine_error);
|
||||||
} else if ocr_ui.engine {
|
} else if !ocr_ui.engine {
|
||||||
|
TextColored(app_theme.palette.dim, "Not loaded: it loads when a capture is read or a scan starts, and unloads after % minutes unused.", MODELS_IDLE_UNLOAD_SECONDS / 60);
|
||||||
|
Separator();
|
||||||
|
ocr_scan_ui();
|
||||||
|
} 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();
|
||||||
@ -700,6 +824,8 @@ ocr_ui_frame :: () { using ImGui;
|
|||||||
if section && viewer.full_index >= 0 {
|
if section && viewer.full_index >= 0 {
|
||||||
Separator();
|
Separator();
|
||||||
Text("Text of % (% , % lines):", viewer.captures[viewer.full_index].name, section.model, section.lines.count);
|
Text("Text of % (% , % lines):", viewer.captures[viewer.full_index].name, section.model, section.lines.count);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Copy all text") ocr_copy_all_text(section);
|
||||||
BeginChild("ocr text");
|
BeginChild("ocr text");
|
||||||
for section.lines TextUnformatted(it.text);
|
for section.lines TextUnformatted(it.text);
|
||||||
EndChild();
|
EndChild();
|
||||||
@ -726,6 +852,17 @@ text_with_links :: (text: string) { using ImGui;
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- the Capture window
|
// ---------------------------------------------------------------- the Capture window
|
||||||
|
|
||||||
|
// Puts the reading on the clipboard, a line of text to each of its lines.
|
||||||
|
ocr_copy_all_text :: (section: *Mcap_Ocr_Section) {
|
||||||
|
builder: String_Builder;
|
||||||
|
builder.allocator = temp;
|
||||||
|
for section.lines {
|
||||||
|
if it_index append(*builder, "\n");
|
||||||
|
append(*builder, it.text);
|
||||||
|
}
|
||||||
|
clipboard_set_text(builder_to_string(*builder,, temp));
|
||||||
|
}
|
||||||
|
|
||||||
// The reading shown over the capture in the Capture window: the overlay's choice, else the chosen
|
// The reading shown over the capture in the Capture window: the overlay's choice, else the chosen
|
||||||
// model's, else the first there is.
|
// model's, else the first there is.
|
||||||
ocr_overlay_section :: () -> *Mcap_Ocr_Section {
|
ocr_overlay_section :: () -> *Mcap_Ocr_Section {
|
||||||
@ -750,12 +887,21 @@ ocr_ui_capture_controls :: (index: s32, path: string) { using ImGui;
|
|||||||
model := ifx ocr_ui.models.count then ocr_ui.models[ocr_ui.model_index].name else "";
|
model := ifx ocr_ui.models.count then ocr_ui.models[ocr_ui.model_index].name else "";
|
||||||
has_reading := entry && catalog_section(entry, model) != null;
|
has_reading := entry && catalog_section(entry, model) != null;
|
||||||
|
|
||||||
can_read := ocr_ui.engine && !busy;
|
ocr_failed := ocr_ui.engine_tried && !ocr_ui.engine;
|
||||||
|
can_read := ocr_ui.models.count && !busy && !ocr_ui.read_wanted && !ocr_failed;
|
||||||
if !can_read BeginDisabled();
|
if !can_read BeginDisabled();
|
||||||
if SmallButton(temp_c_string(ifx has_reading then "Read again" else "OCR")) ocr_ui_start(.{kind = .READ, path = path, index = index});
|
if SmallButton(temp_c_string(ifx has_reading then "Read again" else "OCR")) { // it reads once the models are up: ocr_ui_frame
|
||||||
|
ocr_ui.read_wanted = true;
|
||||||
|
ocr_ui.read_wanted_index = index;
|
||||||
|
free(ocr_ui.read_wanted_path,, context.default_allocator);
|
||||||
|
ocr_ui.read_wanted_path = copy_string(path,, context.default_allocator);
|
||||||
|
}
|
||||||
if !can_read EndDisabled();
|
if !can_read EndDisabled();
|
||||||
SameLine();
|
SameLine();
|
||||||
if busy && ocr_ui.task.kind == .READ && ocr_ui.task.index == index {
|
if ocr_ui.read_wanted && ocr_ui.read_wanted_index == index {
|
||||||
|
TextColored(app_theme.palette.dim, "Loading the models...");
|
||||||
|
SameLine();
|
||||||
|
} else if busy && ocr_ui.task.kind == .READ && ocr_ui.task.index == index {
|
||||||
TextColored(app_theme.palette.dim, "Reading...");
|
TextColored(app_theme.palette.dim, "Reading...");
|
||||||
SameLine();
|
SameLine();
|
||||||
} else if !busy && ocr_ui.read_index == index {
|
} else if !busy && ocr_ui.read_index == index {
|
||||||
@ -778,6 +924,8 @@ ocr_ui_capture_controls :: (index: s32, path: string) { using ImGui;
|
|||||||
SameLine();
|
SameLine();
|
||||||
}
|
}
|
||||||
Checkbox("Boxes", *ocr_ui.show_boxes);
|
Checkbox("Boxes", *ocr_ui.show_boxes);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Copy all text") ocr_copy_all_text(section);
|
||||||
if viewer.search_terms.count {
|
if viewer.search_terms.count {
|
||||||
matches := 0;
|
matches := 0;
|
||||||
for section.lines if search_line_matches(it.text, viewer.search_terms) matches += 1;
|
for section.lines if search_line_matches(it.text, viewer.search_terms) matches += 1;
|
||||||
@ -789,8 +937,8 @@ ocr_ui_capture_controls :: (index: s32, path: string) { using ImGui;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SameLine();
|
SameLine();
|
||||||
} else if !ocr_ui.engine {
|
} else if ocr_failed {
|
||||||
TextColored(app_theme.palette.dim, "(OCR is not running; see the OCR window)");
|
TextColored(app_theme.palette.dim, "(OCR could not start; see the OCR window)");
|
||||||
SameLine();
|
SameLine();
|
||||||
}
|
}
|
||||||
if SmallButton("OCR window") ocr_ui.window_open = true;
|
if SmallButton("OCR window") ocr_ui.window_open = true;
|
||||||
@ -811,6 +959,7 @@ ocr_ui_capture_overlay :: (index: s32, image_min: ImGui.ImVec2, zoom: float) { u
|
|||||||
plain := GetColorU32(.PlotHistogram, ifx searching then 0.35 else 1.0);
|
plain := GetColorU32(.PlotHistogram, ifx searching then 0.35 else 1.0);
|
||||||
matching := GetColorU32(.PlotLinesHovered);
|
matching := GetColorU32(.PlotLinesHovered);
|
||||||
match_number := 0;
|
match_number := 0;
|
||||||
|
clicked: string; // the line right-clicked this frame; the last drawn, which is on top, where they overlap
|
||||||
for section.lines {
|
for section.lines {
|
||||||
p0 := ImVec2.{image_min.x + it.x * scale_x, image_min.y + it.y * scale_y};
|
p0 := ImVec2.{image_min.x + it.x * scale_x, image_min.y + it.y * scale_y};
|
||||||
p1 := ImVec2.{p0.x + it.width * scale_x, p0.y + it.height * scale_y};
|
p1 := ImVec2.{p0.x + it.width * scale_x, p0.y + it.height * scale_y};
|
||||||
@ -831,6 +980,21 @@ ocr_ui_capture_overlay :: (index: s32, image_min: ImGui.ImVec2, zoom: float) { u
|
|||||||
} else {
|
} else {
|
||||||
draw.AddQuad(draw, q[0], q[1], q[2], q[3], plain, 1.5);
|
draw.AddQuad(draw, q[0], q[1], q[2], q[3], plain, 1.5);
|
||||||
}
|
}
|
||||||
if IsMouseHoveringRect(p0, p1) SetTooltip("% (%)", it.text, FF(it.confidence, 2));
|
if IsMouseHoveringRect(p0, p1) {
|
||||||
|
SetTooltip("% (%)", it.text, FF(it.confidence, 2));
|
||||||
|
if IsWindowHovered() && IsMouseClicked(.Right) clicked = it.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click a box for its menu. The text is kept, since the reading may change while the menu is up.
|
||||||
|
MENU :: "##ocr_line_menu";
|
||||||
|
if clicked {
|
||||||
|
free(ocr_ui.menu_text,, context.default_allocator);
|
||||||
|
ocr_ui.menu_text = copy_string(clicked,, context.default_allocator);
|
||||||
|
OpenPopup(MENU);
|
||||||
|
}
|
||||||
|
if BeginPopup(MENU) {
|
||||||
|
if MenuItem("Copy") clipboard_set_text(ocr_ui.menu_text);
|
||||||
|
EndPopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
593
src/app/open_file.jai
Normal file
593
src/app/open_file.jai
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
// A file named on the command line, as Explorer's Open with passes it (mcap.exe FILE): an .mcap, or
|
||||||
|
// an image, video or Snagit .snagx such as the Import window takes.
|
||||||
|
//
|
||||||
|
// With no other copy of mcap running, the app starts as usual and shows the file: an .mcap in the
|
||||||
|
// library is selected and opened in the Capture window; anything else opens in the Opened File
|
||||||
|
// window, which says whether the library has it already and, if not, imports it into the library
|
||||||
|
// folder chosen (app/import.jai, with the file as its source). Once in, it opens in the Capture window.
|
||||||
|
//
|
||||||
|
// With mcap already running, this copy starts in view mode (app.view_mode): the file alone, filling
|
||||||
|
// the window, with the same Import, and Open in mcap, which hands the file to the running copy
|
||||||
|
// (App_Shared_Memory_Tag.Open_File, open_file_request) and closes this one. Nothing else starts: the
|
||||||
|
// library is not listed or watched, no model loads, and there is no tray icon, no global hotkey, and
|
||||||
|
// no settings or window layout written. Closing the window, Escape or Ctrl-W ends it.
|
||||||
|
|
||||||
|
OPEN_FILE_WINDOW :: "Opened File";
|
||||||
|
|
||||||
|
Open_File_Kind :: enum u8 { UNKNOWN; MCAP; SNAGX; IMAGE; VIDEO; }
|
||||||
|
|
||||||
|
Open_File_Stage :: enum s32 {
|
||||||
|
IDLE; // nothing opened, or it went to the Capture window
|
||||||
|
DECODING; // the thread is reading the image
|
||||||
|
DECODED; // the image is the main thread's; the thread is looking for the file in the library
|
||||||
|
DONE; // the thread has ended
|
||||||
|
}
|
||||||
|
|
||||||
|
open_file: struct {
|
||||||
|
path: string; // heap; absolute, with / between its folders
|
||||||
|
kind: Open_File_Kind;
|
||||||
|
size: s64;
|
||||||
|
library_path: string; // heap: the capture in the library this file is (an .mcap in it); else ""
|
||||||
|
window_open: bool; // the Opened File window; in view mode the file is the whole window
|
||||||
|
focus: bool; // bring the window forward next frame
|
||||||
|
|
||||||
|
// Read on a thread of its own: a camera RAW, or a video's frame, takes a while.
|
||||||
|
thread: Thread;
|
||||||
|
thread_started: bool;
|
||||||
|
stage: Open_File_Stage; // written by the thread
|
||||||
|
decoded: Decoded; // the thread's until DECODED; then the main thread's, kept for Ctrl-C
|
||||||
|
details: string; // heap, as decoded: "JPEG, 4032 x 3024", "Video, 0:42, h264"
|
||||||
|
error: string; // heap, as decoded: why it cannot be shown
|
||||||
|
match: string; // heap, the thread's until DONE: where the library has it already, inside it
|
||||||
|
uploaded: bool; // texture made from decoded, or tried
|
||||||
|
texture: Gpu_Texture;
|
||||||
|
texture_generation: u64;
|
||||||
|
fit := Viewer_Fit.WINDOW;
|
||||||
|
zoom: float = 100;
|
||||||
|
|
||||||
|
// Importing it (app/import.jai).
|
||||||
|
folders: [..] string; // the library's folders, for the menu beside the name (heap)
|
||||||
|
dest_buffer: [256] u8;
|
||||||
|
importing: bool; // the importer's run is this file's
|
||||||
|
result: string; // heap: what the import did
|
||||||
|
result_ok: bool;
|
||||||
|
imported_path: string; // heap: where the library has it after the import (or had it already)
|
||||||
|
flag_started: bool; // -open-import has pressed Import
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- startup and shutdown
|
||||||
|
|
||||||
|
// A command-line argument as a path: absolute, with / between its folders (heap); "" for a folder.
|
||||||
|
open_file_normalize :: (argument: string) -> string {
|
||||||
|
path := trim(argument);
|
||||||
|
absolute, ok := get_absolute_path(path,, temp);
|
||||||
|
if ok path = absolute;
|
||||||
|
if is_directory(path) return "";
|
||||||
|
result := copy_string(path,, context.default_allocator);
|
||||||
|
path_overwrite_separators(result, #char "/");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
open_file_startup :: () {
|
||||||
|
open_file.folders.allocator = context.default_allocator;
|
||||||
|
// No OCR in view mode: the running copy reads it, once shown there and read.
|
||||||
|
if app.view_mode importer.read_after = false;
|
||||||
|
if app.open_file open_file_set(app.open_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the shutdown thread: the frames have stopped running the UI.
|
||||||
|
open_file_shutdown :: () {
|
||||||
|
if open_file.thread_started thread_is_done(*open_file.thread, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the running copy's channel (main.jai): a copy in view mode handed it path.
|
||||||
|
open_file_request :: (path: string) {
|
||||||
|
if open_file.importing { toast_show(.Error, "Still importing %", path_filename(open_file.path)); return; }
|
||||||
|
log("[open] % was handed over by a copy in view mode.", path);
|
||||||
|
open_file_set(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- opening
|
||||||
|
|
||||||
|
// Shows path: in the Capture window when it is a capture in the library (and the library is shown),
|
||||||
|
// else in the Opened File window, or in view mode the whole window, read on a thread.
|
||||||
|
open_file_set :: (path: string) {
|
||||||
|
open_file_stop();
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
free(open_file.path); free(open_file.library_path); free(open_file.result); free(open_file.imported_path);
|
||||||
|
open_file.path = copy_string(path);
|
||||||
|
open_file.library_path = copy_string(open_file_in_library(path));
|
||||||
|
open_file.kind = open_file_kind(path);
|
||||||
|
open_file.result = "";
|
||||||
|
open_file.result_ok = false;
|
||||||
|
open_file.imported_path = "";
|
||||||
|
open_file.fit = .WINDOW;
|
||||||
|
_, size, _ := file_modtime_and_size(path);
|
||||||
|
open_file.size = size;
|
||||||
|
log("[open] Opened % (%, % bytes)%.", path, open_file.kind, size, ifx open_file.library_path then ", in the library" else "");
|
||||||
|
|
||||||
|
if !app.view_mode && open_file.library_path && open_file_show_in_library(open_file.library_path) return;
|
||||||
|
|
||||||
|
open_file.window_open = !app.view_mode;
|
||||||
|
open_file.focus = true;
|
||||||
|
// The library folder it would go into: the one its folder went into before, else its folder's name.
|
||||||
|
dest := import_dest_for(path_strip_filename(path));
|
||||||
|
count := min(dest.count, open_file.dest_buffer.count - 1);
|
||||||
|
memcpy(open_file.dest_buffer.data, dest.data, count);
|
||||||
|
open_file.dest_buffer[count] = 0;
|
||||||
|
open_file_list_folders();
|
||||||
|
|
||||||
|
open_file.stage = .DECODING;
|
||||||
|
open_file.thread_started = thread_init(*open_file.thread, open_file_proc, temporary_storage_size = 16 * 1024 * 1024, name = "open file");
|
||||||
|
if !open_file.thread_started {
|
||||||
|
open_file.error = copy_string("the thread to read it could not start");
|
||||||
|
open_file.stage = .DONE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread_start(*open_file.thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets go of the file shown: its thread (waited for), texture and pixels.
|
||||||
|
open_file_stop :: () {
|
||||||
|
if open_file.thread_started {
|
||||||
|
thread_is_done(*open_file.thread, -1);
|
||||||
|
thread_deinit(*open_file.thread);
|
||||||
|
open_file.thread_started = false;
|
||||||
|
}
|
||||||
|
gpu_texture_release(*open_file.texture);
|
||||||
|
viewer_free_pixels(*open_file.decoded);
|
||||||
|
open_file.decoded = .{};
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
free(open_file.details); free(open_file.error); free(open_file.match);
|
||||||
|
open_file.details, open_file.error, open_file.match = "", "", "";
|
||||||
|
open_file.uploaded = false;
|
||||||
|
open_file.stage = .IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capture at path in the Capture window, added to the grid if it is not there yet. Full mode only.
|
||||||
|
open_file_show_in_library :: (path: string) -> bool {
|
||||||
|
index := viewer_capture_find(path);
|
||||||
|
if index < 0 && viewer_is_library_file(path) && file_exists(path) index = viewer_add_capture(path);
|
||||||
|
if index < 0 return false;
|
||||||
|
viewer_request_full(index);
|
||||||
|
open_file.window_open = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// path as the library names it (its folder as viewer_folder() writes it) when it is a capture in the
|
||||||
|
// library; else "". Temp.
|
||||||
|
open_file_in_library :: (path: string) -> string {
|
||||||
|
folder := viewer_folder();
|
||||||
|
#if OS == .WINDOWS inside := begins_with_nocase(path, folder); // D:\ and d:\ alike
|
||||||
|
else inside := begins_with(path, folder);
|
||||||
|
if !inside || path.count <= folder.count + 1 || path[folder.count] != #char "/" return "";
|
||||||
|
in_library := tprint("%/%", folder, advance(path, folder.count + 1));
|
||||||
|
return ifx viewer_is_library_file(in_library) then in_library else "";
|
||||||
|
}
|
||||||
|
|
||||||
|
open_file_kind :: (path: string) -> Open_File_Kind {
|
||||||
|
name := path_filename(path);
|
||||||
|
extension := to_lower_copy(path_extension(name),, temp);
|
||||||
|
if extension == MCAP_EXTENSION return .MCAP;
|
||||||
|
head := import_read_head(path, IMPORT_HEAD_SIZE);
|
||||||
|
if extension == "snagx" && head.count >= 4 && memcmp(head.data, "PK\x03\x04".data, 4) == 0 return .SNAGX;
|
||||||
|
kind := image_kind_from_name(image_kind(head), name);
|
||||||
|
if kind == .VIDEO || (kind == .UNKNOWN && array_find(IMPORT_VIDEO_EXTENSIONS, extension)) return .VIDEO;
|
||||||
|
if kind == .UNKNOWN return .UNKNOWN;
|
||||||
|
return .IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The library's folders, for the menu of where to import into.
|
||||||
|
open_file_list_folders :: () {
|
||||||
|
for open_file.folders free(it,, context.default_allocator);
|
||||||
|
array_reset_keeping_memory(*open_file.folders);
|
||||||
|
visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) {
|
||||||
|
if !info.is_directory || !import_name_is_valid(info.short_name) return; // not hidden ones (.thumbnails)
|
||||||
|
#if OS == .LINUX if info.short_name == "deleted" return; // where deleted captures go there
|
||||||
|
array_add(folders, copy_string(info.short_name,, context.default_allocator));
|
||||||
|
}, visit_files = false, visit_directories = true);
|
||||||
|
quick_sort(open_file.folders, compare);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The thread: the image first, shown as soon as it is there, then whether the library has the file,
|
||||||
|
// by its hash in the library index (which, for a video, means reading it all).
|
||||||
|
open_file_proc :: (thread: *Thread) -> s64 {
|
||||||
|
context.allocator = context.default_allocator;
|
||||||
|
defer atomic_swap(*open_file.stage, Open_File_Stage.DONE);
|
||||||
|
push_allocator(temp);
|
||||||
|
path := open_file.path; // left alone by the main thread while this runs
|
||||||
|
|
||||||
|
decoded: Decoded;
|
||||||
|
decoded.failed = true;
|
||||||
|
details, error: string;
|
||||||
|
hash: u64;
|
||||||
|
hashed: bool;
|
||||||
|
to_hash: Mcap_Original_Ref; // hashed once the image is shown, when it was not hashed already
|
||||||
|
|
||||||
|
if open_file.kind == {
|
||||||
|
case .MCAP;
|
||||||
|
m, original, ok, read_error := mcap_read_without_original(path);
|
||||||
|
if !ok { error = read_error; }
|
||||||
|
else {
|
||||||
|
image := m.rendered; // annotations and all, or a video's frame
|
||||||
|
if m.rendered_kind != .OWN {
|
||||||
|
bytes, read_ok := mcap_read_original(original);
|
||||||
|
if read_ok image = bytes;
|
||||||
|
else error = "its image cannot be read";
|
||||||
|
}
|
||||||
|
if image.count decoded = viewer_decode_image(path, image);
|
||||||
|
details = ifx m.original_name then tprint("Capture of %", m.original_name) else "Capture";
|
||||||
|
if !decoded.failed details = tprint("%, % x %", details, decoded.source_width, decoded.source_height);
|
||||||
|
to_hash = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
case .SNAGX;
|
||||||
|
data, ok := read_entire_file(path, log_errors = false);
|
||||||
|
if !ok { error = "it cannot be read"; }
|
||||||
|
else {
|
||||||
|
m, _, converted, convert_error := snagx_to_mcap(data);
|
||||||
|
if !converted { error = convert_error; }
|
||||||
|
else {
|
||||||
|
decoded = viewer_decode_image(path, ifx m.rendered_kind == .OWN then m.rendered else m.original);
|
||||||
|
details = "Snagit capture";
|
||||||
|
hash, hashed = import_hash(m.original), true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .IMAGE;
|
||||||
|
if open_file.size > IMPORT_MAX_FILE { error = tprint("it is larger than the % MB mcap reads", IMPORT_MAX_FILE / (1024 * 1024)); }
|
||||||
|
else {
|
||||||
|
data, ok := read_entire_file(path, log_errors = false);
|
||||||
|
if !ok { error = "it cannot be read"; }
|
||||||
|
else {
|
||||||
|
bytes := cast([] u8) data;
|
||||||
|
kind := image_kind_from_name(image_kind(bytes), path_filename(path));
|
||||||
|
decoded = viewer_decode_image(path, bytes, kind);
|
||||||
|
details = image_kind_name(kind);
|
||||||
|
if kind == .GIF {
|
||||||
|
frames, duration_ms := gif_info(bytes);
|
||||||
|
if frames > 1 details = tprint("Animated GIF, % frames, %", frames, viewer_duration_text(duration_ms / 1000.0));
|
||||||
|
}
|
||||||
|
if !decoded.failed details = tprint("%, % x %", details, decoded.source_width, decoded.source_height);
|
||||||
|
hash, hashed = import_hash(bytes), true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .VIDEO;
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
image, video, ok, poster_error := video_poster(path);
|
||||||
|
if !ok { error = poster_error; }
|
||||||
|
else {
|
||||||
|
decoded = viewer_decoded_from_image(path, image);
|
||||||
|
details = tprint("Video, %%%, % x %", viewer_duration_text(video.duration), ifx video.codec then tprint(", %", video.codec) else "",
|
||||||
|
ifx video.frame_rate > 0 then tprint(", % fps", FF(video.frame_rate, ifx video.frame_rate == floor(video.frame_rate) then 0 else 2)) else "",
|
||||||
|
decoded.source_width, decoded.source_height);
|
||||||
|
}
|
||||||
|
to_hash = .{path = path, offset = 0, size = open_file.size};
|
||||||
|
} else {
|
||||||
|
error = "videos are read on Windows and macOS only";
|
||||||
|
}
|
||||||
|
|
||||||
|
case;
|
||||||
|
error = "it is not an image, a video or a capture";
|
||||||
|
}
|
||||||
|
if !error && decoded.failed error = "it cannot be decoded (see the Event Log)";
|
||||||
|
|
||||||
|
open_file.decoded = decoded;
|
||||||
|
open_file.details = copy_string(details,, context.default_allocator);
|
||||||
|
open_file.error = copy_string(error,, context.default_allocator);
|
||||||
|
atomic_swap(*open_file.stage, Open_File_Stage.DECODED);
|
||||||
|
reset_temporary_storage();
|
||||||
|
|
||||||
|
if !hashed && to_hash.path hash, hashed = import_hash_file(to_hash);
|
||||||
|
if hashed open_file.match = copy_string(import_index_find(viewer_folder(), hash),, context.default_allocator);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- importing
|
||||||
|
|
||||||
|
open_file_import :: (dest: string) {
|
||||||
|
if !import_start(open_file.path, dest) return; // it says why
|
||||||
|
open_file.importing = true;
|
||||||
|
free(open_file.result); free(open_file.imported_path);
|
||||||
|
open_file.result, open_file.imported_path = "", "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the import of the file has ended: what it did, and in full mode the capture in the Capture window.
|
||||||
|
open_file_import_poll :: () {
|
||||||
|
if !open_file.importing || importer.running return;
|
||||||
|
if app.view_mode {
|
||||||
|
// No import_frame here: the thread is this file's to take back, and its paths, which no grid wants.
|
||||||
|
if importer.thread_started {
|
||||||
|
thread_is_done(*importer.thread, -1);
|
||||||
|
thread_deinit(*importer.thread);
|
||||||
|
importer.thread_started = false;
|
||||||
|
}
|
||||||
|
} else if importer.thread_started {
|
||||||
|
return; // import_frame takes it back first, and puts what it wrote in the grid
|
||||||
|
}
|
||||||
|
open_file.importing = false;
|
||||||
|
|
||||||
|
new_path, duplicate, failure: string;
|
||||||
|
lock(*importer.mutex);
|
||||||
|
if app.view_mode {
|
||||||
|
if importer.new_paths.count new_path = copy_string(importer.new_paths[0],, temp);
|
||||||
|
for importer.new_paths free(it,, context.default_allocator);
|
||||||
|
array_reset_keeping_memory(*importer.new_paths);
|
||||||
|
}
|
||||||
|
if importer.duplicate_notes.count duplicate = copy_string(importer.duplicate_notes[0].text,, temp);
|
||||||
|
if importer.failures.count failure = copy_string(importer.failures[0].text,, temp);
|
||||||
|
unlock(*importer.mutex);
|
||||||
|
if !app.view_mode && importer.new_indices.count new_path = viewer.captures[importer.new_indices[0]].path;
|
||||||
|
|
||||||
|
result: string;
|
||||||
|
ok := false;
|
||||||
|
where := new_path;
|
||||||
|
if importer.error result = importer.error;
|
||||||
|
else if new_path { result = tprint("Imported into %.", importer.dest_name); ok = true; }
|
||||||
|
else if duplicate { result = tprint("Already in the library: %", duplicate); ok = true; where = tprint("%/%", viewer_folder(), duplicate); }
|
||||||
|
else if failure result = tprint("Could not import it: %", failure);
|
||||||
|
else if importer.cancel result = "The import was cancelled.";
|
||||||
|
else result = "mcap does not import this kind of file.";
|
||||||
|
log("[open] Import of % into %: %", open_file.path, importer.dest_name, result);
|
||||||
|
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
open_file.result = copy_string(result);
|
||||||
|
open_file.result_ok = ok;
|
||||||
|
open_file.imported_path = copy_string(where);
|
||||||
|
if ok open_file_list_folders(); // it may have made one
|
||||||
|
if ok && !app.view_mode open_file_show_in_library(where);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode: path handed to the running copy, which shows it; this copy then closes.
|
||||||
|
open_file_send_to_running :: (path: string) {
|
||||||
|
if path.count > APP_SHARED_MEMORY_CHANNEL_BYTES / 2 { toast_show(.Error, "The path is too long to hand over"); return; }
|
||||||
|
key := app_shared_memory_channel_key();
|
||||||
|
defer free(key);
|
||||||
|
if app_send_to_running_instance(key, 5, .Open_File, path) app.should_exit = true;
|
||||||
|
else toast_show(.Error, "mcap is no longer running");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- copying (view mode's Ctrl-C and Ctrl-Shift-C)
|
||||||
|
|
||||||
|
open_file_copy_path :: () {
|
||||||
|
if !open_file.path return;
|
||||||
|
path := open_file.path;
|
||||||
|
#if OS == .WINDOWS path = replace(path, "/", "\\",, temp);
|
||||||
|
clipboard_set_text(path);
|
||||||
|
toast_show(.Success, "Copied the path of %", path_filename(open_file.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
open_file_copy_image :: () {
|
||||||
|
#if OS == .LINUX {
|
||||||
|
toast_show(.Error, "Copying images is not available on this system yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
d := *open_file.decoded;
|
||||||
|
if !open_file.uploaded || d.failed || !d.pixels { toast_show(.Error, "There is no image to copy"); return; }
|
||||||
|
if !platform_copy_image_to_clipboard(.{cast(s64) d.width * d.height * 4, d.pixels}, d.width, d.height) {
|
||||||
|
toast_show(.Error, "Could not put % on the clipboard", path_filename(open_file.path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast_show(.Success, "Copied % (% x %)", path_filename(open_file.path), d.width, d.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- each frame
|
||||||
|
|
||||||
|
// Full mode, from app_main_window_ui: the Opened File window.
|
||||||
|
open_file_frame :: () { using ImGui;
|
||||||
|
open_file_poll();
|
||||||
|
if !open_file.window_open {
|
||||||
|
if open_file.stage != .IDLE && !open_file.importing open_file_stop(); // closed: its image goes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SetNextWindowSize(.{900, 700}, .FirstUseEver);
|
||||||
|
if open_file.focus { SetNextWindowFocus(); open_file.focus = false; }
|
||||||
|
if Begin(OPEN_FILE_WINDOW, *open_file.window_open) open_file_contents();
|
||||||
|
End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode: the file, filling the main window.
|
||||||
|
open_file_view_mode_ui :: () { using ImGui;
|
||||||
|
open_file_poll();
|
||||||
|
viewport := GetMainViewport();
|
||||||
|
SetNextWindowPos(viewport.WorkPos);
|
||||||
|
SetNextWindowSize(viewport.WorkSize);
|
||||||
|
SetNextWindowViewport(viewport.ID_);
|
||||||
|
flags := WindowFlags.NoDecoration | .NoMove | .NoSavedSettings | .NoDocking | .NoBringToFrontOnFocus;
|
||||||
|
if Begin("View", null, flags) open_file_contents();
|
||||||
|
End();
|
||||||
|
if IsKeyPressed(.Escape) && !IsAnyItemActive() app.should_exit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The thread's image, once there, as a texture (again, after the device changed); the thread taken
|
||||||
|
// back once it has ended; the import's end.
|
||||||
|
open_file_poll :: () {
|
||||||
|
stage := atomic_read(*open_file.stage);
|
||||||
|
if (stage == .DECODED || stage == .DONE) && (!open_file.uploaded || open_file.texture_generation != g_texture_generation) {
|
||||||
|
open_file.uploaded = true;
|
||||||
|
open_file.texture_generation = g_texture_generation;
|
||||||
|
gpu_texture_release(*open_file.texture);
|
||||||
|
d := *open_file.decoded;
|
||||||
|
if !d.failed && d.pixels open_file.texture = gpu_texture_create(d.pixels, d.width, d.height);
|
||||||
|
}
|
||||||
|
if stage == .DONE && open_file.thread_started {
|
||||||
|
thread_is_done(*open_file.thread, -1);
|
||||||
|
thread_deinit(*open_file.thread);
|
||||||
|
open_file.thread_started = false;
|
||||||
|
}
|
||||||
|
open_file_import_poll();
|
||||||
|
if app.open_import_dest open_file_flag_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -open-import DEST: once the file has been read, Import into DEST, as the button does; then exits,
|
||||||
|
// 0 when it is in the library (imported, or there already), 1 when not.
|
||||||
|
open_file_flag_frame :: () {
|
||||||
|
if app.should_exit return;
|
||||||
|
if !app.open_file || open_file.library_path {
|
||||||
|
log("[open] -open-import: %.", ifx app.open_file then "it is in the library" else "no file was opened");
|
||||||
|
app.exit_code = ifx app.open_file then cast(s32) 0 else 1;
|
||||||
|
app.should_exit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !open_file.flag_started {
|
||||||
|
if atomic_read(*open_file.stage) != .DONE return;
|
||||||
|
open_file.flag_started = true;
|
||||||
|
open_file_import(app.open_import_dest);
|
||||||
|
if !open_file.importing { log_error("[open] -open-import % could not start.", app.open_import_dest); app.exit_code = 1; app.should_exit = true; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if open_file.importing return;
|
||||||
|
log("[open] -open-import is done: %", open_file.result);
|
||||||
|
app.exit_code = ifx open_file.result_ok then cast(s32) 0 else 1;
|
||||||
|
app.should_exit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
open_file_contents :: () { using ImGui;
|
||||||
|
if !open_file.path return;
|
||||||
|
stage := atomic_read(*open_file.stage);
|
||||||
|
shown := stage == .DECODED || stage == .DONE;
|
||||||
|
Text("%", path_filename(open_file.path));
|
||||||
|
SameLine();
|
||||||
|
if shown && open_file.details TextColored(app_theme.palette.dim, "%, %", open_file.details, open_file_size_text(open_file.size));
|
||||||
|
else TextColored(app_theme.palette.dim, "%", open_file_size_text(open_file.size));
|
||||||
|
if !shown { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
|
||||||
|
native := open_file.path;
|
||||||
|
#if OS == .WINDOWS native = replace(native, "/", "\\",, temp);
|
||||||
|
TextColored(app_theme.palette.dim, "%", native);
|
||||||
|
|
||||||
|
open_file_library_ui(stage);
|
||||||
|
Separator();
|
||||||
|
|
||||||
|
if shown && open_file.error {
|
||||||
|
TextColored(app_theme.palette.warning, "This file cannot be shown: %.", open_file.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if open_file.texture.handle open_file_image_ui();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the file stands with the library, and Import when it is not in it.
|
||||||
|
open_file_library_ui :: (stage: Open_File_Stage) { using ImGui;
|
||||||
|
library := viewer_folder();
|
||||||
|
if open_file.library_path {
|
||||||
|
relative := advance(open_file.library_path, library.count + 1);
|
||||||
|
folder := trim_right(path_strip_filename(relative), "/");
|
||||||
|
Text("In the library, in %.", ifx folder then folder else "its top folder");
|
||||||
|
if app.view_mode { SameLine(); open_file_show_button(open_file.library_path); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if open_file.result {
|
||||||
|
TextColored(ifx open_file.result_ok then app_theme.palette.success else app_theme.palette.warning, "%", open_file.result);
|
||||||
|
if open_file.result_ok {
|
||||||
|
if app.view_mode && open_file.imported_path { SameLine(); open_file_show_button(open_file.imported_path); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stage == .DONE && open_file.match {
|
||||||
|
Text("Already in the library: %", open_file.match);
|
||||||
|
SameLine();
|
||||||
|
where := tprint("%/%", library, open_file.match);
|
||||||
|
if app.view_mode open_file_show_button(where);
|
||||||
|
else if Button("Show") open_file_show_in_library(where);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); return; }
|
||||||
|
if OS == .LINUX && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows and macOS only."); return; }
|
||||||
|
|
||||||
|
if open_file.importing {
|
||||||
|
if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total));
|
||||||
|
else Text("Importing into %...", importer.dest_name);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Cancel") { atomic_swap(*importer.cancel, true); atomic_swap(*importer.paused, false); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Import into library folder");
|
||||||
|
SameLine();
|
||||||
|
SetNextItemWidth(GetFontSize() * 16);
|
||||||
|
InputTextWithHint("##dest", "e.g. Photos", open_file.dest_buffer.data, open_file.dest_buffer.count);
|
||||||
|
SameLine();
|
||||||
|
if BeginCombo("##folders", "", .NoPreview) {
|
||||||
|
for open_file.folders {
|
||||||
|
if Selectable(temp_c_string(it)) {
|
||||||
|
count := min(it.count, open_file.dest_buffer.count - 1);
|
||||||
|
memcpy(open_file.dest_buffer.data, it.data, count);
|
||||||
|
open_file.dest_buffer[count] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !open_file.folders.count TextColored(app_theme.palette.dim, "The library has no folders yet");
|
||||||
|
EndCombo();
|
||||||
|
}
|
||||||
|
if IsItemHovered() SetTooltip("The library's folders");
|
||||||
|
dest := trim(to_string(open_file.dest_buffer.data));
|
||||||
|
valid := dest.count > 0 && import_name_is_valid(dest);
|
||||||
|
busy := importer.running; // a folder's import, from the Import window
|
||||||
|
SameLine();
|
||||||
|
if !valid || busy BeginDisabled();
|
||||||
|
if Button("Import") open_file_import(dest);
|
||||||
|
if !valid || busy EndDisabled();
|
||||||
|
if busy && IsItemHovered(.AllowWhenDisabled) SetTooltip("After the import running now");
|
||||||
|
if app.view_mode { SameLine(); open_file_show_button(open_file.path, "Open in mcap"); }
|
||||||
|
|
||||||
|
if dest && !valid TextColored(app_theme.palette.warning, "\"%\" cannot be a folder's name.", dest);
|
||||||
|
else if dest {
|
||||||
|
into := tprint("%/%", library, dest);
|
||||||
|
#if OS == .WINDOWS into = replace(into, "/", "\\",, temp);
|
||||||
|
TextColored(app_theme.palette.dim, "Into %%", into, ifx array_find(open_file.folders, dest) then "" else " (a new folder)");
|
||||||
|
}
|
||||||
|
if !app.view_mode Checkbox("Read text and labels once it is in", *importer.read_after);
|
||||||
|
if stage != .DONE TextColored(app_theme.palette.dim, "Looking for it in the library...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode: a button handing path to the running copy, which shows it.
|
||||||
|
open_file_show_button :: (path: string, label := "Show in mcap") { using ImGui;
|
||||||
|
if Button(temp_c_string(label)) open_file_send_to_running(path);
|
||||||
|
if IsItemHovered() SetTooltip("Shows it in the mcap already running, and closes this window");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image, sized as the Capture window sizes its own (viewer_full_window).
|
||||||
|
open_file_image_ui :: () { using ImGui;
|
||||||
|
fit := open_file.fit;
|
||||||
|
Text("Fit:");
|
||||||
|
SameLine(); if RadioButton("Window", fit == .WINDOW) fit = .WINDOW;
|
||||||
|
SameLine(); if RadioButton("Width", fit == .WIDTH) fit = .WIDTH;
|
||||||
|
SameLine(); if RadioButton("Height", fit == .HEIGHT) fit = .HEIGHT;
|
||||||
|
SameLine(); if RadioButton("Off", fit == .OFF) fit = .OFF;
|
||||||
|
open_file.fit = fit;
|
||||||
|
SameLine();
|
||||||
|
SetNextItemWidth(260);
|
||||||
|
if SliderFloat("Zoom", *open_file.zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM, "%.0f%%", .Logarithmic) open_file.fit = .OFF;
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("100%") { open_file.fit = .OFF; open_file.zoom = 100; }
|
||||||
|
if open_file.kind == .VIDEO {
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Play in the default player") platform_open_file_in_default_program(open_file.path, path_strip_filename(open_file.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := WindowFlags.HorizontalScrollbar;
|
||||||
|
if open_file.fit == {
|
||||||
|
case .WINDOW; flags = .NoScrollbar;
|
||||||
|
case .WIDTH; flags |= .AlwaysVerticalScrollbar;
|
||||||
|
case .HEIGHT; flags |= .AlwaysHorizontalScrollbar;
|
||||||
|
}
|
||||||
|
BeginChild("image", .{0, 0}, .None, flags);
|
||||||
|
area := GetContentRegionAvail();
|
||||||
|
source_w, source_h := cast(float) open_file.decoded.source_width, cast(float) open_file.decoded.source_height;
|
||||||
|
if source_w > 0 && source_h > 0 && area.x > 0 && area.y > 0 {
|
||||||
|
if open_file.fit == {
|
||||||
|
case .WINDOW; open_file.zoom = 100 * min(area.x / source_w, area.y / source_h);
|
||||||
|
case .WIDTH; open_file.zoom = 100 * area.x / source_w;
|
||||||
|
case .HEIGHT; open_file.zoom = 100 * area.y / source_h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zoom := clamp(open_file.zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM) / 100;
|
||||||
|
Image(gpu_texture_ref(open_file.texture), .{source_w * zoom, source_h * zoom});
|
||||||
|
EndChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 812 KB, 4.2 MB, 1.3 GB. Temp.
|
||||||
|
open_file_size_text :: (bytes: s64) -> string {
|
||||||
|
if bytes < 1024 * 1024 return tprint("% KB", (bytes + 1023) / 1024);
|
||||||
|
if bytes < 1024 * 1024 * 1024 return tprint("% MB", FF(bytes / (1024.0 * 1024.0), 1));
|
||||||
|
return tprint("% GB", FF(bytes / (1024.0 * 1024.0 * 1024.0), 1));
|
||||||
|
}
|
||||||
243
src/app/screen_capture.jai
Normal file
243
src/app/screen_capture.jai
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// Screen capture, as Snagit does it: PrintScreen on Windows, Cmd+Shift+2 on macOS (the capture_region
|
||||||
|
// global hotkey, from any app, even with mcap hidden in the tray) freezes every screen, and a rectangle
|
||||||
|
// dragged over them is taken (app/screen_capture_win32.jai, app/screen_capture_macos.jai). What is taken goes onto the clipboard at once,
|
||||||
|
// and into the library as an .mcap in the folder SCREEN_CAPTURE_FOLDER: the pixels as a PNG, with
|
||||||
|
// the window it was taken from in metadata.json, its title as WindowName and its program as AppName,
|
||||||
|
// as Snagit records them, so searches find it the same way. mcap then comes to the front showing
|
||||||
|
// it in the Capture window, and OCR and labels read it once they are free.
|
||||||
|
//
|
||||||
|
// The PNG is made and the file written on a thread of its own, as a large capture takes a second or
|
||||||
|
// so to compress. A screen recording (app/screen_record.jai) goes into the library through the same
|
||||||
|
// jobs.
|
||||||
|
|
||||||
|
SCREEN_CAPTURE_FOLDER :: "Screen Captures"; // in the library
|
||||||
|
|
||||||
|
Screen_Capture_Job :: struct {
|
||||||
|
shot: Screen_Shot;
|
||||||
|
date: string; // CaptureDate; heap
|
||||||
|
|
||||||
|
// A recording (app/screen_record.jai), not a still; its strings are heap.
|
||||||
|
video: bool;
|
||||||
|
recording: string; // the MP4 recorded, deleted once in the library
|
||||||
|
encoder: string; // "h264_nvenc"
|
||||||
|
source: string; // how the screen was taken: Record_Stats.source
|
||||||
|
dropped: s64; // frames the encoder fell too far behind for
|
||||||
|
paused_seconds: float64; // left out of it
|
||||||
|
audio: string; // the sound's sources, "Speakers (...); Microphone (...)", or ""
|
||||||
|
|
||||||
|
thread: Thread;
|
||||||
|
busy: bool; // cleared by the thread as it ends
|
||||||
|
path: string; // what it wrote; heap
|
||||||
|
error: string; // or why it could not; heap
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_capture: struct {
|
||||||
|
jobs: [..] *Screen_Capture_Job;
|
||||||
|
scan_pending: [..] s32; // captures OCR and labels have yet to read
|
||||||
|
segment_pending: [..] s32; // and then segmentation, when the folder is set to segment
|
||||||
|
}
|
||||||
|
|
||||||
|
// From app_frame when the hotkey fired, outside the ImGui frame: lets a region be chosen, copies it,
|
||||||
|
// and starts writing it into the library.
|
||||||
|
screen_capture_take :: () {
|
||||||
|
if app_exit_shutdown_started() return;
|
||||||
|
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||||
|
start := current_time_monotonic();
|
||||||
|
// While recording, the overlay's frozen screen would be in the recording: the screen under the
|
||||||
|
// cursor is taken at once instead.
|
||||||
|
shot, ok, error := platform_screen_capture_select(immediate = screen_record.recording);
|
||||||
|
if !ok {
|
||||||
|
if error { log_error("[capture] %.", error); toast_show(.Error, "Screen capture: %", error); }
|
||||||
|
else log("[capture] Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log("[capture] % x % at %, % (%) from \"%\" (%), chosen in % s.", shot.width, shot.height, shot.left, shot.top,
|
||||||
|
ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name,
|
||||||
|
FF(to_float64_seconds(current_time_monotonic() - start), 1));
|
||||||
|
|
||||||
|
if !platform_copy_image_to_clipboard(shot.pixels, shot.width, shot.height) log_error("[capture] Could not put the capture on the clipboard.");
|
||||||
|
|
||||||
|
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||||
|
job.shot = shot;
|
||||||
|
job.date = copy_string(date,, context.default_allocator);
|
||||||
|
screen_capture_job_start(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the job into the library on a thread of its own; screen_capture_frame takes it from there.
|
||||||
|
// The job is freed when it cannot start.
|
||||||
|
screen_capture_job_start :: (job: *Screen_Capture_Job) -> bool {
|
||||||
|
job.busy = true;
|
||||||
|
if !thread_init(*job.thread, screen_capture_save_proc, temporary_storage_size = 1024 * 1024, name = "screen capture") {
|
||||||
|
what := ifx job.video then "recording" else "capture";
|
||||||
|
log_error("[capture] Could not start the thread to save the %.", what);
|
||||||
|
toast_show(.Error, "The screen % could not be saved", what);
|
||||||
|
screen_capture_job_free(job);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
thread_start(*job.thread, job);
|
||||||
|
screen_capture.jobs.allocator = context.default_allocator;
|
||||||
|
array_add(*screen_capture.jobs, job);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every frame, even while hidden in the tray: the captures written, into the grid and the Capture
|
||||||
|
// window, and then read.
|
||||||
|
screen_capture_frame :: () {
|
||||||
|
if app_exit_shutdown_started() return;
|
||||||
|
screen_record_frame();
|
||||||
|
for job: screen_capture.jobs {
|
||||||
|
if atomic_read(*job.busy) continue;
|
||||||
|
thread_is_done(*job.thread, -1);
|
||||||
|
thread_deinit(*job.thread);
|
||||||
|
what := ifx job.video then "recording" else "capture";
|
||||||
|
if job.video screen_record_saved(job.path, job.error);
|
||||||
|
if job.error {
|
||||||
|
log_error("[capture] Could not save the %: %", what, job.error);
|
||||||
|
toast_show(.Error, "The screen % could not be saved: %", what, job.error);
|
||||||
|
} else {
|
||||||
|
log("[capture] Saved %.", job.path);
|
||||||
|
index := viewer_add_capture(job.path);
|
||||||
|
if index >= 0 {
|
||||||
|
viewer_request_full(index);
|
||||||
|
screen_capture.scan_pending.allocator = context.default_allocator;
|
||||||
|
screen_capture.segment_pending.allocator = context.default_allocator;
|
||||||
|
array_add(*screen_capture.scan_pending, index);
|
||||||
|
if !job.video && library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index);
|
||||||
|
}
|
||||||
|
app.restore_window_this_frame = true;
|
||||||
|
if job.video toast_show(.Success, "% x % recorded", job.shot.width, job.shot.height);
|
||||||
|
else toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height);
|
||||||
|
}
|
||||||
|
screen_capture_job_free(job);
|
||||||
|
remove job;
|
||||||
|
}
|
||||||
|
|
||||||
|
// As an import's are, once the models are loaded and free: OCR and labels, then segmentation.
|
||||||
|
if screen_capture.scan_pending.count && !ocr_ui.scan.running && ocr_ui_models_ready(labels_ui.scan_labels) && ocr_ui.engine {
|
||||||
|
ocr_scan_start(screen_capture.scan_pending);
|
||||||
|
array_reset_keeping_memory(*screen_capture.scan_pending);
|
||||||
|
}
|
||||||
|
// Once their catalog entries are in: the scan takes only images, and they say which.
|
||||||
|
if screen_capture.segment_pending.count && !screen_capture.scan_pending.count && !ocr_ui.thread_busy && !ocr_ui.scan.running {
|
||||||
|
catalogued := true;
|
||||||
|
for screen_capture.segment_pending if !viewer.captures[it].catalog && !viewer.captures[it].removed { catalogued = false; break; }
|
||||||
|
if catalogued {
|
||||||
|
segment_scan_start(screen_capture.segment_pending, quiet = true);
|
||||||
|
array_reset_keeping_memory(*screen_capture.segment_pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the shutdown thread: a capture being written is finished, not lost.
|
||||||
|
screen_capture_shutdown :: () {
|
||||||
|
screen_record_shutdown(); // a recording running is stopped and becomes a job, finished below
|
||||||
|
for screen_capture.jobs thread_is_done(*it.thread, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_capture_job_free :: (job: *Screen_Capture_Job) {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
screen_shot_free(*job.shot);
|
||||||
|
free(job.date);
|
||||||
|
free(job.path);
|
||||||
|
free(job.error);
|
||||||
|
free(job.recording);
|
||||||
|
free(job.encoder);
|
||||||
|
free(job.source);
|
||||||
|
free(job.audio);
|
||||||
|
free(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_capture_save_proc :: (thread: *Thread) -> s64 {
|
||||||
|
job := cast(*Screen_Capture_Job) thread.data;
|
||||||
|
context.allocator = context.default_allocator;
|
||||||
|
defer atomic_swap(*job.busy, false);
|
||||||
|
path, error: string;
|
||||||
|
if job.video path, error = screen_record_write(job);
|
||||||
|
else path, error = screen_capture_write(job.shot, job.date);
|
||||||
|
if error job.error = copy_string(error);
|
||||||
|
else job.path = copy_string(path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the capture into the library: its path, or why it could not be (temp).
|
||||||
|
screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error: string {
|
||||||
|
image := Image.{pixels = shot.pixels.data, width = shot.width, height = shot.height};
|
||||||
|
png, _, _, png_ok, png_error := image_preview_png(image, max(shot.width, shot.height)); // at its own size
|
||||||
|
if !png_ok return "", png_error;
|
||||||
|
preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE);
|
||||||
|
if !preview_ok return "", preview_error;
|
||||||
|
hash := import_hash(png);
|
||||||
|
|
||||||
|
doc := New(Capture_Document,, temp);
|
||||||
|
capture_document_init(doc, temp);
|
||||||
|
doc.comment = tprint("Captured by % %.", APP_NAME, VERSION);
|
||||||
|
metadata := capture_add_file(doc, "metadata.json");
|
||||||
|
import_add(doc, metadata, "AppName", import_text(shot.app_name));
|
||||||
|
import_add(doc, metadata, "CaptureDate", date);
|
||||||
|
import_add(doc, metadata, "WindowName", import_text(shot.window_title));
|
||||||
|
info := capture_add_file(doc, "capture.json");
|
||||||
|
import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection");
|
||||||
|
import_add_number(doc, info, "Left", xx shot.left);
|
||||||
|
import_add_number(doc, info, "Top", xx shot.top);
|
||||||
|
import_add_number(doc, info, "Width", xx shot.width);
|
||||||
|
import_add_number(doc, info, "Height", xx shot.height);
|
||||||
|
if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path));
|
||||||
|
import_add(doc, info, "ContentHash", import_hash_text(hash));
|
||||||
|
import_add(doc, info, "Format", "PNG");
|
||||||
|
|
||||||
|
name := import_out_name(date, hash);
|
||||||
|
text, text_ok := import_document_text(doc, name);
|
||||||
|
if !text_ok return "", "its Capture Text does not read back cleanly (see the Event Log)";
|
||||||
|
|
||||||
|
m: Mcap;
|
||||||
|
m.text = text;
|
||||||
|
m.preview = .{width = cast(u16) preview_width, height = cast(u16) preview_height, png = preview};
|
||||||
|
m.original_name = tprint("%.png", path_strip_extension(name));
|
||||||
|
m.original = png;
|
||||||
|
m.rendered_kind = .NONE;
|
||||||
|
serializer, serialized, serialize_error := mcap_serialize(m,, temp);
|
||||||
|
if !serialized return "", serialize_error;
|
||||||
|
|
||||||
|
path, folder_error := screen_capture_out_path(name);
|
||||||
|
if folder_error return "", folder_error;
|
||||||
|
written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data});
|
||||||
|
if !written return "", write_error;
|
||||||
|
return path, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a capture named name goes in SCREEN_CAPTURE_FOLDER, made if need be: name, or name_2 ... when
|
||||||
|
// another file has it. Temp.
|
||||||
|
screen_capture_out_path :: (name: string) -> path: string, error: string {
|
||||||
|
folder := tprint("%/%", viewer_folder(), SCREEN_CAPTURE_FOLDER);
|
||||||
|
if !make_directory_if_it_does_not_exist(folder, recursive = true) return "", tprint("% could not be made", folder);
|
||||||
|
path := tprint("%/%", folder, name);
|
||||||
|
number := 2;
|
||||||
|
while file_exists(path) {
|
||||||
|
path = tprint("%/%_%.%", folder, path_strip_extension(name), number, MCAP_EXTENSION);
|
||||||
|
number += 1;
|
||||||
|
}
|
||||||
|
return path, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .LINUX {
|
||||||
|
Screen_Shot :: struct {
|
||||||
|
pixels: [] u8;
|
||||||
|
width, height: s32;
|
||||||
|
left, top: s32;
|
||||||
|
whole_screen: bool;
|
||||||
|
window_title, app_name, app_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_shot_free :: (shot: *Screen_Shot) {
|
||||||
|
array_free(shot.pixels,, context.default_allocator);
|
||||||
|
shot.* = .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_screen_capture_select :: (video := false, immediate := false) -> Screen_Shot, ok: bool, error: string {
|
||||||
|
return .{}, false, "not available on this system yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
660
src/app/screen_capture_win32.jai
Normal file
660
src/app/screen_capture_win32.jai
Normal file
@ -0,0 +1,660 @@
|
|||||||
|
// The screen capture overlay on Windows (app/screen_capture.jai is the rest). The moment the hotkey
|
||||||
|
// fires, everything on every screen is copied, and shown frozen and darkened in a window over all of
|
||||||
|
// them. A rectangle dragged over it 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.
|
||||||
|
//
|
||||||
|
// For a recording (video), the outline and magnifier are red, the drag is kept on the screen it began
|
||||||
|
// on (a recording is of one screen), 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.
|
||||||
|
//
|
||||||
|
// Drawn with GDI into a bitmap the size of the desktop, so it needs neither ImGui nor the D3D11 device
|
||||||
|
// and works while the main window is hidden in the tray. Only the parts that change are drawn again.
|
||||||
|
// The selection runs in a modal loop of its own: platform_screen_capture_select returns once a region
|
||||||
|
// is taken or the capture is cancelled.
|
||||||
|
|
||||||
|
// The region taken, and the window it was taken from.
|
||||||
|
Screen_Shot :: struct {
|
||||||
|
pixels: [] u8; // RGBA, rows packed; heap
|
||||||
|
width, height: s32;
|
||||||
|
left, top: s32; // on the virtual desktop, whose origin is the primary screen's top left
|
||||||
|
whole_screen: bool; // a click took the screen under the cursor
|
||||||
|
window_title: string; // the window under the region's center; heap, as are the two below
|
||||||
|
app_name: string; // its program's description ("Google Chrome"), or its file name
|
||||||
|
app_path: string; // its program's file
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
fallback_window := GetForegroundWindow(); // before the overlay takes the focus
|
||||||
|
overlay.video = video;
|
||||||
|
|
||||||
|
// The window procedure runs in this context, during the modal loop below, and in no other.
|
||||||
|
overlay.app_context = *context;
|
||||||
|
defer overlay.app_context = null;
|
||||||
|
if !overlay_freeze() { overlay_release(); return shot, false, "the screen could not be copied"; }
|
||||||
|
defer overlay_release();
|
||||||
|
if immediate {
|
||||||
|
cursor: POINT;
|
||||||
|
GetCursorPos(*cursor);
|
||||||
|
overlay.result = overlay_monitor_rect(.{cursor.x - overlay.left, cursor.y - overlay.top});
|
||||||
|
overlay.whole_screen = true;
|
||||||
|
overlay.cancelled = false;
|
||||||
|
} else {
|
||||||
|
if !overlay_open() return shot, false, "the capture window could not be opened";
|
||||||
|
overlay_choose();
|
||||||
|
}
|
||||||
|
if overlay.cancelled return shot, false, "";
|
||||||
|
|
||||||
|
r := overlay.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 = overlay.left + r.left;
|
||||||
|
shot.top = overlay.top + r.top;
|
||||||
|
shot.whole_screen = overlay.whole_screen;
|
||||||
|
|
||||||
|
// The desktop's bitmap is BGRA (its alpha undefined), top-down.
|
||||||
|
if !video shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator);
|
||||||
|
out := shot.pixels.data;
|
||||||
|
if !video for y: r.top..r.bottom - 1 {
|
||||||
|
row := overlay.screen_pixels + cast(s64) y * overlay.width;
|
||||||
|
for x: r.left..r.right - 1 {
|
||||||
|
p := row[x];
|
||||||
|
out[0] = cast,trunc(u8) (p >> 16);
|
||||||
|
out[1] = cast,trunc(u8) (p >> 8);
|
||||||
|
out[2] = cast,trunc(u8) p;
|
||||||
|
out[3] = 255;
|
||||||
|
out += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window under the region's middle, now that the overlay is gone; else the one in front.
|
||||||
|
center := POINT.{shot.left + shot.width / 2, shot.top + shot.height / 2};
|
||||||
|
window := WindowFromPoint(center);
|
||||||
|
if window window = GetAncestor(window, GA_ROOT);
|
||||||
|
if !window window = fallback_window;
|
||||||
|
if window overlay_window_info(*shot, window);
|
||||||
|
return shot, true, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puts the RGBA pixels on the clipboard as a bitmap, which every program pastes.
|
||||||
|
platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool {
|
||||||
|
row_bytes := cast(s64) width * 4;
|
||||||
|
size := size_of(BITMAPINFOHEADER) + row_bytes * height;
|
||||||
|
memory := GlobalAlloc(GMEM_MOVEABLE, cast(SIZE_T) size);
|
||||||
|
if !memory return false;
|
||||||
|
data := cast(*u8) GlobalLock(memory);
|
||||||
|
if !data { GlobalFree(memory); return false; }
|
||||||
|
|
||||||
|
header := cast(*BITMAPINFOHEADER) data;
|
||||||
|
header.* = .{};
|
||||||
|
header.biSize = size_of(BITMAPINFOHEADER);
|
||||||
|
header.biWidth = width;
|
||||||
|
header.biHeight = height; // bottom-up, as most programs expect of CF_DIB
|
||||||
|
header.biPlanes = 1;
|
||||||
|
header.biBitCount = 32;
|
||||||
|
header.biCompression = BI_RGB;
|
||||||
|
header.biSizeImage = cast(u32) (row_bytes * height);
|
||||||
|
out := data + size_of(BITMAPINFOHEADER);
|
||||||
|
for y: 0..height - 1 {
|
||||||
|
in := pixels.data + (height - 1 - y) * row_bytes;
|
||||||
|
for x: 0..width - 1 {
|
||||||
|
out[0] = in[2]; out[1] = in[1]; out[2] = in[0]; out[3] = 255;
|
||||||
|
out += 4; in += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlobalUnlock(memory);
|
||||||
|
|
||||||
|
if !OpenClipboard(platform.hwnd) { GlobalFree(memory); return false; }
|
||||||
|
defer CloseClipboard();
|
||||||
|
EmptyClipboard();
|
||||||
|
if !SetClipboardData(CF_DIB, memory) { GlobalFree(memory); return false; } // the clipboard owns it once set
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
overlay: struct {
|
||||||
|
hwnd: HWND;
|
||||||
|
class_registered: bool;
|
||||||
|
app_context: *#Context;
|
||||||
|
video: bool; // choosing a region to record
|
||||||
|
|
||||||
|
left, top: s32; // the virtual desktop's top left
|
||||||
|
width, height: s32; // and size, in pixels
|
||||||
|
scale: float; // the DPI scale of the screen the overlay's window counts as on
|
||||||
|
|
||||||
|
screen_dc, dim_dc, back_dc: HDC; // the screens as copied; the same darkened; what is drawn
|
||||||
|
screen_bitmap, dim_bitmap, back_bitmap: HBITMAP;
|
||||||
|
screen_old, dim_old, back_old: HGDIOBJ;
|
||||||
|
screen_pixels: *u32;
|
||||||
|
font, font_old: HFONT;
|
||||||
|
accent_brush, black_brush, label_brush, frame_brush: HBRUSH;
|
||||||
|
|
||||||
|
cursor: POINT; // in the overlay's pixels, as everything below
|
||||||
|
dragging: bool;
|
||||||
|
anchor: POINT;
|
||||||
|
drawn: Overlay_Layout; // what is on screen, to draw again where it was
|
||||||
|
|
||||||
|
active: bool; // has had the focus; losing it after cancels
|
||||||
|
done, cancelled: bool;
|
||||||
|
result: RECT;
|
||||||
|
whole_screen: bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay_Layout :: struct {
|
||||||
|
valid: bool;
|
||||||
|
dragging: bool;
|
||||||
|
selection: RECT; // the pixels selected, while dragging
|
||||||
|
too_small: bool; // to record: smaller than RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT
|
||||||
|
cursor: POINT;
|
||||||
|
magnifier: RECT; // its frame, and the label under it
|
||||||
|
label: RECT;
|
||||||
|
text: [80] u16;
|
||||||
|
text_count: s32;
|
||||||
|
}
|
||||||
|
|
||||||
|
MAGNIFIER_PIXELS :: 21; // the pixels it shows across: odd, so one is in the middle
|
||||||
|
MAGNIFIER_ZOOM :: 6; // each shown this many pixels wide, at 100%
|
||||||
|
MAGNIFIER_OFFSET :: 24; // from the cursor
|
||||||
|
BORDER :: 2; // the selection's outline, at 100%
|
||||||
|
CLICK_SLOP :: 3; // a drag smaller than this either way is a click
|
||||||
|
|
||||||
|
ACCENT_COLOR :: 0x0000A5FF; // COLORREF, 0x00BBGGRR: orange
|
||||||
|
RECORD_COLOR :: 0x002828E8; // red: choosing a region to record
|
||||||
|
DASH :: 6; // a too-small region's dashes and gaps, at 100%
|
||||||
|
GAP :: 4;
|
||||||
|
LABEL_COLOR :: 0x00202020;
|
||||||
|
FRAME_COLOR :: 0x00FFFFFF;
|
||||||
|
TEXT_COLOR :: 0x00FFFFFF;
|
||||||
|
|
||||||
|
OVERLAY_CLASS :: "mcapScreenCapture";
|
||||||
|
|
||||||
|
// Copies every screen into a bitmap, and a darkened copy of it.
|
||||||
|
overlay_freeze :: () -> bool {
|
||||||
|
overlay.left = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||||
|
overlay.top = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||||
|
overlay.width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||||
|
overlay.height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||||
|
if overlay.width <= 0 || overlay.height <= 0 return false;
|
||||||
|
|
||||||
|
desktop := GetDC(null);
|
||||||
|
if !desktop return false;
|
||||||
|
defer ReleaseDC(null, desktop);
|
||||||
|
|
||||||
|
dim_pixels: *u32;
|
||||||
|
ok: bool;
|
||||||
|
ok, overlay.screen_dc, overlay.screen_bitmap, overlay.screen_old, overlay.screen_pixels = overlay_bitmap(desktop);
|
||||||
|
if !ok return false;
|
||||||
|
ok, overlay.dim_dc, overlay.dim_bitmap, overlay.dim_old, dim_pixels = overlay_bitmap(desktop);
|
||||||
|
if !ok return false;
|
||||||
|
back_pixels: *u32;
|
||||||
|
ok, overlay.back_dc, overlay.back_bitmap, overlay.back_old, back_pixels = overlay_bitmap(desktop);
|
||||||
|
if !ok return false;
|
||||||
|
|
||||||
|
// CAPTUREBLT takes layered windows too, such as tooltips and menus with shadows.
|
||||||
|
if !BitBlt(overlay.screen_dc, 0, 0, overlay.width, overlay.height, desktop, overlay.left, overlay.top, cast(ROP) (cast(u32) ROP.SRCCOPY | CAPTUREBLT)) return false;
|
||||||
|
|
||||||
|
count := cast(s64) overlay.width * overlay.height;
|
||||||
|
for 0..count - 1 dim_pixels[it] = (overlay.screen_pixels[it] >> 1) & 0x7F7F7F7F; // half as bright
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_bitmap :: (desktop: HDC) -> ok: bool, dc: HDC, bitmap: HBITMAP, old: HGDIOBJ, pixels: *u32 {
|
||||||
|
info: BITMAPINFO;
|
||||||
|
info.bmiHeader.biSize = size_of(BITMAPINFOHEADER);
|
||||||
|
info.bmiHeader.biWidth = overlay.width;
|
||||||
|
info.bmiHeader.biHeight = -overlay.height; // top-down
|
||||||
|
info.bmiHeader.biPlanes = 1;
|
||||||
|
info.bmiHeader.biBitCount = 32;
|
||||||
|
info.bmiHeader.biCompression = BI_RGB;
|
||||||
|
|
||||||
|
dc := CreateCompatibleDC(desktop);
|
||||||
|
if !dc return false, null, null, null, null;
|
||||||
|
bits: *void;
|
||||||
|
bitmap := CreateDIBSection(dc, *info, DIB_RGB_COLORS, *bits, null, 0);
|
||||||
|
if !bitmap || !bits { DeleteDC(dc); return false, null, null, null, null; }
|
||||||
|
old := SelectObject(dc, bitmap);
|
||||||
|
return true, dc, bitmap, old, cast(*u32) bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_open :: () -> bool {
|
||||||
|
instance := GetModuleHandleW(null);
|
||||||
|
if !overlay.class_registered {
|
||||||
|
wc: WNDCLASSEXW;
|
||||||
|
wc.cbSize = size_of(WNDCLASSEXW);
|
||||||
|
wc.lpfnWndProc = xx overlay_proc;
|
||||||
|
wc.hInstance = instance;
|
||||||
|
wc.hCursor = LoadCursorW(null, IDC_CROSS);
|
||||||
|
wc.lpszClassName = utf8_to_wide(OVERLAY_CLASS,, context.default_allocator);
|
||||||
|
if RegisterClassExW(*wc) == 0 return false;
|
||||||
|
overlay.class_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.cursor = .{};
|
||||||
|
overlay.dragging = false;
|
||||||
|
overlay.drawn = .{};
|
||||||
|
overlay.active = false;
|
||||||
|
overlay.done = false;
|
||||||
|
overlay.cancelled = false;
|
||||||
|
overlay.whole_screen = false;
|
||||||
|
|
||||||
|
// Topmost, and a tool window so it is not in the taskbar or Alt+Tab.
|
||||||
|
overlay.hwnd = CreateWindowExW(WS_EX_TOPMOST | WS_EX_TOOLWINDOW, utf8_to_wide(OVERLAY_CLASS,, temp), utf8_to_wide("Screen capture",, temp),
|
||||||
|
WS_POPUP, overlay.left, overlay.top, overlay.width, overlay.height, null, null, instance, null);
|
||||||
|
if !overlay.hwnd return false;
|
||||||
|
|
||||||
|
dpi := GetDpiForWindow(overlay.hwnd);
|
||||||
|
overlay.scale = ifx dpi then dpi / 96.0 else 1.0;
|
||||||
|
font_height := cast(s32) (15 * overlay.scale + 0.5);
|
||||||
|
overlay.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
|
||||||
|
if overlay.font overlay.font_old = SelectObject(overlay.back_dc, overlay.font);
|
||||||
|
overlay.accent_brush = CreateSolidBrush(ifx overlay.video then cast(u32) RECORD_COLOR else ACCENT_COLOR);
|
||||||
|
overlay.black_brush = CreateSolidBrush(0);
|
||||||
|
overlay.label_brush = CreateSolidBrush(LABEL_COLOR);
|
||||||
|
overlay.frame_brush = CreateSolidBrush(FRAME_COLOR);
|
||||||
|
|
||||||
|
cursor: POINT;
|
||||||
|
GetCursorPos(*cursor);
|
||||||
|
overlay.cursor = .{cursor.x - overlay.left, cursor.y - overlay.top};
|
||||||
|
|
||||||
|
ShowWindow(overlay.hwnd, SW_SHOW);
|
||||||
|
SetForegroundWindow(overlay.hwnd); // allowed: the hotkey made this the app the user last used
|
||||||
|
overlay_update();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_release :: () {
|
||||||
|
release :: (dc: *HDC, bitmap: *HBITMAP, old: HGDIOBJ) {
|
||||||
|
if dc.* {
|
||||||
|
SelectObject(dc.*, old);
|
||||||
|
DeleteDC(dc.*);
|
||||||
|
dc.* = null;
|
||||||
|
}
|
||||||
|
if bitmap.* { DeleteObject(bitmap.*); bitmap.* = null; }
|
||||||
|
}
|
||||||
|
if overlay.font {
|
||||||
|
if overlay.back_dc SelectObject(overlay.back_dc, overlay.font_old);
|
||||||
|
DeleteObject(overlay.font);
|
||||||
|
overlay.font = null;
|
||||||
|
}
|
||||||
|
release(*overlay.screen_dc, *overlay.screen_bitmap, overlay.screen_old);
|
||||||
|
release(*overlay.dim_dc, *overlay.dim_bitmap, overlay.dim_old);
|
||||||
|
release(*overlay.back_dc, *overlay.back_bitmap, overlay.back_old);
|
||||||
|
overlay.screen_pixels = null;
|
||||||
|
for brush: HBRUSH.[overlay.accent_brush, overlay.black_brush, overlay.label_brush, overlay.frame_brush] if brush DeleteObject(brush);
|
||||||
|
overlay.accent_brush, overlay.black_brush, overlay.label_brush, overlay.frame_brush = null, null, null, null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call {
|
||||||
|
if !overlay.app_context return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||||
|
push_context overlay.app_context.* {
|
||||||
|
if message == {
|
||||||
|
case WM_SETCURSOR;
|
||||||
|
SetCursor(LoadCursorW(null, IDC_CROSS));
|
||||||
|
return 1;
|
||||||
|
case WM_ERASEBKGND;
|
||||||
|
return 1;
|
||||||
|
case WM_DPICHANGED;
|
||||||
|
return 0; // the window keeps covering the desktop, whatever DPI it counts as at
|
||||||
|
case WM_PAINT;
|
||||||
|
overlay_paint();
|
||||||
|
return 0;
|
||||||
|
case WM_ACTIVATE;
|
||||||
|
if (wParam & 0xFFFF) == WA_INACTIVE {
|
||||||
|
// Another window came in front: a stuck overlay would hide it. Not once done, as closing
|
||||||
|
// the overlay sends this too.
|
||||||
|
if overlay.active && !overlay.done overlay_cancel();
|
||||||
|
} else {
|
||||||
|
overlay.active = true;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_MOUSEMOVE;
|
||||||
|
overlay.cursor = overlay_point(lParam);
|
||||||
|
overlay_update();
|
||||||
|
return 0;
|
||||||
|
case WM_LBUTTONDOWN;
|
||||||
|
overlay.cursor = overlay_point(lParam);
|
||||||
|
overlay.anchor = overlay.cursor;
|
||||||
|
overlay.dragging = true;
|
||||||
|
SetCapture(hwnd);
|
||||||
|
overlay_update();
|
||||||
|
return 0;
|
||||||
|
case WM_LBUTTONUP;
|
||||||
|
if overlay.dragging {
|
||||||
|
ReleaseCapture();
|
||||||
|
overlay.cursor = overlay_point(lParam);
|
||||||
|
overlay_take();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_RBUTTONDOWN;
|
||||||
|
if overlay.dragging { // let go of the selection, and start again
|
||||||
|
overlay.dragging = false;
|
||||||
|
ReleaseCapture();
|
||||||
|
overlay_update();
|
||||||
|
} else {
|
||||||
|
overlay_cancel();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_KEYDOWN;
|
||||||
|
overlay_key(wParam);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_point :: (lParam: LPARAM) -> POINT {
|
||||||
|
return .{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)}; // signed: a capture reaches past the edges
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_key :: (key: WPARAM) {
|
||||||
|
step: s32 = ifx GetKeyState(VK_SHIFT) < 0 then cast(s32) 10 else 1;
|
||||||
|
dx, dy: s32;
|
||||||
|
if key == {
|
||||||
|
case VK_ESCAPE; overlay_cancel(); return;
|
||||||
|
case VK_RETURN;
|
||||||
|
if overlay.dragging ReleaseCapture();
|
||||||
|
overlay_take();
|
||||||
|
return;
|
||||||
|
case VK_LEFT; dx = -step;
|
||||||
|
case VK_RIGHT; dx = step;
|
||||||
|
case VK_UP; dy = -step;
|
||||||
|
case VK_DOWN; dy = step;
|
||||||
|
case; return;
|
||||||
|
}
|
||||||
|
// Moving the cursor sends WM_MOUSEMOVE, which does the rest.
|
||||||
|
x := clamp(overlay.cursor.x + dx, 0, overlay.width - 1);
|
||||||
|
y := clamp(overlay.cursor.y + dy, 0, overlay.height - 1);
|
||||||
|
capture_set_cursor_pos(overlay.left + x, overlay.top + y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The overlay's own message loop, until a region is taken or it is cancelled; then it is closed.
|
||||||
|
overlay_choose :: () {
|
||||||
|
while !overlay.done {
|
||||||
|
msg: MSG;
|
||||||
|
got := GetMessageW(*msg, null, 0, 0);
|
||||||
|
if got == -1 { overlay.cancelled = true; break; }
|
||||||
|
if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again
|
||||||
|
PostQuitMessage(cast(s32) msg.wParam);
|
||||||
|
overlay.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TranslateMessage(*msg);
|
||||||
|
DispatchMessageW(*msg);
|
||||||
|
}
|
||||||
|
DestroyWindow(overlay.hwnd);
|
||||||
|
overlay.hwnd = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_cancel :: () {
|
||||||
|
overlay.cancelled = true;
|
||||||
|
overlay.done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The selection, or on a click the screen under the cursor. A region too small to record is not
|
||||||
|
// taken: the overlay stays, to drag again.
|
||||||
|
overlay_take :: () {
|
||||||
|
selection := overlay_selection();
|
||||||
|
click := !overlay.dragging || overlay_is_click(selection);
|
||||||
|
if overlay.video && !click && overlay_too_small(selection) {
|
||||||
|
overlay.dragging = false;
|
||||||
|
overlay_update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if click {
|
||||||
|
selection = overlay_monitor_rect(overlay.cursor);
|
||||||
|
overlay.whole_screen = true;
|
||||||
|
}
|
||||||
|
overlay.dragging = false;
|
||||||
|
selection.left = clamp(selection.left, 0, overlay.width);
|
||||||
|
selection.right = clamp(selection.right, 0, overlay.width);
|
||||||
|
selection.top = clamp(selection.top, 0, overlay.height);
|
||||||
|
selection.bottom = clamp(selection.bottom, 0, overlay.height);
|
||||||
|
overlay.result = selection;
|
||||||
|
overlay.done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the anchor to the cursor, both pixels in it. To record, on the screen the drag began on.
|
||||||
|
overlay_selection :: () -> RECT {
|
||||||
|
a, c := overlay.anchor, overlay.cursor;
|
||||||
|
bounds := ifx overlay.video then overlay_monitor_rect(a) else RECT.{0, 0, overlay.width, overlay.height};
|
||||||
|
c.x = clamp(c.x, bounds.left, bounds.right - 1);
|
||||||
|
c.y = clamp(c.y, bounds.top, bounds.bottom - 1);
|
||||||
|
return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_is_click :: (selection: RECT) -> bool {
|
||||||
|
return selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_too_small :: (selection: RECT) -> bool {
|
||||||
|
return !record_size_allowed(selection.right - selection.left, selection.bottom - selection.top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The screen a point is on, in the overlay's pixels.
|
||||||
|
overlay_monitor_rect :: (point: POINT) -> RECT {
|
||||||
|
monitor := MonitorFromPoint(.{point.x + overlay.left, point.y + overlay.top}, MONITOR_DEFAULTTONEAREST);
|
||||||
|
info: MONITORINFO;
|
||||||
|
info.cbSize = size_of(MONITORINFO);
|
||||||
|
if !monitor || !GetMonitorInfoW(monitor, *info) return .{0, 0, overlay.width, overlay.height};
|
||||||
|
r := info.rcMonitor;
|
||||||
|
return .{r.left - overlay.left, r.top - overlay.top, r.right - overlay.left, r.bottom - overlay.top};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Works out what to draw for the cursor and selection now, and has what changed drawn again: where
|
||||||
|
// each part was, and where it is.
|
||||||
|
overlay_update :: () {
|
||||||
|
layout: Overlay_Layout;
|
||||||
|
layout.valid = true;
|
||||||
|
layout.dragging = overlay.dragging;
|
||||||
|
layout.cursor = .{clamp(overlay.cursor.x, 0, overlay.width - 1), clamp(overlay.cursor.y, 0, overlay.height - 1)};
|
||||||
|
if layout.dragging {
|
||||||
|
layout.selection = overlay_selection();
|
||||||
|
layout.too_small = overlay.video && !overlay_is_click(layout.selection) && overlay_too_small(layout.selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
text: string;
|
||||||
|
if layout.dragging text = tprint("% × %", layout.selection.right - layout.selection.left, layout.selection.bottom - layout.selection.top);
|
||||||
|
else text = tprint("%, %", layout.cursor.x + overlay.left, layout.cursor.y + overlay.top);
|
||||||
|
if layout.too_small text = tprint("% · at least % × %", text, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT);
|
||||||
|
wide := utf8_to_wide(text,, temp);
|
||||||
|
while wide && wide[layout.text_count] && layout.text_count < layout.text.count layout.text_count += 1;
|
||||||
|
memcpy(layout.text.data, wide, layout.text_count * size_of(u16));
|
||||||
|
|
||||||
|
// The magnifier below and right of the cursor, or on the other side where the screen ends.
|
||||||
|
zoom := overlay_zoom();
|
||||||
|
side := MAGNIFIER_PIXELS * zoom + 2; // and its frame
|
||||||
|
offset := cast(s32) (MAGNIFIER_OFFSET * overlay.scale);
|
||||||
|
padding := cast(s32) (4 * overlay.scale);
|
||||||
|
extent: SIZE;
|
||||||
|
GetTextExtentPoint32W(overlay.back_dc, layout.text.data, layout.text_count, *extent);
|
||||||
|
label_height := extent.cy + 2 * padding;
|
||||||
|
screen := overlay_monitor_rect(layout.cursor);
|
||||||
|
x := layout.cursor.x + offset;
|
||||||
|
y := layout.cursor.y + offset;
|
||||||
|
if x + side > screen.right x = layout.cursor.x - offset - side;
|
||||||
|
if y + side + label_height > screen.bottom y = layout.cursor.y - offset - side - label_height;
|
||||||
|
layout.magnifier = .{x, y, x + side, y + side};
|
||||||
|
label_width := max(side, extent.cx + 2 * padding);
|
||||||
|
layout.label = .{x, y + side, x + label_width, y + side + label_height};
|
||||||
|
|
||||||
|
previous := overlay.drawn;
|
||||||
|
overlay.drawn = layout;
|
||||||
|
if previous.valid overlay_invalidate(previous);
|
||||||
|
overlay_invalidate(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_zoom :: () -> s32 {
|
||||||
|
return max(cast(s32) (MAGNIFIER_ZOOM * overlay.scale + 0.5), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_invalidate :: (layout: Overlay_Layout) {
|
||||||
|
invalidate :: (r: RECT) {
|
||||||
|
rect := r;
|
||||||
|
InvalidateRect(overlay.hwnd, *rect, .FALSE);
|
||||||
|
}
|
||||||
|
border := overlay_border();
|
||||||
|
if layout.dragging {
|
||||||
|
s := layout.selection;
|
||||||
|
invalidate(.{s.left - border, s.top - border, s.right + border, s.bottom + border});
|
||||||
|
} else {
|
||||||
|
invalidate(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1});
|
||||||
|
invalidate(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height});
|
||||||
|
}
|
||||||
|
invalidate(layout.magnifier);
|
||||||
|
invalidate(layout.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_border :: () -> s32 {
|
||||||
|
return max(cast(s32) (BORDER * overlay.scale + 0.5), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws what has changed into the back bitmap, clipped to it, and puts it on screen.
|
||||||
|
overlay_paint :: () {
|
||||||
|
region := CreateRectRgn(0, 0, 0, 0);
|
||||||
|
if region GetUpdateRgn(overlay.hwnd, region, .FALSE);
|
||||||
|
paint: PAINTSTRUCT;
|
||||||
|
dc := BeginPaint(overlay.hwnd, *paint);
|
||||||
|
if region SelectClipRgn(overlay.back_dc, region);
|
||||||
|
overlay_draw(overlay.back_dc, overlay.drawn);
|
||||||
|
r := paint.rcPaint;
|
||||||
|
BitBlt(dc, r.left, r.top, r.right - r.left, r.bottom - r.top, overlay.back_dc, r.left, r.top, .SRCCOPY);
|
||||||
|
SelectClipRgn(overlay.back_dc, null);
|
||||||
|
if region DeleteObject(region);
|
||||||
|
EndPaint(overlay.hwnd, *paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_draw :: (dc: HDC, layout: Overlay_Layout) {
|
||||||
|
fill :: (r: RECT, brush: HBRUSH) #expand {
|
||||||
|
rect := r;
|
||||||
|
FillRect(`dc, *rect, brush);
|
||||||
|
}
|
||||||
|
BitBlt(dc, 0, 0, overlay.width, overlay.height, overlay.dim_dc, 0, 0, .SRCCOPY);
|
||||||
|
if !layout.valid return;
|
||||||
|
|
||||||
|
border := overlay_border();
|
||||||
|
if layout.dragging {
|
||||||
|
s := layout.selection;
|
||||||
|
BitBlt(dc, s.left, s.top, s.right - s.left, s.bottom - s.top, overlay.screen_dc, s.left, s.top, .SRCCOPY);
|
||||||
|
// The outline just outside it, so every pixel taken shows as it is; dashed when too small to record.
|
||||||
|
outline := RECT.[
|
||||||
|
.{s.left - border, s.top - border, s.right + border, s.top},
|
||||||
|
.{s.left - border, s.bottom, s.right + border, s.bottom + border},
|
||||||
|
.{s.left - border, s.top, s.left, s.bottom},
|
||||||
|
.{s.right, s.top, s.right + border, s.bottom},
|
||||||
|
];
|
||||||
|
for outline {
|
||||||
|
if !layout.too_small { fill(it, overlay.accent_brush); continue; }
|
||||||
|
dash := max(cast(s32) (DASH * overlay.scale + 0.5), 2);
|
||||||
|
step := dash + max(cast(s32) (GAP * overlay.scale + 0.5), 2);
|
||||||
|
across := it.right - it.left > it.bottom - it.top;
|
||||||
|
start := ifx across then it.left else it.top;
|
||||||
|
end := ifx across then it.right else it.bottom;
|
||||||
|
position := start;
|
||||||
|
while position < end {
|
||||||
|
piece := it;
|
||||||
|
if across { piece.left = position; piece.right = min(position + dash, end); }
|
||||||
|
else { piece.top = position; piece.bottom = min(position + dash, end); }
|
||||||
|
fill(piece, overlay.accent_brush);
|
||||||
|
position += step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fill(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}, overlay.accent_brush);
|
||||||
|
fill(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}, overlay.accent_brush);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The magnifier: the pixels around the cursor, as copied, each zoom pixels wide. Past the
|
||||||
|
// desktop's edges it is black.
|
||||||
|
m := layout.magnifier;
|
||||||
|
fill(m, overlay.frame_brush);
|
||||||
|
inner := RECT.{m.left + 1, m.top + 1, m.right - 1, m.bottom - 1};
|
||||||
|
fill(inner, overlay.black_brush);
|
||||||
|
zoom := overlay_zoom();
|
||||||
|
half :: MAGNIFIER_PIXELS / 2;
|
||||||
|
source := RECT.{layout.cursor.x - half, layout.cursor.y - half, layout.cursor.x + half + 1, layout.cursor.y + half + 1};
|
||||||
|
shown := RECT.{max(source.left, 0), max(source.top, 0), min(source.right, overlay.width), min(source.bottom, overlay.height)};
|
||||||
|
if shown.right > shown.left && shown.bottom > shown.top {
|
||||||
|
SetStretchBltMode(dc, .COLORONCOLOR);
|
||||||
|
StretchBlt(dc, inner.left + (shown.left - source.left) * zoom, inner.top + (shown.top - source.top) * zoom,
|
||||||
|
(shown.right - shown.left) * zoom, (shown.bottom - shown.top) * zoom,
|
||||||
|
overlay.screen_dc, shown.left, shown.top, shown.right - shown.left, shown.bottom - shown.top, .SRCCOPY);
|
||||||
|
}
|
||||||
|
// The pixel under the cursor, outlined.
|
||||||
|
c := RECT.{inner.left + half * zoom, inner.top + half * zoom, inner.left + (half + 1) * zoom, inner.top + (half + 1) * zoom};
|
||||||
|
fill(.{c.left - 1, c.top - 1, c.right + 1, c.top}, overlay.accent_brush);
|
||||||
|
fill(.{c.left - 1, c.bottom, c.right + 1, c.bottom + 1}, overlay.accent_brush);
|
||||||
|
fill(.{c.left - 1, c.top, c.left, c.bottom}, overlay.accent_brush);
|
||||||
|
fill(.{c.right, c.top, c.right + 1, c.bottom}, overlay.accent_brush);
|
||||||
|
|
||||||
|
// Under it, the position, or the selection's size.
|
||||||
|
fill(layout.label, overlay.label_brush);
|
||||||
|
SetBkMode(dc, TRANSPARENT);
|
||||||
|
SetTextColor(dc, TEXT_COLOR);
|
||||||
|
text := layout.text;
|
||||||
|
padding := cast(s32) (4 * overlay.scale);
|
||||||
|
TextOutW(dc, layout.label.left + padding, layout.label.top + padding, text.data, layout.text_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window's title and its program.
|
||||||
|
overlay_window_info :: (shot: *Screen_Shot, window: HWND) {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
title: [1024] u16;
|
||||||
|
count := GetWindowTextW(window, title.data, title.count);
|
||||||
|
if count > 0 {
|
||||||
|
text, ok := wide_to_utf8(title.data, count,, temp);
|
||||||
|
if ok shot.window_title = copy_string(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
process_id: u32;
|
||||||
|
GetWindowThreadProcessId(window, *process_id);
|
||||||
|
if !process_id return;
|
||||||
|
process := OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, .FALSE, process_id);
|
||||||
|
if !process return;
|
||||||
|
defer CloseHandle(process);
|
||||||
|
path: [1024] u16;
|
||||||
|
length: u32 = path.count;
|
||||||
|
if !QueryFullProcessImageNameW(process, 0, path.data, *length) return;
|
||||||
|
text, ok := wide_to_utf8(path.data, cast(s32) length,, temp);
|
||||||
|
if !ok return;
|
||||||
|
shot.app_path = copy_string(text);
|
||||||
|
description := overlay_file_description(path.data);
|
||||||
|
shot.app_name = copy_string(ifx description then description else path_strip_extension(path_filename(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A program's description, as Task Manager shows it: "Google Chrome" for chrome.exe. "" when it has none. Temp.
|
||||||
|
overlay_file_description :: (path: *u16) -> string {
|
||||||
|
handle: u32;
|
||||||
|
size := GetFileVersionInfoSizeW(path, *handle);
|
||||||
|
if !size return "";
|
||||||
|
data := NewArray(size, u8, initialized = false,, temp);
|
||||||
|
if !GetFileVersionInfoW(path, 0, size, data.data) return "";
|
||||||
|
|
||||||
|
language := "040904b0"; // US English, Unicode: when it does not say
|
||||||
|
translation: *u16;
|
||||||
|
length: u32;
|
||||||
|
if VerQueryValueW(data.data, utf8_to_wide("\\VarFileInfo\\Translation",, temp), xx *translation, *length) != .FALSE && length >= 4 {
|
||||||
|
language = tprint("%1%2", formatInt(translation[0], base = 16, minimum_digits = 4), formatInt(translation[1], base = 16, minimum_digits = 4));
|
||||||
|
}
|
||||||
|
value: *u16;
|
||||||
|
key := utf8_to_wide(tprint("\\StringFileInfo\\%\\FileDescription", language),, temp);
|
||||||
|
if !VerQueryValueW(data.data, key, xx *value, *length) || length <= 1 return "";
|
||||||
|
text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator
|
||||||
|
return ifx ok then trim(text) else "";
|
||||||
|
}
|
||||||
438
src/app/screen_record.jai
Normal file
438
src/app/screen_record.jai
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
// Screen recording (video-capture-plan.html, macos-plan phase 7): Ctrl+PrintScreen on Windows,
|
||||||
|
// Opt+Cmd+Shift+2 on macOS (the record_region global hotkey, from any app, even with mcap hidden in the
|
||||||
|
// tray) freezes the screens as a still's key does, in the overlay's video mode
|
||||||
|
// (app/screen_capture_win32.jai, app/screen_capture_macos.jai): a region of one screen, at least 240 x 180, or
|
||||||
|
// a click for the whole screen. It is recorded with modules/Screen_Record to an MP4 in the temporary
|
||||||
|
// folder until Ctrl+PrintScreen is pressed again. The MP4 then goes into the library as a video .mcap
|
||||||
|
// in SCREEN_CAPTURE_FOLDER, beside the stills, through app/screen_capture.jai's jobs: the MP4 as its
|
||||||
|
// original, a frame from it as its rendered image (which OCR and labels read), the window it was
|
||||||
|
// recorded from in metadata.json, as for a still, and how it was recorded in capture.json.
|
||||||
|
//
|
||||||
|
// While it records, a red border is drawn just outside the region and a toolbar beside it gives the
|
||||||
|
// time, Pause, Stop and Discard (app/screen_record_win32.jai, app/screen_record_macos.jai); both are
|
||||||
|
// kept out of the recording.
|
||||||
|
// Time paused is left out of the video. With sound: what the computer plays and the microphone, as
|
||||||
|
// [video] in the settings file says, as are the frame rate, the cursor and how the screen is taken.
|
||||||
|
// The toolbar's System sound and Mic buttons turn each on and off while recording, and are kept in
|
||||||
|
// [video] for the next: every recording has a sound track for them, silent while nothing is on.
|
||||||
|
//
|
||||||
|
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
|
||||||
|
// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
|
||||||
|
// start (screen_record_leftovers_popup). PrintScreen while recording takes the screen under the cursor
|
||||||
|
// at once, without the overlay, whose frozen screen would be in the recording.
|
||||||
|
//
|
||||||
|
// Windows and macOS, as modules/Screen_Record is.
|
||||||
|
|
||||||
|
SCREEN_RECORD_PREFIX :: "recording-"; // the MP4s in the temporary folder: recording-2026-10-10_07-12-43.mp4
|
||||||
|
|
||||||
|
screen_record: struct {
|
||||||
|
recording: bool;
|
||||||
|
flag_started: bool; // -record has started its recording
|
||||||
|
flag_start: Apollo_Time;
|
||||||
|
leftovers: [..] string; // MP4s a crash left in the temporary folder, to offer; heap
|
||||||
|
leftovers_asked: bool;
|
||||||
|
shot: Screen_Shot; // the region and the window it is of; no pixels
|
||||||
|
date: string; // when it started, as CaptureDate; heap
|
||||||
|
path: string; // the MP4 being written; heap
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS recorder: Recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From app_frame when the hotkey fired, outside the ImGui frame: starts recording, or stops.
|
||||||
|
screen_record_toggle :: () {
|
||||||
|
if screen_record.recording screen_record_stop();
|
||||||
|
else screen_record_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every frame (from screen_capture_frame): a recording that stopped by itself is finished and kept.
|
||||||
|
screen_record_frame :: () {
|
||||||
|
if app.record_and_exit > 0 screen_record_flag_frame();
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
if !screen_record.recording return;
|
||||||
|
#if OS == .MACOS record_ui_tick();
|
||||||
|
failed, why := record_failed(*screen_record.recorder);
|
||||||
|
if failed {
|
||||||
|
toast_show(.Error, "The recording stopped: %", why);
|
||||||
|
screen_record_stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for kind: Audio_Source_Kind.[.SYSTEM, .MICROPHONE] {
|
||||||
|
failed, why := record_audio_failed(*screen_record.recorder, kind);
|
||||||
|
if !failed continue;
|
||||||
|
toast_show(.Error, "% could not be turned on: %", screen_record_audio_name(kind), why);
|
||||||
|
screen_record_remember_audio(kind, false);
|
||||||
|
record_ui_refresh();
|
||||||
|
}
|
||||||
|
ask := record_ui_take_ask();
|
||||||
|
if ask == .SYSTEM_SOUND || ask == .MICROPHONE {
|
||||||
|
kind: Audio_Source_Kind = ifx ask == .SYSTEM_SOUND then .SYSTEM else .MICROPHONE;
|
||||||
|
on := !record_audio_on(*screen_record.recorder, kind);
|
||||||
|
if record_set_audio(*screen_record.recorder, kind, on) {
|
||||||
|
log("[record] % turned %.", screen_record_audio_name(kind), ifx on then "on" else "off");
|
||||||
|
toast_show(.Success, "% %", screen_record_audio_name(kind), ifx on then "on" else "off");
|
||||||
|
screen_record_remember_audio(kind, on);
|
||||||
|
}
|
||||||
|
record_ui_refresh();
|
||||||
|
}
|
||||||
|
if ask == {
|
||||||
|
case .PAUSE;
|
||||||
|
pause := !record_paused(*screen_record.recorder);
|
||||||
|
record_pause(*screen_record.recorder, pause);
|
||||||
|
log("[record] %.", ifx pause then "Paused" else "Resumed");
|
||||||
|
record_ui_refresh();
|
||||||
|
case .STOP; screen_record_stop();
|
||||||
|
case .DISCARD; screen_record_discard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -record SECONDS: the screen under the cursor, without the overlay, for that long, or the region of
|
||||||
|
// -record-region X Y W H; then screen_record_saved ends the app once it is in the library. With
|
||||||
|
// -record-quit, the app is quit while still recording instead, as quitting from the tray would: the
|
||||||
|
// shutdown keeps the recording. -record-ui-visible leaves the border and toolbar in screenshots, to
|
||||||
|
// look at them.
|
||||||
|
screen_record_flag_frame :: () {
|
||||||
|
if !screen_record.flag_started {
|
||||||
|
if viewer.catalog_loaded < viewer.captures.count return; // the library is up, so the grid can take it
|
||||||
|
screen_record.flag_started = true;
|
||||||
|
screen_record.flag_start = current_time_monotonic();
|
||||||
|
screen_record_start(immediate = true);
|
||||||
|
if !screen_record.recording {
|
||||||
|
log_error("[record] -record could not start.");
|
||||||
|
app.exit_code = 1; app.should_exit = true; app.record_and_exit = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen_record.recording && to_float64_seconds(current_time_monotonic() - screen_record.flag_start) >= app.record_and_exit {
|
||||||
|
if array_find(app.args, "-record-quit") { log("[record] -record-quit: quitting while recording."); app.should_exit = true; app.record_and_exit = 0; }
|
||||||
|
else screen_record_stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recording is in the library (path) or could not be put there (error): -record's end.
|
||||||
|
screen_record_saved :: (path: string, error: string) {
|
||||||
|
if app.record_and_exit <= 0 return;
|
||||||
|
if error log_error("[record] -record failed: %", error);
|
||||||
|
else log("[record] -record is done: %", path);
|
||||||
|
app.exit_code = ifx error then cast(s32) 1 else 0;
|
||||||
|
app.should_exit = true;
|
||||||
|
app.record_and_exit = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the shutdown thread: a recording running is stopped and kept.
|
||||||
|
screen_record_shutdown :: () {
|
||||||
|
if screen_record.recording screen_record_stop(shutdown = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At startup: recordings a crash left in the temporary folder, to offer (screen_record_leftovers_popup).
|
||||||
|
screen_record_startup :: () {
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
screen_record.leftovers.allocator = context.default_allocator;
|
||||||
|
for file_list(screen_record_folder(),, temp) {
|
||||||
|
name := path_filename(it);
|
||||||
|
if !begins_with(name, SCREEN_RECORD_PREFIX) || !ends_with_nocase(name, ".mp4") continue;
|
||||||
|
array_add(*screen_record.leftovers, copy_string(it,, context.default_allocator));
|
||||||
|
}
|
||||||
|
if screen_record.leftovers.count log("[record] % recording% left in % by an earlier run.", screen_record.leftovers.count, ifx screen_record.leftovers.count == 1 then "" else "s", screen_record_folder());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offer of what screen_record_startup found: into the library, or deleted, or asked again next
|
||||||
|
// time. A modal popup, at the top level, each frame.
|
||||||
|
screen_record_leftovers_popup :: () { using ImGui;
|
||||||
|
POPUP :: "Unsaved recordings";
|
||||||
|
if !screen_record.leftovers.count || app.record_and_exit > 0 return;
|
||||||
|
if !screen_record.leftovers_asked && !IsPopupOpen(POPUP) { OpenPopup(POPUP); screen_record.leftovers_asked = true; }
|
||||||
|
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
|
||||||
|
count := screen_record.leftovers.count;
|
||||||
|
Text(ifx count == 1 then "A screen recording was not saved: mcap closed while it was recording." else tprint("% screen recordings were not saved: mcap closed while they were recording.", count));
|
||||||
|
for screen_record.leftovers {
|
||||||
|
_, size, _ := file_modtime_and_size(it);
|
||||||
|
TextColored(app_theme.palette.dim, "% (% MB)", screen_record_leftover_date(it), FF(size / (1024.0 * 1024.0), 1));
|
||||||
|
}
|
||||||
|
TextColored(app_theme.palette.dim, "Each plays up to its last two seconds or so.");
|
||||||
|
Spacing();
|
||||||
|
done := false;
|
||||||
|
if Button("Save to the library") {
|
||||||
|
for screen_record.leftovers {
|
||||||
|
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||||
|
job.video = true;
|
||||||
|
job.recording = it; // the job's now
|
||||||
|
job.date = copy_string(screen_record_leftover_date(it),, context.default_allocator);
|
||||||
|
screen_capture_job_start(job);
|
||||||
|
}
|
||||||
|
array_reset_keeping_memory(*screen_record.leftovers);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
SameLine();
|
||||||
|
if Button("Delete") {
|
||||||
|
for screen_record.leftovers { file_delete(it); free(it,, context.default_allocator); }
|
||||||
|
array_reset_keeping_memory(*screen_record.leftovers);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
SameLine();
|
||||||
|
if Button("Later") || IsKeyPressed(.Escape) {
|
||||||
|
for screen_record.leftovers free(it,, context.default_allocator);
|
||||||
|
array_reset_keeping_memory(*screen_record.leftovers);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
if done CloseCurrentPopup();
|
||||||
|
EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2026-10-10 07:12:43" from recording-2026-10-10_07-12-43.mp4 (or _2.mp4 ...): when it began. Temp.
|
||||||
|
screen_record_leftover_date :: (path: string) -> string {
|
||||||
|
name := path_strip_extension(path_filename(path));
|
||||||
|
stamp := advance(name, SCREEN_RECORD_PREFIX.count);
|
||||||
|
if stamp.count < 19 return name;
|
||||||
|
stamp.count = 19;
|
||||||
|
date := copy_string(stamp,, temp);
|
||||||
|
date[10] = #char " ";
|
||||||
|
date[13] = #char ":";
|
||||||
|
date[16] = #char ":";
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_record_folder :: () -> string {
|
||||||
|
return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
|
||||||
|
// immediate: the screen under the cursor, without the overlay (-record).
|
||||||
|
screen_record_start :: (immediate := false) {
|
||||||
|
if app_exit_shutdown_started() return;
|
||||||
|
date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL));
|
||||||
|
shot, ok, error := platform_screen_capture_select(video = true, immediate = immediate);
|
||||||
|
#if OS == .WINDOWS {
|
||||||
|
r := app.record_region;
|
||||||
|
if ok && immediate && r.right > r.left { // -record-region
|
||||||
|
shot.left, shot.top, shot.width, shot.height = r.left, r.top, r.right - r.left, r.bottom - r.top;
|
||||||
|
shot.whole_screen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if error { log_error("[record] %.", error); toast_show(.Error, "Screen recording: %", error); }
|
||||||
|
else log("[record] Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := screen_record_folder();
|
||||||
|
make_directory_if_it_does_not_exist(folder, recursive = true);
|
||||||
|
stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp);
|
||||||
|
path := tprint("%/%%.mp4", folder, SCREEN_RECORD_PREFIX, stamp);
|
||||||
|
number := 2;
|
||||||
|
while file_exists(path) { path = tprint("%/%%_%.mp4", folder, SCREEN_RECORD_PREFIX, stamp, number); number += 1; }
|
||||||
|
|
||||||
|
options: Record_Options;
|
||||||
|
options.path = path;
|
||||||
|
options.fps = cast(s32) app_user_settings.video_fps;
|
||||||
|
options.cursor = app_user_settings.video_cursor;
|
||||||
|
options.system_audio = app_user_settings.video_system_audio;
|
||||||
|
options.microphone = app_user_settings.video_microphone;
|
||||||
|
options.audio_track = true; // so the toolbar can turn a source on mid-recording
|
||||||
|
#if OS == .MACOS {
|
||||||
|
// The display's region, and the border and toolbar first: ScreenCaptureKit leaves them out of the
|
||||||
|
// recording by their window numbers.
|
||||||
|
options.display_id = shot.display_id;
|
||||||
|
options.x, options.y, options.w, options.h = shot.local_x, shot.local_y, shot.local_width, shot.local_height;
|
||||||
|
options.width, options.height = shot.width, shot.height;
|
||||||
|
options.exclude_windows = record_ui_open(shot, shot.width & cast(s32) ~1, shot.height & cast(s32) ~1);
|
||||||
|
} else {
|
||||||
|
options.region = .{shot.left, shot.top, shot.left + shot.width, shot.top + shot.height};
|
||||||
|
if app_user_settings.video_capture == {
|
||||||
|
case "dxgi"; options.capture = .DXGI;
|
||||||
|
case "wgc"; options.capture = .WGC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started, start_error := record_start(*screen_record.recorder, options);
|
||||||
|
if !started {
|
||||||
|
#if OS == .MACOS record_ui_close();
|
||||||
|
log_error("[record] Could not record % x % at %, %: %", shot.width, shot.height, shot.left, shot.top, start_error);
|
||||||
|
toast_show(.Error, "Could not record: %", start_error);
|
||||||
|
screen_shot_free(*shot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := record_stats(*screen_record.recorder);
|
||||||
|
screen_record.recording = true;
|
||||||
|
screen_record.shot = shot;
|
||||||
|
screen_record.date = copy_string(date,, context.default_allocator);
|
||||||
|
screen_record.path = copy_string(path,, context.default_allocator);
|
||||||
|
if stats.fell_back_from log("[record] Desktop Duplication could not run: %", stats.fell_back_from);
|
||||||
|
log("[record] Recording % x % at %, % (%) of \"%\" (%) with % and %, to %.", stats.width, stats.height, shot.left, shot.top,
|
||||||
|
ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, stats.source, stats.encoder, path);
|
||||||
|
if stats.audio log("[record] Sound: %.", stats.audio);
|
||||||
|
if stats.audio_errors {
|
||||||
|
log_error("[record] Sound not recorded: %.", stats.audio_errors);
|
||||||
|
toast_show(.Error, "Recording without some sound: %", stats.audio_errors);
|
||||||
|
} else {
|
||||||
|
toast_show(.Success, "Recording % x %: % stops", stats.width, stats.height, screen_record_hotkey_text());
|
||||||
|
}
|
||||||
|
#if OS == .WINDOWS record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sound button's state, into [video] for the next recording.
|
||||||
|
screen_record_remember_audio :: (kind: Audio_Source_Kind, on: bool) {
|
||||||
|
setting := ifx kind == .SYSTEM then *app_user_settings.video_system_audio else *app_user_settings.video_microphone;
|
||||||
|
if setting.* == on return;
|
||||||
|
setting.* = on;
|
||||||
|
app_save_user_settings_file();
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_record_audio_name :: (kind: Audio_Source_Kind) -> string {
|
||||||
|
return ifx kind == .SYSTEM then "System sound" else "The microphone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
|
||||||
|
screen_record_discard :: () {
|
||||||
|
if !screen_record.recording return;
|
||||||
|
screen_record.recording = false;
|
||||||
|
record_ui_close();
|
||||||
|
record_stop(*screen_record.recorder);
|
||||||
|
file_delete(screen_record.path);
|
||||||
|
log("[record] Discarded % (% s).", screen_record.path, FF(screen_record.recorder.stats.seconds, 1));
|
||||||
|
toast_show(.Success, "Recording discarded");
|
||||||
|
screen_shot_free(*screen_record.shot);
|
||||||
|
free(screen_record.date,, context.default_allocator);
|
||||||
|
free(screen_record.path,, context.default_allocator);
|
||||||
|
screen_record.date, screen_record.path = "", "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops, and hands the MP4 to a job that puts it in the library. shutdown: on the shutdown thread, so
|
||||||
|
// no toasts, and the border and toolbar, which are the main thread's, are only hidden.
|
||||||
|
screen_record_stop :: (shutdown := false) {
|
||||||
|
if !screen_record.recording return;
|
||||||
|
screen_record.recording = false;
|
||||||
|
quiet := shutdown;
|
||||||
|
if shutdown record_ui_hide();
|
||||||
|
else record_ui_close();
|
||||||
|
stopped, stop_error := record_stop(*screen_record.recorder);
|
||||||
|
stats := screen_record.recorder.stats;
|
||||||
|
if !stopped log_error("[record] %; keeping what was recorded.", stop_error);
|
||||||
|
log("[record] Stopped after % s: % frames written, % dropped, % KB.", FF(stats.seconds, 1), stats.written, stats.dropped, stats.bytes / 1024);
|
||||||
|
|
||||||
|
if !stats.written {
|
||||||
|
log_error("[record] Nothing was recorded; % is deleted.", screen_record.path);
|
||||||
|
screen_record_saved("", "nothing was recorded");
|
||||||
|
if !quiet toast_show(.Error, "Nothing was recorded");
|
||||||
|
file_delete(screen_record.path);
|
||||||
|
screen_shot_free(*screen_record.shot);
|
||||||
|
free(screen_record.date,, context.default_allocator);
|
||||||
|
free(screen_record.path,, context.default_allocator);
|
||||||
|
screen_record.date, screen_record.path = "", "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job := New(Screen_Capture_Job,, context.default_allocator);
|
||||||
|
job.video = true;
|
||||||
|
job.shot = screen_record.shot;
|
||||||
|
job.shot.width, job.shot.height = stats.width, stats.height; // as recorded: rounded down to even
|
||||||
|
job.date = screen_record.date;
|
||||||
|
job.recording = screen_record.path;
|
||||||
|
job.encoder = copy_string(stats.encoder,, context.default_allocator);
|
||||||
|
job.source = copy_string(stats.source,, context.default_allocator);
|
||||||
|
job.dropped = stats.dropped;
|
||||||
|
job.paused_seconds = stats.paused_seconds;
|
||||||
|
job.audio = copy_string(stats.audio,, context.default_allocator);
|
||||||
|
screen_record.shot = .{};
|
||||||
|
screen_record.date, screen_record.path = "", "";
|
||||||
|
screen_capture_job_start(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recording's .mcap in the library, from the job's MP4, which is deleted once it is in: its path,
|
||||||
|
// or why it could not be (temp). On the job's thread.
|
||||||
|
//
|
||||||
|
// The MP4 was written fragmented, so a crash leaves one that plays; it is made a regular MP4 first
|
||||||
|
// (record_remux), with its length and index in its header, as players need to show its time and seek
|
||||||
|
// in it. Should that fail, the MP4 goes in as it was.
|
||||||
|
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||||
|
_, recorded_size, found := file_modtime_and_size(job.recording);
|
||||||
|
if !found || recorded_size <= 0 return "", tprint("% is missing", job.recording);
|
||||||
|
mp4 := job.recording;
|
||||||
|
remuxed := tprint("%.regular.mp4", path_strip_extension(job.recording));
|
||||||
|
remux_ok, remux_error, packets := record_remux(job.recording, remuxed);
|
||||||
|
if remux_ok mp4 = remuxed;
|
||||||
|
else { log_error("[record] % stays fragmented: %", job.recording, remux_error); file_delete(remuxed); }
|
||||||
|
defer if remux_ok file_delete(remuxed);
|
||||||
|
_, size, _ := file_modtime_and_size(mp4);
|
||||||
|
|
||||||
|
image, video, decoded, decode_error := video_poster(mp4);
|
||||||
|
if !decoded return "", tprint("the recording cannot be read back: %", decode_error);
|
||||||
|
defer image_free(*image);
|
||||||
|
preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE);
|
||||||
|
if !preview_ok return "", preview_error;
|
||||||
|
poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size
|
||||||
|
if !poster_ok return "", poster_error;
|
||||||
|
hash, hashed := import_hash_file(.{path = mp4, offset = 0, size = size});
|
||||||
|
if !hashed return "", tprint("% cannot be read", mp4);
|
||||||
|
|
||||||
|
shot := job.shot;
|
||||||
|
known := shot.width > 0; // a crash's leftover knows no region, window or encoder
|
||||||
|
if !known { shot.width, shot.height = image.width, image.height; }
|
||||||
|
doc := New(Capture_Document,, temp);
|
||||||
|
capture_document_init(doc, temp);
|
||||||
|
doc.comment = tprint("Recorded by % %.", APP_NAME, VERSION);
|
||||||
|
metadata := capture_add_file(doc, "metadata.json");
|
||||||
|
import_add(doc, metadata, "AppName", import_text(shot.app_name));
|
||||||
|
import_add(doc, metadata, "CaptureDate", job.date);
|
||||||
|
import_add(doc, metadata, "WindowName", import_text(shot.window_title));
|
||||||
|
info := capture_add_file(doc, "capture.json");
|
||||||
|
if known {
|
||||||
|
import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection");
|
||||||
|
import_add_number(doc, info, "Left", xx shot.left);
|
||||||
|
import_add_number(doc, info, "Top", xx shot.top);
|
||||||
|
}
|
||||||
|
import_add_number(doc, info, "Width", xx shot.width);
|
||||||
|
import_add_number(doc, info, "Height", xx shot.height);
|
||||||
|
if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path));
|
||||||
|
import_add(doc, info, "ContentHash", import_hash_text(hash));
|
||||||
|
import_add(doc, info, "Format", "MP4");
|
||||||
|
import_add_number(doc, info, "Duration", import_milliseconds(video.duration));
|
||||||
|
if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate);
|
||||||
|
import_add(doc, info, "VideoCodec", import_text(video.codec));
|
||||||
|
import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time));
|
||||||
|
if job.encoder import_add(doc, info, "Encoder", import_text(job.encoder));
|
||||||
|
if job.source import_add(doc, info, "Capture", import_text(job.source));
|
||||||
|
import_add_number(doc, info, "DroppedFrames", xx job.dropped);
|
||||||
|
capture_object_add(doc, *info.root, "Audio", capture_boolean(video.has_audio));
|
||||||
|
if job.audio import_add(doc, info, "AudioSources", import_text(job.audio));
|
||||||
|
if job.paused_seconds > 0 import_add_number(doc, info, "Paused", import_milliseconds(job.paused_seconds));
|
||||||
|
|
||||||
|
name := import_out_name(job.date, hash);
|
||||||
|
text, text_ok := import_document_text(doc, name);
|
||||||
|
if !text_ok return "", "its Capture Text does not read back cleanly (see the Event Log)";
|
||||||
|
|
||||||
|
m: Mcap;
|
||||||
|
m.text = text;
|
||||||
|
m.preview = .{width = cast(u16) preview_width, height = cast(u16) preview_height, png = preview};
|
||||||
|
m.original_name = tprint("%.mp4", path_strip_extension(name));
|
||||||
|
m.rendered_kind = .OWN;
|
||||||
|
m.rendered = poster;
|
||||||
|
path, folder_error := screen_capture_out_path(name);
|
||||||
|
if folder_error return "", folder_error;
|
||||||
|
written, write_error := mcap_write_streamed(path, m, .{path = mp4, offset = 0, size = size});
|
||||||
|
if !written return "", write_error;
|
||||||
|
file_delete(job.recording);
|
||||||
|
return path, "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The record_region hotkey as it is bound, for the toast: "Ctrl+PrintScreen". Temp.
|
||||||
|
screen_record_hotkey_text :: () -> string {
|
||||||
|
if !record_region_hotkey.binding_count return "the record_region hotkey";
|
||||||
|
return to_string(record_region_hotkey.bindings[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
screen_record_start :: (immediate := false) {
|
||||||
|
toast_show(.Error, "Screen recording is not available on this system yet");
|
||||||
|
if app.record_and_exit > 0 screen_record_saved("", "screen recording is on Windows and macOS only");
|
||||||
|
}
|
||||||
|
|
||||||
|
screen_record_stop :: (shutdown := false) {}
|
||||||
|
|
||||||
|
screen_record_write :: (job: *Screen_Capture_Job) -> path: string, error: string {
|
||||||
|
return "", "screen recording is on Windows and macOS only";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
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";
|
||||||
335
src/app/screen_record_win32.jai
Normal file
335
src/app/screen_record_win32.jai
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
// While recording (app/screen_record.jai), on Windows: 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. Both are
|
||||||
|
// plain GDI windows, as the overlay is, so they work while mcap is hidden in the tray, and both are
|
||||||
|
// kept out of every capture (WDA_EXCLUDEFROMCAPTURE), the recording's own included. The border is
|
||||||
|
// outside the region anyway, so it could never be in the video.
|
||||||
|
//
|
||||||
|
// Neither takes the focus: the window being recorded keeps it. The border lets clicks through. A
|
||||||
|
// toolbar button only asks (record_ui.asked); screen_record_frame does it, outside the window's own
|
||||||
|
// message, as Stop and Discard close the toolbar. Discard asks first, in the toolbar itself: a
|
||||||
|
// message box would be in the recording.
|
||||||
|
|
||||||
|
Record_UI_Ask :: enum u8 { NONE; SYSTEM_SOUND; MICROPHONE; PAUSE; STOP; DISCARD; }
|
||||||
|
|
||||||
|
record_ui: struct {
|
||||||
|
border, toolbar: HWND;
|
||||||
|
classes_registered: bool;
|
||||||
|
scale: float;
|
||||||
|
font: HFONT;
|
||||||
|
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;
|
||||||
|
size_text: string; // "1920 × 1080"; heap
|
||||||
|
}
|
||||||
|
|
||||||
|
Record_UI_Button :: struct { rect: RECT; label: string; ask: Record_UI_Ask; confirm: bool; }
|
||||||
|
|
||||||
|
// Shows the border around region (on the virtual desktop) and the toolbar beside it.
|
||||||
|
record_ui_open :: (region: RECT, whole_screen: bool, width: s32, height: s32) {
|
||||||
|
record_ui_close();
|
||||||
|
instance := GetModuleHandleW(null);
|
||||||
|
if !record_ui.classes_registered {
|
||||||
|
wc: WNDCLASSEXW;
|
||||||
|
wc.cbSize = size_of(WNDCLASSEXW);
|
||||||
|
wc.lpfnWndProc = xx DefWindowProcW;
|
||||||
|
wc.hInstance = instance;
|
||||||
|
wc.hbrBackground = CreateSolidBrush(RECORD_UI_RED);
|
||||||
|
wc.lpszClassName = utf8_to_wide(RECORD_BORDER_CLASS,, context.default_allocator);
|
||||||
|
RegisterClassExW(*wc);
|
||||||
|
wc.lpfnWndProc = xx record_toolbar_proc;
|
||||||
|
wc.hbrBackground = null;
|
||||||
|
wc.hCursor = LoadCursorW(null, IDC_ARROW);
|
||||||
|
wc.lpszClassName = utf8_to_wide(RECORD_TOOLBAR_CLASS,, context.default_allocator);
|
||||||
|
RegisterClassExW(*wc);
|
||||||
|
record_ui.classes_registered = true;
|
||||||
|
}
|
||||||
|
record_ui.started = current_time_monotonic();
|
||||||
|
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);
|
||||||
|
|
||||||
|
monitor := MonitorFromPoint(.{region.left, region.top}, MONITOR_DEFAULTTONEAREST);
|
||||||
|
info: MONITORINFO;
|
||||||
|
info.cbSize = size_of(MONITORINFO);
|
||||||
|
GetMonitorInfoW(monitor, *info);
|
||||||
|
screen := info.rcMonitor;
|
||||||
|
|
||||||
|
// The toolbar first, to know the screen's DPI.
|
||||||
|
ex_style :: WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
|
||||||
|
record_ui.toolbar = CreateWindowExW(ex_style, utf8_to_wide(RECORD_TOOLBAR_CLASS,, temp), utf8_to_wide("Recording",, temp),
|
||||||
|
WS_POPUP, screen.left, screen.top, 10, 10, null, null, instance, null);
|
||||||
|
if !record_ui.toolbar return;
|
||||||
|
dpi := GetDpiForWindow(record_ui.toolbar);
|
||||||
|
record_ui.scale = ifx dpi then dpi / 96.0 else 1.0;
|
||||||
|
font_height := cast(s32) (14 * record_ui.scale + 0.5);
|
||||||
|
record_ui.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
border := record_ui_px(3);
|
||||||
|
gap := record_ui_px(6);
|
||||||
|
x := region.right - toolbar_width;
|
||||||
|
y := region.bottom + border + gap;
|
||||||
|
if y + toolbar_height > screen.bottom y = region.top - border - gap - toolbar_height;
|
||||||
|
if y < screen.top || whole_screen {
|
||||||
|
y = region.top + gap;
|
||||||
|
x = region.right - toolbar_width - gap;
|
||||||
|
}
|
||||||
|
x = clamp(x, screen.left, screen.right - toolbar_width);
|
||||||
|
SetWindowPos(record_ui.toolbar, HWND_TOPMOST, x, y, toolbar_width, toolbar_height, SWP_NOACTIVATE);
|
||||||
|
visible := array_find(app.args, "-record-ui-visible"); // to look at them in a screenshot
|
||||||
|
if !visible SetWindowDisplayAffinity(record_ui.toolbar, WDA_EXCLUDEFROMCAPTURE);
|
||||||
|
ShowWindow(record_ui.toolbar, SW_SHOWNOACTIVATE);
|
||||||
|
SetTimer(record_ui.toolbar, RECORD_UI_TIMER, 250, null);
|
||||||
|
|
||||||
|
// The border: a frame border pixels wide just outside the region, which lets clicks through. Not
|
||||||
|
// for a whole screen, where it would be on the screen beside it.
|
||||||
|
if !whole_screen {
|
||||||
|
outer := RECT.{region.left - border, region.top - border, region.right + border, region.bottom + border};
|
||||||
|
record_ui.border = CreateWindowExW(ex_style | WS_EX_LAYERED | WS_EX_TRANSPARENT, utf8_to_wide(RECORD_BORDER_CLASS,, temp), utf8_to_wide("Recording",, temp),
|
||||||
|
WS_POPUP, outer.left, outer.top, outer.right - outer.left, outer.bottom - outer.top, null, null, instance, null);
|
||||||
|
if record_ui.border {
|
||||||
|
SetLayeredWindowAttributes(record_ui.border, 0, 255, LWA_ALPHA);
|
||||||
|
frame := CreateRectRgn(0, 0, outer.right - outer.left, outer.bottom - outer.top);
|
||||||
|
inside := CreateRectRgn(border, border, outer.right - outer.left - border, outer.bottom - outer.top - border);
|
||||||
|
CombineRgn(frame, frame, inside, RGN_DIFF);
|
||||||
|
DeleteObject(inside);
|
||||||
|
SetWindowRgn(record_ui.border, frame, .TRUE); // the window owns the region from here
|
||||||
|
if !visible SetWindowDisplayAffinity(record_ui.border, WDA_EXCLUDEFROMCAPTURE);
|
||||||
|
ShowWindow(record_ui.border, SW_SHOWNOACTIVATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record_ui_close :: () {
|
||||||
|
if record_ui.toolbar { KillTimer(record_ui.toolbar, RECORD_UI_TIMER); DestroyWindow(record_ui.toolbar); record_ui.toolbar = null; }
|
||||||
|
if record_ui.border { DestroyWindow(record_ui.border); record_ui.border = null; }
|
||||||
|
if record_ui.font { DeleteObject(record_ui.font); record_ui.font = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// From another thread (the shutdown's): hidden, not destroyed, which only the main thread may do.
|
||||||
|
record_ui_hide :: () {
|
||||||
|
if record_ui.toolbar ShowWindowAsync(record_ui.toolbar, SW_HIDE);
|
||||||
|
if record_ui.border ShowWindowAsync(record_ui.border, SW_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 :: () {
|
||||||
|
if record_ui.toolbar InvalidateRect(record_ui.toolbar, null, .FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
RECORD_BORDER_CLASS :: "mcapRecordBorder";
|
||||||
|
RECORD_TOOLBAR_CLASS :: "mcapRecordToolbar";
|
||||||
|
RECORD_UI_TIMER :: 1;
|
||||||
|
RECORD_UI_RED :: 0x002828E8; // COLORREF 0x00BBGGRR, as the overlay's video mode
|
||||||
|
RECORD_UI_GREY :: 0x00909090;
|
||||||
|
RECORD_UI_AMBER :: 0x0000B4FF;
|
||||||
|
RECORD_UI_BACK :: 0x00202020;
|
||||||
|
RECORD_UI_BUTTON :: 0x00383838;
|
||||||
|
RECORD_UI_HOVER :: 0x00505050;
|
||||||
|
RECORD_UI_TEXT :: 0x00F0F0F0;
|
||||||
|
|
||||||
|
record_ui_px :: (pixels: float) -> s32 {
|
||||||
|
return max(cast(s32) (pixels * record_ui.scale + 0.5), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the toolbar shows now, left to right: the status, then the buttons.
|
||||||
|
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 "";
|
||||||
|
if stats.lost {
|
||||||
|
lost := tprint("screen lost % time%", stats.lost, ifx stats.lost == 1 then "" else "s");
|
||||||
|
dropped = ifx dropped then tprint("%, %", dropped, lost) else lost;
|
||||||
|
}
|
||||||
|
return status, dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buttons for the state the toolbar is in.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
text_width :: (dc: HDC, text: string) -> s32 {
|
||||||
|
wide := utf8_to_wide(text,, temp);
|
||||||
|
count: s32;
|
||||||
|
while wide[count] count += 1;
|
||||||
|
size: SIZE;
|
||||||
|
GetTextExtentPoint32W(dc, wide, count, *size);
|
||||||
|
return size.cx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The toolbar's size, and where its buttons are, for the longest it is likely to be.
|
||||||
|
record_toolbar_size :: () -> width: s32, height: s32 {
|
||||||
|
dc := GetDC(record_ui.toolbar);
|
||||||
|
defer ReleaseDC(record_ui.toolbar, dc);
|
||||||
|
old := SelectObject(dc, record_ui.font);
|
||||||
|
defer SelectObject(dc, old);
|
||||||
|
padding, gap := record_ui_px(10), record_ui_px(8);
|
||||||
|
status_width := text_width(dc, tprint("PAUSED 00:00:00 %", record_ui.size_text));
|
||||||
|
dropped_width := text_width(dc, "999 dropped, screen lost 9 times");
|
||||||
|
buttons_width := text_width(dc, "System sound: off") + text_width(dc, "Mic: off") + text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 5 * 2 * padding + 4 * gap;
|
||||||
|
height := record_ui_px(34);
|
||||||
|
width := padding + record_ui_px(12) + gap + status_width + gap + dropped_width + gap + buttons_width + padding;
|
||||||
|
return width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
record_toolbar_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call {
|
||||||
|
if !platform.main_context return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||||
|
push_context platform.main_context.* {
|
||||||
|
if message == {
|
||||||
|
case WM_MOUSEACTIVATE;
|
||||||
|
return MA_NOACTIVATE; // the window being recorded keeps the focus
|
||||||
|
case WM_ERASEBKGND;
|
||||||
|
return 1;
|
||||||
|
case WM_TIMER;
|
||||||
|
// The time moves on, and a mouse that left the toolbar no longer lights a button.
|
||||||
|
cursor: POINT;
|
||||||
|
GetCursorPos(*cursor);
|
||||||
|
window: RECT;
|
||||||
|
GetWindowRect(hwnd, *window);
|
||||||
|
if cursor.x < window.left || cursor.x >= window.right || cursor.y < window.top || cursor.y >= window.bottom record_ui.hovered = -1;
|
||||||
|
InvalidateRect(hwnd, null, .FALSE);
|
||||||
|
return 0;
|
||||||
|
case WM_MOUSEMOVE;
|
||||||
|
hovered := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
|
||||||
|
if hovered != record_ui.hovered { record_ui.hovered = hovered; InvalidateRect(hwnd, null, .FALSE); }
|
||||||
|
return 0;
|
||||||
|
case WM_LBUTTONDOWN;
|
||||||
|
hit := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
|
||||||
|
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; }
|
||||||
|
InvalidateRect(hwnd, null, .FALSE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_PAINT;
|
||||||
|
record_toolbar_paint(hwnd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record_toolbar_hit :: (p: POINT) -> s32 {
|
||||||
|
for array_view(record_ui.buttons, 0, record_ui.button_count) {
|
||||||
|
r := it.rect;
|
||||||
|
if p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom return cast(s32) it_index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
record_toolbar_paint :: (hwnd: HWND) {
|
||||||
|
paint: PAINTSTRUCT;
|
||||||
|
window_dc := BeginPaint(hwnd, *paint);
|
||||||
|
defer EndPaint(hwnd, *paint);
|
||||||
|
client: RECT;
|
||||||
|
GetClientRect(hwnd, *client);
|
||||||
|
width, height := client.right, client.bottom;
|
||||||
|
|
||||||
|
// Drawn whole into a bitmap, then put on screen: no flicker as the time changes.
|
||||||
|
dc := CreateCompatibleDC(window_dc);
|
||||||
|
defer DeleteDC(dc);
|
||||||
|
bitmap := CreateCompatibleBitmap(window_dc, width, height);
|
||||||
|
defer DeleteObject(bitmap);
|
||||||
|
old_bitmap := SelectObject(dc, bitmap);
|
||||||
|
defer SelectObject(dc, old_bitmap);
|
||||||
|
old_font := SelectObject(dc, record_ui.font);
|
||||||
|
defer SelectObject(dc, old_font);
|
||||||
|
|
||||||
|
fill :: (r: RECT, colour: u32) #expand {
|
||||||
|
brush := CreateSolidBrush(colour);
|
||||||
|
rect := r;
|
||||||
|
FillRect(`dc, *rect, brush);
|
||||||
|
DeleteObject(brush);
|
||||||
|
}
|
||||||
|
text_at :: (x: s32, text: string, colour: u32) #expand {
|
||||||
|
SetTextColor(`dc, colour);
|
||||||
|
wide := utf8_to_wide(text,, temp);
|
||||||
|
count: s32;
|
||||||
|
while wide[count] count += 1;
|
||||||
|
size: SIZE;
|
||||||
|
GetTextExtentPoint32W(`dc, wide, count, *size);
|
||||||
|
TextOutW(`dc, x, (`height - size.cy) / 2, wide, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
fill(.{0, 0, width, height}, RECORD_UI_BACK);
|
||||||
|
SetBkMode(dc, TRANSPARENT);
|
||||||
|
padding, gap := record_ui_px(10), record_ui_px(8);
|
||||||
|
paused := record_paused(*screen_record.recorder);
|
||||||
|
|
||||||
|
// The dot: red while recording, blinking each half second; grey while paused.
|
||||||
|
dot := record_ui_px(12);
|
||||||
|
blink := (cast(s64) (to_float64_seconds(current_time_monotonic() - record_ui.started) * 2)) % 2 == 0;
|
||||||
|
dot_colour: u32 = ifx paused then cast(u32) RECORD_UI_GREY else ifx blink then cast(u32) RECORD_UI_RED else cast(u32) 0x00602020;
|
||||||
|
brush := CreateSolidBrush(dot_colour);
|
||||||
|
old_brush := SelectObject(dc, brush);
|
||||||
|
old_pen := SelectObject(dc, GetStockObject(NULL_PEN));
|
||||||
|
Ellipse(dc, padding, (height - dot) / 2, padding + dot, (height - dot) / 2 + dot);
|
||||||
|
SelectObject(dc, old_pen);
|
||||||
|
SelectObject(dc, old_brush);
|
||||||
|
DeleteObject(brush);
|
||||||
|
|
||||||
|
status, dropped := record_toolbar_texts();
|
||||||
|
x := padding + dot + gap;
|
||||||
|
text_at(x, status, RECORD_UI_TEXT);
|
||||||
|
x += text_width(dc, status) + gap;
|
||||||
|
if dropped text_at(x, dropped, RECORD_UI_AMBER);
|
||||||
|
|
||||||
|
// The buttons, from the right.
|
||||||
|
buttons := record_toolbar_buttons();
|
||||||
|
button_height := height - 2 * record_ui_px(5);
|
||||||
|
right := width - padding;
|
||||||
|
for < buttons {
|
||||||
|
label_width := text_width(dc, it.label);
|
||||||
|
r := RECT.{right - label_width - 2 * padding, (height - button_height) / 2, right, (height - button_height) / 2 + button_height};
|
||||||
|
record_ui.buttons[it_index].rect = r;
|
||||||
|
// A sound that is on, in the record red.
|
||||||
|
lit := (it.ask == .SYSTEM_SOUND && record_audio_on(*screen_record.recorder, .SYSTEM)) || (it.ask == .MICROPHONE && record_audio_on(*screen_record.recorder, .MICROPHONE));
|
||||||
|
fill(r, ifx lit then cast(u32) RECORD_UI_RED else ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
|
||||||
|
SetTextColor(dc, ifx it.ask == .DISCARD then cast(u32) RECORD_UI_AMBER else cast(u32) RECORD_UI_TEXT);
|
||||||
|
wide := utf8_to_wide(it.label,, temp);
|
||||||
|
count: s32;
|
||||||
|
while wide[count] count += 1;
|
||||||
|
size: SIZE;
|
||||||
|
GetTextExtentPoint32W(dc, wide, count, *size);
|
||||||
|
TextOutW(dc, r.left + padding, r.top + (button_height - size.cy) / 2, wide, count);
|
||||||
|
right = r.left - gap;
|
||||||
|
}
|
||||||
|
if record_ui.confirming text_at(right - text_width(dc, "Discard it?"), "Discard it?", RECORD_UI_AMBER);
|
||||||
|
|
||||||
|
BitBlt(window_dc, 0, 0, width, height, dc, 0, 0, .SRCCOPY);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
// The scan runs on the OCR thread (app/ocr_ui.jai), as a task of its own, so it shares the OCR scan's
|
// The scan runs on the OCR thread (app/ocr_ui.jai), as a task of its own, so it shares the OCR scan's
|
||||||
// progress, pause and cancel, and one thread uses the GPU at a time. The engine is made by the first
|
// progress, pause and cancel, and one thread uses the GPU at a time. The engine is made by the first
|
||||||
// scan, not at startup: it takes some 2.4 GB of GPU memory, and most sessions never segment anything.
|
// scan, not at startup: it takes some 2.4 GB of GPU memory, and most sessions never segment anything.
|
||||||
|
// It is destroyed again once it has gone unused for a while (ocr_ui_unload_idle_models).
|
||||||
|
|
||||||
SEGMENT_WINDOW :: "Segmentation";
|
SEGMENT_WINDOW :: "Segmentation";
|
||||||
SEGMENT_MODEL :: "mask2former-swin-base-coco-panoptic"; // the one segmentation model; its chunks are keyed by this
|
SEGMENT_MODEL :: "mask2former-swin-base-coco-panoptic"; // the one segmentation model; its chunks are keyed by this
|
||||||
@ -15,6 +16,7 @@ segment_ui: struct {
|
|||||||
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
|
// Owned by the OCR thread while ocr_ui.thread_busy, like ocr_ui.engine.
|
||||||
engine: *Segment_Engine;
|
engine: *Segment_Engine;
|
||||||
engine_error: string;
|
engine_error: string;
|
||||||
|
last_used: Apollo_Time; // main thread: when a scan last used the engine (ocr_ui_unload_idle_models)
|
||||||
|
|
||||||
// Main thread.
|
// Main thread.
|
||||||
window_open: bool;
|
window_open: bool;
|
||||||
@ -112,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
|
||||||
@ -196,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
|
||||||
@ -269,7 +271,7 @@ segment_read_and_store :: (engine: *Segment_Engine, items: [] Scan_Item) -> [] s
|
|||||||
entry, load_error := catalog_load(item.path);
|
entry, load_error := catalog_load(item.path);
|
||||||
if entry {
|
if entry {
|
||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
array_add(*viewer.catalog_updates, .{item.index, entry});
|
array_add(*viewer.catalog_updates, .{item.index, entry, false});
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
} else {
|
} else {
|
||||||
errors[n] = sprint("was segmented, but could not be read back: %", load_error);
|
errors[n] = sprint("was segmented, but could not be read back: %", load_error);
|
||||||
@ -290,8 +292,8 @@ segment_ui_segment_and_exit :: (busy: bool) {
|
|||||||
}
|
}
|
||||||
if viewer.scan_error { finish(1, "[Segment scan] %", viewer.scan_error); return; }
|
if viewer.scan_error { finish(1, "[Segment scan] %", viewer.scan_error); return; }
|
||||||
if !segment_ui.scan_requested {
|
if !segment_ui.scan_requested {
|
||||||
// After the catalog, and after the engines made at startup, which use the same thread.
|
// After the catalog.
|
||||||
if busy || viewer.catalog_loaded < viewer.captures.count || !ocr_ui.engine_tried || !labels_ui.engine_tried return;
|
if busy || viewer.catalog_loaded < viewer.captures.count return;
|
||||||
segment_ui.scan_requested = true;
|
segment_ui.scan_requested = true;
|
||||||
segment_ui.window_open = true; // its progress bar
|
segment_ui.window_open = true; // its progress bar
|
||||||
if !segment_scan_start(quiet = true) finish(0, "[Segment scan] -segment: nothing to segment (no image in a folder set to segment needs it).");
|
if !segment_scan_start(quiet = true) finish(0, "[Segment scan] -segment: nothing to segment (no image in a folder set to segment needs it).");
|
||||||
@ -333,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);
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
// those in view first and then the rows around them, and are uploaded a few per frame. Textures
|
// those in view first and then the rows around them, and are uploaded a few per frame. Textures
|
||||||
// for previews far out of view are released once there are more than VIEWER_MAX_TEXTURES.
|
// for previews far out of view are released once there are more than VIEWER_MAX_TEXTURES.
|
||||||
//
|
//
|
||||||
// When the workers have no previews to decode they load the catalog (app/catalog.jai), what search
|
// The catalog (app/catalog.jai), what search and sorting need from each capture, loads on the
|
||||||
// and sorting need from each capture. The grid shows viewer.visible: the captures in display order,
|
// background thread group (app/background.jai), so previews keep these workers to themselves. The grid shows viewer.visible: the captures in display order,
|
||||||
// newest first once the catalog is complete, and only those matching the search. Searching runs on
|
// newest first once the catalog is complete, and only those matching the search. Searching runs on
|
||||||
// background workers (app/background.jai), so the UI thread never waits on it.
|
// background workers (app/background.jai), so the UI thread never waits on it.
|
||||||
//
|
//
|
||||||
@ -13,12 +13,46 @@
|
|||||||
//
|
//
|
||||||
// A capture keeps its index for as long as the app runs: the workers, the OCR scan and Stats refer
|
// A capture keeps its index for as long as the app runs: the workers, the OCR scan and Stats refer
|
||||||
// to captures by index. Refresh (viewer_refresh) marks the files gone from the folder as removed and
|
// to captures by index. Refresh (viewer_refresh) marks the files gone from the folder as removed and
|
||||||
// appends the new ones.
|
// appends the new ones. The library is watched (viewer_watch_frame), so files added or deleted
|
||||||
|
// outside the app come and go on their own; Refresh is still there for a watcher that missed some.
|
||||||
|
|
||||||
VIEWER_FOLDER :: "D:/McapIndex/mcap"; // hardcoded for now; -library FOLDER shows another
|
// The library when [library] folder in settings.config names none; -library FOLDER overrides both.
|
||||||
|
#if OS == .WINDOWS LIBRARY_FOLDER_DEFAULT :: "D:/McapIndex/mcap";
|
||||||
|
else LIBRARY_FOLDER_DEFAULT :: "~/Pictures/mcap";
|
||||||
|
|
||||||
|
// Set once at startup (library_folder_choose), so the library never changes under a running app.
|
||||||
viewer_folder :: () -> string {
|
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
|
||||||
@ -55,12 +89,16 @@ Viewer_Capture :: struct {
|
|||||||
in_flight: bool; // under viewer.mutex: queued for a worker, or being decoded
|
in_flight: bool; // under viewer.mutex: queued for a worker, or being decoded
|
||||||
catalog: *Catalog_Entry; // main thread only; null until loaded
|
catalog: *Catalog_Entry; // main thread only; null until loaded
|
||||||
removed: bool; // main thread only: its file was gone at a refresh; shown nowhere
|
removed: bool; // main thread only: its file was gone at a refresh; shown nowhere
|
||||||
|
catalog_failed: bool; // main thread only: its catalog entry could not be loaded
|
||||||
|
file_size: s64 = -1; // its file's, as listed at startup or a refresh; -1 when not known
|
||||||
|
file_modified: s64; // nanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// A catalog entry made off the main thread, for the main thread to put in place.
|
// A catalog entry made off the main thread, for the main thread to put in place.
|
||||||
Catalog_Update :: struct {
|
Catalog_Update :: struct {
|
||||||
index: s32;
|
index: s32;
|
||||||
entry: *Catalog_Entry; // null when it could not be loaded
|
entry: *Catalog_Entry; // null when it could not be loaded
|
||||||
|
from_cache: bool; // given by the catalog cache, not read from the file
|
||||||
}
|
}
|
||||||
|
|
||||||
// How the full-size window sizes its image.
|
// How the full-size window sizes its image.
|
||||||
@ -108,7 +146,6 @@ viewer: struct {
|
|||||||
quit: bool;
|
quit: bool;
|
||||||
workers: [VIEWER_WORKER_COUNT] Thread;
|
workers: [VIEWER_WORKER_COUNT] Thread;
|
||||||
workers_started: s64;
|
workers_started: s64;
|
||||||
catalog_next: s64; // the next capture whose catalog entry a worker loads
|
|
||||||
catalog_updates: [..] Catalog_Update; // made, waiting for the main thread
|
catalog_updates: [..] Catalog_Update; // made, waiting for the main thread
|
||||||
|
|
||||||
// Display order and search; main thread only.
|
// Display order and search; main thread only.
|
||||||
@ -116,6 +153,12 @@ viewer: struct {
|
|||||||
visible_dirty := true;
|
visible_dirty := true;
|
||||||
visible_rebuilt_at: Apollo_Time;
|
visible_rebuilt_at: Apollo_Time;
|
||||||
catalog_loaded: s64; // entries put in place, or found unreadable
|
catalog_loaded: s64; // entries put in place, or found unreadable
|
||||||
|
catalog_started: Apollo_Time; // when the workers started on it, for the log line once it is complete
|
||||||
|
catalog_timed: bool; // that line has been logged
|
||||||
|
catalog_from_cache: s64; // entries the catalog cache gave
|
||||||
|
catalog_from_files: s64; // entries read from their files
|
||||||
|
first_preview_logged: bool;
|
||||||
|
first_frame_logged: bool;
|
||||||
removed_count: s64; // captures marked removed
|
removed_count: s64; // captures marked removed
|
||||||
delete_index: s32 = -1; // the capture whose delete is being confirmed
|
delete_index: s32 = -1; // the capture whose delete is being confirmed
|
||||||
delete_without_asking: bool; // until the app restarts
|
delete_without_asking: bool; // until the app restarts
|
||||||
@ -124,6 +167,7 @@ viewer: struct {
|
|||||||
visible_generation: s64; // counts rebuilds of visible
|
visible_generation: s64; // counts rebuilds of visible
|
||||||
sorted_by_date: bool;
|
sorted_by_date: bool;
|
||||||
search_buffer: [256] u8;
|
search_buffer: [256] u8;
|
||||||
|
focus_search: bool; // Ctrl-F (focus_search in [hotkeys]): the search box takes the keyboard next frame
|
||||||
search_terms: [..] Search_Term;
|
search_terms: [..] Search_Term;
|
||||||
folders: [..] string; // the library's folders holding captures, sorted; "" for its top
|
folders: [..] string; // the library's folders holding captures, sorted; "" for its top
|
||||||
folder_filter: string; // the folder the grid shows; "" for all (heap)
|
folder_filter: string; // the folder the grid shows; "" for all (heap)
|
||||||
@ -138,14 +182,31 @@ viewer: struct {
|
|||||||
full_fit := Viewer_Fit.WINDOW;
|
full_fit := Viewer_Fit.WINDOW;
|
||||||
full_zoom: float = 100; // percent of the size it was captured at; set by the fit, unless OFF
|
full_zoom: float = 100; // percent of the size it was captured at; set by the fit, unless OFF
|
||||||
|
|
||||||
// A video or animation's original, written out to open in the default player (viewer_open_original).
|
// A capture's original, written out to open in the default player (viewer_open_original), or
|
||||||
|
// exported to a folder (viewer_export_original).
|
||||||
open_thread: Thread;
|
open_thread: Thread;
|
||||||
open_started: bool;
|
open_started: bool;
|
||||||
open_busy: bool; // set by the main thread, cleared by the open thread when it ends
|
open_busy: bool; // set by the main thread, cleared by the open thread when it ends
|
||||||
open_source: string; // the .mcap; heap, main thread's until the thread starts
|
open_source: string; // the .mcap; heap, main thread's until the thread starts
|
||||||
open_name: string;
|
open_name: string;
|
||||||
|
open_folder: string; // where to export it; "" to write it to the temporary folder and open it
|
||||||
open_result: string; // the file written, or ""; heap, the open thread's until it ends
|
open_result: string; // the file written, or ""; heap, the open thread's until it ends
|
||||||
open_error: string;
|
open_error: string;
|
||||||
|
|
||||||
|
// The export being asked about (viewer_export_popup).
|
||||||
|
export_index: s32 = -1; // the capture to export, while the popup is up
|
||||||
|
export_choice: s64; // which of viewer_export_choices, or -1 for Custom...
|
||||||
|
export_buffer: [1024] u8; // the folder typed in for Custom...
|
||||||
|
export_add: bool; // Custom...: add the folder to [export]
|
||||||
|
export_make_default: bool;
|
||||||
|
|
||||||
|
// The selected capture's image, decoded off the main thread for the clipboard (viewer_copy_image).
|
||||||
|
copy_thread: Thread;
|
||||||
|
copy_started: bool;
|
||||||
|
copy_busy: bool; // set by the main thread, cleared by the copy thread when it ends
|
||||||
|
copy_source: string; // the .mcap; heap, main thread's until the thread starts
|
||||||
|
copy_name: string;
|
||||||
|
copy_done: Decoded; // the copy thread's until it ends
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- startup and shutdown
|
// ---------------------------------------------------------------- startup and shutdown
|
||||||
@ -165,16 +226,21 @@ viewer_startup :: () {
|
|||||||
|
|
||||||
viewer.folders.allocator = context.default_allocator;
|
viewer.folders.allocator = context.default_allocator;
|
||||||
|
|
||||||
|
viewer_watch_start(); // before the listing, so nothing written meanwhile is missed
|
||||||
|
|
||||||
start := current_time_monotonic();
|
start := current_time_monotonic();
|
||||||
for viewer_list_files() {
|
for viewer_list_files() {
|
||||||
capture := array_add(*viewer.captures);
|
capture := array_add(*viewer.captures);
|
||||||
viewer_capture_init(capture, it);
|
viewer_capture_init(capture, it.path);
|
||||||
|
capture.file_size, capture.file_modified = it.size, it.modified;
|
||||||
}
|
}
|
||||||
viewer_update_folders();
|
viewer_update_folders();
|
||||||
// Newest first, roughly: dated names (2026-...) sort after the older {GUID} ones.
|
// Newest first, roughly: dated names (2026-...) sort after the older {GUID} ones.
|
||||||
quick_sort(viewer.captures, (a: Viewer_Capture, b: Viewer_Capture) -> s64 { return compare(b.name, a.name); });
|
quick_sort(viewer.captures, (a: Viewer_Capture, b: Viewer_Capture) -> s64 { return compare(b.name, a.name); });
|
||||||
if !viewer.captures.count viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder());
|
if !viewer.captures.count viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder());
|
||||||
log("[viewer] Found % captures in % in % ms.", viewer.captures.count, viewer_folder(), FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
|
log("[startup] Found % captures in % at % ms, listing them in % ms.", viewer.captures.count, viewer_folder(), FF(startup_ms(), 1), FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
|
||||||
|
if app.cold_start viewer_evict_file_cache();
|
||||||
|
viewer.catalog_started = current_time_monotonic();
|
||||||
|
|
||||||
for * viewer.workers {
|
for * viewer.workers {
|
||||||
// Reading a front takes 64 KB of temporary storage, and a full image several MB.
|
// Reading a front takes 64 KB of temporary storage, and a full image several MB.
|
||||||
@ -186,6 +252,23 @@ viewer_startup :: () {
|
|||||||
viewer.workers_started += 1;
|
viewer.workers_started += 1;
|
||||||
}
|
}
|
||||||
background_startup();
|
background_startup();
|
||||||
|
{
|
||||||
|
// The catalog: from the cache what it has a current entry for, the rest from the files.
|
||||||
|
items := NewArray(viewer.captures.count, Catalog_Item,, temp);
|
||||||
|
for * items {
|
||||||
|
capture := *viewer.captures[it_index];
|
||||||
|
it.* = .{index = cast(s32) it_index, path = capture.path, size = capture.file_size, modified = capture.file_modified};
|
||||||
|
}
|
||||||
|
if app.no_catalog_cache {
|
||||||
|
log("[startup] -no-catalog-cache: every capture is read from its file.");
|
||||||
|
background_catalog_load(items);
|
||||||
|
} else {
|
||||||
|
index := New(Catalog_Cache_Index,, context.default_allocator);
|
||||||
|
index.path = copy_string(catalog_cache_path(),, context.default_allocator);
|
||||||
|
index.captures = array_copy(items,, context.default_allocator);
|
||||||
|
background_catalog_cache(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
if app.start_search viewer_set_search(app.start_search);
|
if app.start_search viewer_set_search(app.start_search);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,7 +280,10 @@ viewer_shutdown :: () {
|
|||||||
for 1..viewer.workers_started signal(*viewer.wake);
|
for 1..viewer.workers_started signal(*viewer.wake);
|
||||||
for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1);
|
for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1);
|
||||||
if viewer.open_started thread_is_done(*viewer.open_thread, -1);
|
if viewer.open_started thread_is_done(*viewer.open_thread, -1);
|
||||||
|
if viewer.copy_started thread_is_done(*viewer.copy_thread, -1);
|
||||||
background_shutdown();
|
background_shutdown();
|
||||||
|
catalog_cache_save_at_shutdown(); // the workers have stopped: nothing changes the entries now
|
||||||
|
viewer_watch_stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- the workers
|
// ---------------------------------------------------------------- the workers
|
||||||
@ -208,7 +294,6 @@ viewer_worker :: (thread: *Thread) -> s64 {
|
|||||||
while true {
|
while true {
|
||||||
index: s32 = -1;
|
index: s32 = -1;
|
||||||
full: bool;
|
full: bool;
|
||||||
catalog: bool;
|
|
||||||
request: u64;
|
request: u64;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
||||||
@ -229,15 +314,13 @@ viewer_worker :: (thread: *Thread) -> s64 {
|
|||||||
} else if viewer.queue_next < viewer.queue.count {
|
} else if viewer.queue_next < viewer.queue.count {
|
||||||
index = viewer.queue[viewer.queue_next];
|
index = viewer.queue[viewer.queue_next];
|
||||||
viewer.queue_next += 1;
|
viewer.queue_next += 1;
|
||||||
} else if viewer.catalog_next < viewer.captures.count {
|
|
||||||
// Nothing to show is waiting: load the next catalog entry.
|
|
||||||
catalog = true;
|
|
||||||
index = cast(s32) viewer.catalog_next;
|
|
||||||
viewer.catalog_next += 1;
|
|
||||||
}
|
}
|
||||||
if index >= 0 path = viewer.captures[index].path; // the array grows only under the mutex, and paths are never freed
|
if index >= 0 path = viewer.captures[index].path; // the array grows only under the mutex, and paths are never freed
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
if index < 0 break; // nothing to do: wait to be woken
|
if index < 0 { // nothing to do: wait to be woken, having given back what this thread's heap holds free
|
||||||
|
Default_Allocator.thread_idle();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if segments {
|
if segments {
|
||||||
overlay := segment_overlay_read(path);
|
overlay := segment_overlay_read(path);
|
||||||
@ -255,16 +338,6 @@ viewer_worker :: (thread: *Thread) -> s64 {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if catalog {
|
|
||||||
entry, error := catalog_load(path);
|
|
||||||
if !entry log_error("[catalog] % %", path, error);
|
|
||||||
reset_temporary_storage();
|
|
||||||
lock(*viewer.mutex);
|
|
||||||
array_add(*viewer.catalog_updates, .{index, entry});
|
|
||||||
unlock(*viewer.mutex);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result := ifx full then viewer_decode_full(path) else viewer_decode_preview(path);
|
result := ifx full then viewer_decode_full(path) else viewer_decode_preview(path);
|
||||||
result.index = index;
|
result.index = index;
|
||||||
|
|
||||||
@ -321,6 +394,14 @@ viewer_decode_image :: (path: string, bytes: [] u8, kind := Image_Kind.UNKNOWN)
|
|||||||
log_error("[viewer] Cannot decode the image in %: %", path, error);
|
log_error("[viewer] Cannot decode the image in %: %", path, error);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
return viewer_decoded_from_image(path, image);
|
||||||
|
}
|
||||||
|
|
||||||
|
// image, which this takes, as a texture can hold it: scaled down when too large for one.
|
||||||
|
viewer_decoded_from_image :: (path: string, image_in: Image) -> Decoded {
|
||||||
|
result: Decoded;
|
||||||
|
result.failed = true;
|
||||||
|
image := image_in;
|
||||||
pixels := image.pixels;
|
pixels := image.pixels;
|
||||||
width, height := image.width, image.height;
|
width, height := image.width, image.height;
|
||||||
result.pixels = pixels;
|
result.pixels = pixels;
|
||||||
@ -416,15 +497,24 @@ viewer_frame_gpu_work :: () {
|
|||||||
capture.failed = true;
|
capture.failed = true;
|
||||||
} else if !capture.texture.handle {
|
} else if !capture.texture.handle {
|
||||||
capture.texture = gpu_texture_create(d.pixels, d.width, d.height);
|
capture.texture = gpu_texture_create(d.pixels, d.width, d.height);
|
||||||
if capture.texture.handle array_add(*viewer.with_texture, d.index);
|
if capture.texture.handle {
|
||||||
else capture.failed = true;
|
array_add(*viewer.with_texture, d.index);
|
||||||
|
if !viewer.first_preview_logged {
|
||||||
|
viewer.first_preview_logged = true;
|
||||||
|
log("[startup] First preview shown at % ms.", FF(startup_ms(), 1));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
capture.failed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewer_free_pixels(*d);
|
viewer_free_pixels(*d);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer_evict();
|
viewer_evict();
|
||||||
viewer_apply_catalog_updates();
|
viewer_apply_catalog_updates();
|
||||||
|
catalog_cache_frame(); // saves the catalog cache once it is complete and has changed (app/catalog.jai)
|
||||||
viewer_open_frame();
|
viewer_open_frame();
|
||||||
|
viewer_copy_frame();
|
||||||
segment_overlay_frame(); // the Capture window's segments: what arrived, and its texture (app/segment_ui.jai)
|
segment_overlay_frame(); // the Capture window's segments: what arrived, and its texture (app/segment_ui.jai)
|
||||||
background_frame(); // takes back search results, and starts a search when visible_dirty (app/background.jai)
|
background_frame(); // takes back search results, and starts a search when visible_dirty (app/background.jai)
|
||||||
}
|
}
|
||||||
@ -441,7 +531,6 @@ viewer_apply_catalog_updates :: () {
|
|||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
updates := array_copy(viewer.catalog_updates,, temp);
|
updates := array_copy(viewer.catalog_updates,, temp);
|
||||||
array_reset_keeping_memory(*viewer.catalog_updates);
|
array_reset_keeping_memory(*viewer.catalog_updates);
|
||||||
more_to_load := viewer.catalog_next < viewer.captures.count;
|
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
|
|
||||||
// Whether the date order can change: a capture's first entry (one a refresh added), one lost, or a
|
// Whether the date order can change: a capture's first entry (one a refresh added), one lost, or a
|
||||||
@ -451,6 +540,9 @@ viewer_apply_catalog_updates :: () {
|
|||||||
for updates {
|
for updates {
|
||||||
capture := *viewer.captures[it.index];
|
capture := *viewer.captures[it.index];
|
||||||
if !capture.catalog viewer.catalog_loaded += 1;
|
if !capture.catalog viewer.catalog_loaded += 1;
|
||||||
|
if !capture.catalog capture.catalog_failed = !it.entry;
|
||||||
|
if it.from_cache viewer.catalog_from_cache += 1;
|
||||||
|
else { viewer.catalog_from_files += 1; catalog_cache.dirty = true; }
|
||||||
if !capture.catalog || !it.entry || it.entry.date != capture.catalog.date order_changes = true;
|
if !capture.catalog || !it.entry || it.entry.date != capture.catalog.date order_changes = true;
|
||||||
if it.entry || !capture.catalog {
|
if it.entry || !capture.catalog {
|
||||||
background_retire(capture.catalog); // a search or Stats job may be reading it
|
background_retire(capture.catalog); // a search or Stats job may be reading it
|
||||||
@ -464,20 +556,27 @@ viewer_apply_catalog_updates :: () {
|
|||||||
if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count {
|
if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count {
|
||||||
viewer.sorted_by_date = true;
|
viewer.sorted_by_date = true;
|
||||||
viewer.visible_dirty = true;
|
viewer.visible_dirty = true;
|
||||||
|
if !viewer.catalog_timed {
|
||||||
|
viewer.catalog_timed = true;
|
||||||
|
failed: s64;
|
||||||
|
for viewer.captures if it.catalog_failed failed += 1;
|
||||||
|
log("[startup] Catalog complete at % ms: % entries in % ms, % from the cache and % read from their files on % threads (% could not be read)%.",
|
||||||
|
FF(startup_ms(), 1), viewer.captures.count, FF(to_float64_seconds(current_time_monotonic() - viewer.catalog_started) * 1000.0, 1),
|
||||||
|
viewer.catalog_from_cache, viewer.catalog_from_files, background_catalog_threads(), failed, ifx app.cold_start then ", from a cold start" else "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The workers sleep when there is nothing to show; wake them while there is catalog to load.
|
|
||||||
if more_to_load signal(*viewer.wake);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The captures in display order: by date, newest first, once every date is known (by name until
|
// The captures in display order: by date, newest first, once every date is known (by name until
|
||||||
// then), and only those matching the search.
|
// then), and only those matching the search.
|
||||||
// Looks at the folder again: captures whose file is gone are marked removed, and new files are
|
// Looks at the folder again: captures whose file is gone are marked removed, and new files are
|
||||||
// added, their previews and catalog entries loaded as the others' were. Indices stay as they are.
|
// added, their previews and catalog entries loaded as the others' were. Indices stay as they are.
|
||||||
viewer_refresh :: () {
|
// quiet (the watcher's): no toast, and no log line when nothing changed.
|
||||||
|
viewer_refresh :: (quiet := false) {
|
||||||
start := current_time_monotonic();
|
start := current_time_monotonic();
|
||||||
present: Table(string, bool); // the folder's .mcap files now
|
present: Table(string, bool); // the folder's .mcap files now
|
||||||
present.allocator = temp;
|
present.allocator = temp;
|
||||||
for viewer_list_files() table_set(*present, it, true);
|
for viewer_list_files() table_set(*present, it.path, true);
|
||||||
|
|
||||||
removed, added: s64;
|
removed, added: s64;
|
||||||
known: Table(string, bool); // the captures still there
|
known: Table(string, bool); // the captures still there
|
||||||
@ -489,16 +588,19 @@ viewer_refresh :: () {
|
|||||||
removed += 1;
|
removed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
new_items: [..] Catalog_Item;
|
||||||
|
new_items.allocator = temp;
|
||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
for _, path: present {
|
for _, path: present {
|
||||||
if table_contains(*known, path) continue;
|
if table_contains(*known, path) continue;
|
||||||
capture := array_add(*viewer.captures);
|
capture := array_add(*viewer.captures);
|
||||||
capture.* = .{};
|
capture.* = .{};
|
||||||
viewer_capture_init(capture, path);
|
viewer_capture_init(capture, path);
|
||||||
|
array_add(*new_items, .{index = cast(s32) (viewer.captures.count - 1), path = capture.path});
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
if added signal(*viewer.wake); // their catalog entries
|
background_catalog_load(new_items);
|
||||||
viewer_update_folders();
|
viewer_update_folders();
|
||||||
|
|
||||||
free(viewer.scan_error);
|
free(viewer.scan_error);
|
||||||
@ -507,22 +609,24 @@ viewer_refresh :: () {
|
|||||||
viewer.catalog_generation += 1;
|
viewer.catalog_generation += 1;
|
||||||
viewer.visible_dirty = true;
|
viewer.visible_dirty = true;
|
||||||
viewer.visible_rebuilt_at = .{};
|
viewer.visible_rebuilt_at = .{};
|
||||||
|
if quiet && !removed && !added return;
|
||||||
log("[viewer] Refreshed %: % removed, % added, % captures, in % ms.", viewer_folder(), removed, added,
|
log("[viewer] Refreshed %: % removed, % added, % captures, in % ms.", viewer_folder(), removed, added,
|
||||||
viewer.captures.count - viewer.removed_count, FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
|
viewer.captures.count - viewer.removed_count, FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
|
||||||
toast_show(.Success, "% removed, % added", removed, added);
|
if !quiet toast_show(.Success, "% removed, % added", removed, added);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One capture written into the library since it was looked at (an import): added as a refresh would
|
// One capture written into the library since it was looked at (an import): added as a refresh would
|
||||||
// add it. Its index, or -1 when it is already there.
|
// add it. Its index; the one it has when it is already there (the watcher may have seen it first).
|
||||||
viewer_add_capture :: (path: string) -> s32 {
|
viewer_add_capture :: (path: string) -> s32 {
|
||||||
for viewer.captures if !it.removed && it.path == path return -1;
|
index := viewer_capture_find(path);
|
||||||
|
if index >= 0 return index;
|
||||||
lock(*viewer.mutex);
|
lock(*viewer.mutex);
|
||||||
capture := array_add(*viewer.captures);
|
capture := array_add(*viewer.captures);
|
||||||
capture.* = .{};
|
capture.* = .{};
|
||||||
viewer_capture_init(capture, path);
|
viewer_capture_init(capture, path);
|
||||||
index := cast(s32) (viewer.captures.count - 1);
|
index = cast(s32) (viewer.captures.count - 1);
|
||||||
unlock(*viewer.mutex);
|
unlock(*viewer.mutex);
|
||||||
signal(*viewer.wake); // its catalog entry
|
background_catalog_load(.[.{index = index, path = capture.path}]);
|
||||||
if viewer.scan_error { free(viewer.scan_error); viewer.scan_error = ""; }
|
if viewer.scan_error { free(viewer.scan_error); viewer.scan_error = ""; }
|
||||||
if !array_find(viewer.folders, capture.folder) viewer_update_folders();
|
if !array_find(viewer.folders, capture.folder) viewer_update_folders();
|
||||||
viewer.sorted_by_date = false; // until its entry is in
|
viewer.sorted_by_date = false; // until its entry is in
|
||||||
@ -530,20 +634,160 @@ viewer_add_capture :: (path: string) -> s32 {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every .mcap in the library, in its folders too, but not in hidden ones. Temp.
|
Listed_File :: struct {
|
||||||
viewer_list_files :: () -> [..] string {
|
path: string;
|
||||||
files: [..] string;
|
size: s64;
|
||||||
|
modified: s64; // nanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every .mcap in the library, in its folders too, but not in hidden ones, with its size and modified
|
||||||
|
// time (which listing a folder gives at no extra cost on Windows and Unix). Temp.
|
||||||
|
viewer_list_files :: () -> [..] Listed_File {
|
||||||
|
files: [..] Listed_File;
|
||||||
files.allocator = temp;
|
files.allocator = temp;
|
||||||
extension := tprint(".%", MCAP_EXTENSION);
|
visit_files(viewer_folder(), true, *files, (info: *File_Visit_Info, files: *[..] Listed_File) {
|
||||||
for file_list(viewer_folder(), recursive = true,, temp) {
|
if !viewer_is_library_file(info.full_name) return;
|
||||||
if !ends_with_nocase(it, extension) continue;
|
array_add(files, .{copy_string(info.full_name,, temp), info.size, to_nanoseconds(info.modification_time)});
|
||||||
relative := advance(it, viewer_folder().count + 1);
|
});
|
||||||
if begins_with(relative, ".") || contains(relative, "/.") continue;
|
|
||||||
array_add(*files, it);
|
|
||||||
}
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 on Linux.
|
||||||
|
viewer_is_library_path :: (path: string) -> bool {
|
||||||
|
folder := viewer_folder();
|
||||||
|
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
|
||||||
|
relative := advance(path, folder.count + 1);
|
||||||
|
if begins_with(relative, ".") || contains(relative, "/.") return false;
|
||||||
|
#if OS == .LINUX if relative == "deleted" || begins_with(relative, "deleted/") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_is_library_file :: (path: string) -> bool {
|
||||||
|
return ends_with_nocase(path, tprint(".%", MCAP_EXTENSION)) && viewer_is_library_path(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capture whose file is path, of those not removed; -1 when none.
|
||||||
|
viewer_capture_find :: (path: string) -> s32 {
|
||||||
|
for viewer.captures if !it.removed && it.path == path return cast(s32) it_index;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- watching the library
|
||||||
|
|
||||||
|
// The watcher reports which paths changed, not what to do about them: a capture written in place
|
||||||
|
// (the app's own writes go to NAME.mcap.tmp and are renamed over NAME.mcap) shows as a remove and a
|
||||||
|
// move, a copy in progress as an add and modifies. So each path it reports is looked at as it is
|
||||||
|
// now, once it has settled: a file the grid lacks is added, a capture whose file is gone is
|
||||||
|
// removed. A folder added, removed or renamed reports no events for what is in it, and lost events
|
||||||
|
// say nothing of what was lost, so those compare the whole library (viewer_refresh) instead.
|
||||||
|
//
|
||||||
|
// Removals wait while the OCR thread is busy, as Refresh does: a scan or reading refers to captures
|
||||||
|
// by index. Main thread only, but for viewer_watch_stop on the shutdown thread once the frames stop.
|
||||||
|
|
||||||
|
VIEWER_WATCH_BUFFER_SIZE :: 64 * 1024; // per watched folder on Windows; the most a network share takes
|
||||||
|
|
||||||
|
viewer_watch: struct {
|
||||||
|
watcher: Watcher.File_Watcher(void);
|
||||||
|
started: bool;
|
||||||
|
paths: [..] string; // .mcap files changed, not looked at yet (heap)
|
||||||
|
rescan: bool; // compare the whole library
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_watch_start :: () {
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
viewer_watch.paths.allocator = context.default_allocator;
|
||||||
|
folder := viewer_folder();
|
||||||
|
if !Watcher.init(*viewer_watch.watcher, viewer_watch_callback, merge_window_seconds = 0.25, event_buffer_size = VIEWER_WATCH_BUFFER_SIZE) {
|
||||||
|
log_error("[viewer] Could not watch %; Refresh shows what changed in it.", folder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !Watcher.add_directories(*viewer_watch.watcher, folder) {
|
||||||
|
log_error("[viewer] Could not watch %; Refresh shows what changed in it.", folder);
|
||||||
|
Watcher.deinit(viewer_watch.watcher);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer_watch.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_watch_stop :: () {
|
||||||
|
if !viewer_watch.started return;
|
||||||
|
Watcher.deinit(viewer_watch.watcher);
|
||||||
|
viewer_watch.started = false;
|
||||||
|
for viewer_watch.paths free(it,, context.default_allocator);
|
||||||
|
array_free(viewer_watch.paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_watch_frame :: () {
|
||||||
|
if !viewer_watch.started return;
|
||||||
|
Watcher.process_changes(*viewer_watch.watcher);
|
||||||
|
if !viewer_watch.rescan && !viewer_watch.paths.count return;
|
||||||
|
|
||||||
|
busy := ocr_ui.thread_busy; // removals wait for it
|
||||||
|
if viewer_watch.rescan {
|
||||||
|
if busy return;
|
||||||
|
viewer_watch.rescan = false;
|
||||||
|
for viewer_watch.paths free(it,, context.default_allocator);
|
||||||
|
array_reset_keeping_memory(*viewer_watch.paths);
|
||||||
|
viewer_refresh(quiet = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
kept: s64; // the paths that wait for the OCR thread, moved to the front
|
||||||
|
for path: viewer_watch.paths {
|
||||||
|
index := viewer_capture_find(path);
|
||||||
|
there := file_exists(path) && !is_directory(path);
|
||||||
|
wait := false;
|
||||||
|
if there && index < 0 {
|
||||||
|
viewer_add_capture(path);
|
||||||
|
log("[viewer] % was added to the library.", path);
|
||||||
|
} else if !there && index >= 0 {
|
||||||
|
if busy wait = true;
|
||||||
|
else { viewer_mark_removed(index); log("[viewer] % was removed from the library.", path); }
|
||||||
|
} else if there && (viewer.captures[index].failed || viewer.captures[index].catalog_failed) {
|
||||||
|
// Read while it was still being written (a copy in progress): read it again, as a new capture.
|
||||||
|
if busy wait = true;
|
||||||
|
else { viewer_mark_removed(index); viewer_add_capture(path); }
|
||||||
|
}
|
||||||
|
if wait { viewer_watch.paths[kept] = path; kept += 1; }
|
||||||
|
else free(path,, context.default_allocator);
|
||||||
|
}
|
||||||
|
viewer_watch.paths.count = kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_watch_callback :: (watcher: *Watcher.File_Watcher(void), change: *Watcher.File_Change, user_data: *void) {
|
||||||
|
Event :: Watcher.File_Change_Event;
|
||||||
|
if change.events & (Event.OVERFLOW | .SCAN_CHILDREN) { viewer_watch.rescan = true; return; }
|
||||||
|
path := change.full_path;
|
||||||
|
if !viewer_is_library_path(path) return;
|
||||||
|
if viewer_is_library_file(path) && !(change.events & .IS_FOLDER) {
|
||||||
|
for viewer_watch.paths if it == path return;
|
||||||
|
array_add(*viewer_watch.paths, copy_string(path,, context.default_allocator));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A folder's entries changing makes it MODIFIED, which says nothing about the folder itself.
|
||||||
|
if !(change.events & (Event.ADDED | .REMOVED | .MOVED)) return;
|
||||||
|
if ends_with_nocase(path, ".tmp") return; // the app's own writes, on their way to a .mcap
|
||||||
|
// A folder now, or one that held captures: what is in it came or went with it.
|
||||||
|
if is_directory(path) { viewer_watch.rescan = true; return; }
|
||||||
|
prefix := tprint("%/", path);
|
||||||
|
for viewer.captures if !it.removed && begins_with(it.path, prefix) { viewer_watch.rescan = true; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -cold: every capture's file dropped from the system's file cache, so the catalog and previews are
|
||||||
|
// read from the disk as on the first start after a reboot.
|
||||||
|
viewer_evict_file_cache :: () {
|
||||||
|
start := current_time_monotonic();
|
||||||
|
evicted: s64;
|
||||||
|
for viewer.captures if platform_evict_file_cache(it.path) evicted += 1;
|
||||||
|
platform_evict_file_cache(catalog_cache_path());
|
||||||
|
if !evicted && viewer.captures.count {
|
||||||
|
log("[viewer] -cold: the system's file cache cannot be emptied here; the library is read from whatever it holds.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log("[viewer] -cold: dropped % of % captures from the system's file cache in % ms.", evicted, viewer.captures.count, FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
viewer_capture_init :: (capture: *Viewer_Capture, path: string) {
|
viewer_capture_init :: (capture: *Viewer_Capture, path: string) {
|
||||||
push_allocator(context.default_allocator);
|
push_allocator(context.default_allocator);
|
||||||
capture.path = copy_string(path);
|
capture.path = copy_string(path);
|
||||||
@ -577,6 +821,7 @@ viewer_mark_removed :: (index: s32) {
|
|||||||
if capture.removed return;
|
if capture.removed return;
|
||||||
capture.removed = true;
|
capture.removed = true;
|
||||||
viewer.removed_count += 1;
|
viewer.removed_count += 1;
|
||||||
|
catalog_cache.dirty = true; // the next save leaves it out
|
||||||
if capture.texture.handle {
|
if capture.texture.handle {
|
||||||
gpu_texture_release(*capture.texture);
|
gpu_texture_release(*capture.texture);
|
||||||
found, position := array_find(viewer.with_texture, index);
|
found, position := array_find(viewer.with_texture, index);
|
||||||
@ -592,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];
|
||||||
@ -615,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);
|
||||||
@ -675,8 +923,9 @@ viewer_delete_now :: (index: s32) {
|
|||||||
else toast_show(.Success, "Overwrote and deleted %", name);
|
else toast_show(.Success, "Overwrote and deleted %", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
|
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
|
||||||
else VIEWER_TRASH_NAME :: "deleted folder";
|
else #if OS == .MACOS VIEWER_TRASH_NAME :: "Trash";
|
||||||
|
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.
|
||||||
viewer_delete_popup :: () { using ImGui;
|
viewer_delete_popup :: () { using ImGui;
|
||||||
@ -844,8 +1093,14 @@ viewer_request_full :: (index: s32) {
|
|||||||
// ---------------------------------------------------------------- drawing
|
// ---------------------------------------------------------------- drawing
|
||||||
|
|
||||||
viewer_ui :: () { using ImGui;
|
viewer_ui :: () { using ImGui;
|
||||||
|
if !viewer.first_frame_logged { viewer.first_frame_logged = true; log("[startup] First frame of the grid at % ms.", FF(startup_ms(), 1)); }
|
||||||
|
viewer_watch_frame(); // files added or deleted outside the app
|
||||||
viewer_frame_gpu_work();
|
viewer_frame_gpu_work();
|
||||||
|
|
||||||
|
start := GetCursorPos();
|
||||||
|
viewer_corner_buttons(start);
|
||||||
|
SetCursorPos(start);
|
||||||
|
|
||||||
if viewer.scan_error {
|
if viewer.scan_error {
|
||||||
TextColored(app_theme.palette.warning, "%", viewer.scan_error);
|
TextColored(app_theme.palette.warning, "%", viewer.scan_error);
|
||||||
SameLine();
|
SameLine();
|
||||||
@ -855,6 +1110,11 @@ viewer_ui :: () { using ImGui;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewer.focus_search {
|
||||||
|
viewer.focus_search = false;
|
||||||
|
SetWindowFocus(); // SetKeyboardFocusHere alone leaves another window in front
|
||||||
|
SetKeyboardFocusHere(); // tabbing in: its text is selected, so typing replaces the query
|
||||||
|
}
|
||||||
SetNextItemWidth(min(GetContentRegionAvail().x * 0.5, 520));
|
SetNextItemWidth(min(GetContentRegionAvail().x * 0.5, 520));
|
||||||
if InputTextWithHint("##search", "Search text, titles, URLs, apps, notes, labels. \"a phrase\" ocr: title: url: app: note: label: like: folder: has:", viewer.search_buffer.data, viewer.search_buffer.count) {
|
if InputTextWithHint("##search", "Search text, titles, URLs, apps, notes, labels. \"a phrase\" ocr: title: url: app: note: label: like: folder: has:", viewer.search_buffer.data, viewer.search_buffer.count) {
|
||||||
viewer_search_changed();
|
viewer_search_changed();
|
||||||
@ -885,7 +1145,7 @@ viewer_ui :: () { using ImGui;
|
|||||||
SameLine();
|
SameLine();
|
||||||
SetNextItemWidth(160);
|
SetNextItemWidth(160);
|
||||||
SliderFloat("Size", *viewer.thumb_size, 64, MCAP_PREVIEW_MAX_SIDE, "%.0f px");
|
SliderFloat("Size", *viewer.thumb_size, 64, MCAP_PREVIEW_MAX_SIDE, "%.0f px");
|
||||||
SameLine();
|
// The window buttons, on the row under the search.
|
||||||
// The OCR window (app/ocr_ui.jai): the model, GPU or CPU, and the library scan.
|
// The OCR window (app/ocr_ui.jai): the model, GPU or CPU, and the library scan.
|
||||||
if Button("OCR") ocr_ui.window_open = !ocr_ui.window_open;
|
if Button("OCR") ocr_ui.window_open = !ocr_ui.window_open;
|
||||||
SameLine();
|
SameLine();
|
||||||
@ -896,6 +1156,9 @@ viewer_ui :: () { using ImGui;
|
|||||||
if Button("Segments") segment_ui.window_open = !segment_ui.window_open; // app/segment_ui.jai
|
if Button("Segments") segment_ui.window_open = !segment_ui.window_open; // app/segment_ui.jai
|
||||||
if IsItemHovered() SetTooltip("Segmentation: what is where in the images of the library folders you choose");
|
if IsItemHovered() SetTooltip("Segmentation: what is where in the images of the library folders you choose");
|
||||||
SameLine();
|
SameLine();
|
||||||
|
if Button("GPU Bench") gpu_bench_ui.window_open = !gpu_bench_ui.window_open; // app/gpu_bench_ui.jai
|
||||||
|
if IsItemHovered() SetTooltip("Time every GPU model over a fixed corpus, and compare machines");
|
||||||
|
SameLine();
|
||||||
if Button("Import") importer.window_open = !importer.window_open; // app/import.jai
|
if Button("Import") importer.window_open = !importer.window_open; // app/import.jai
|
||||||
if IsItemHovered() SetTooltip("Bring in a folder of screenshots and photos");
|
if IsItemHovered() SetTooltip("Bring in a folder of screenshots and photos");
|
||||||
SameLine();
|
SameLine();
|
||||||
@ -907,8 +1170,8 @@ 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 starting, 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A running OCR scan, as a thin bar under the search, so it shows with the OCR window closed.
|
// A running OCR scan, as a thin bar under the search, so it shows with the OCR window closed.
|
||||||
@ -925,10 +1188,40 @@ viewer_ui :: () { using ImGui;
|
|||||||
viewer_grid();
|
viewer_grid();
|
||||||
EndChild();
|
EndChild();
|
||||||
viewer_delete_popup();
|
viewer_delete_popup();
|
||||||
|
viewer_export_popup();
|
||||||
|
|
||||||
viewer_full_window();
|
viewer_full_window();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The settings and Debug Panel buttons in the top right corner, on the search's row, sized to its text.
|
||||||
|
viewer_corner_buttons :: (start: Vector2) { using ImGui;
|
||||||
|
size := floor(GetTextLineHeight());
|
||||||
|
gap := floor(imgui_scale(4.0));
|
||||||
|
x := GetWindowContentRegionMax().x - TOP_RIGHT_PADDING - 2 * size - gap;
|
||||||
|
y := start.y + GetStyle().FramePadding.y; // level with the text in the row's frames
|
||||||
|
draw := GetWindowDrawList();
|
||||||
|
text := GetColorU32(.Text);
|
||||||
|
|
||||||
|
SetCursorPos(.{x, y});
|
||||||
|
settings_clicked, settings_min := imgui_icon_button("##mcap_open_settings", size);
|
||||||
|
settings_path := app_user_settings_path();
|
||||||
|
ButtonTooltip(tprint("Opens %", settings_path));
|
||||||
|
imgui_draw_gear(draw, settings_min, size, text);
|
||||||
|
if settings_clicked {
|
||||||
|
if !file_exists(settings_path) app_save_user_settings_file();
|
||||||
|
app_open_config_file(settings_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetCursorPos(.{x + size + gap, y});
|
||||||
|
debug_clicked, debug_min := imgui_icon_button("##mcap_toggle_debug_panel", size);
|
||||||
|
hotkey := to_string(toggle_debug_panel_hotkey);
|
||||||
|
tooltip := ifx app.show_debug_panel then "Hides the Debug Panel" else "Shows the Debug Panel";
|
||||||
|
ButtonTooltip(ifx hotkey.count then tprint("% (%)", tooltip, hotkey) else tooltip);
|
||||||
|
// In the accent while the panel shows.
|
||||||
|
imgui_draw_bug(draw, debug_min, size, ifx app.show_debug_panel then GetColorU32(app_theme.palette.accent) else text);
|
||||||
|
if debug_clicked app.show_debug_panel = !app.show_debug_panel;
|
||||||
|
}
|
||||||
|
|
||||||
// The trash button's square in a thumbnail whose top left is at top_left.
|
// The trash button's square in a thumbnail whose top left is at top_left.
|
||||||
viewer_trash_rect :: (top_left: ImGui.ImVec2, cell: float) -> ImGui.ImVec2, ImGui.ImVec2 {
|
viewer_trash_rect :: (top_left: ImGui.ImVec2, cell: float) -> ImGui.ImVec2, ImGui.ImVec2 {
|
||||||
size := clamp(cell * 0.16, 18, 30);
|
size := clamp(cell * 0.16, 18, 30);
|
||||||
@ -987,17 +1280,28 @@ viewer_duration_text :: (seconds: float64) -> string {
|
|||||||
// Writes the capture's original (a video, an animated GIF) as it was imported to a file in the
|
// Writes the capture's original (a video, an animated GIF) as it was imported to a file in the
|
||||||
// temporary folder, on a thread of its own, as it may be large; viewer_open_frame opens it.
|
// temporary folder, on a thread of its own, as it may be large; viewer_open_frame opens it.
|
||||||
viewer_open_original :: (index: s32) {
|
viewer_open_original :: (index: s32) {
|
||||||
|
viewer_write_original(index, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the capture's original as it was imported into folder, on a thread of its own, as it may be
|
||||||
|
// large; viewer_open_frame says where it went.
|
||||||
|
viewer_export_original :: (index: s32, folder: string) {
|
||||||
|
viewer_write_original(index, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_write_original :: (index: s32, folder: string) {
|
||||||
if viewer.open_started return;
|
if viewer.open_started return;
|
||||||
capture := *viewer.captures[index];
|
capture := *viewer.captures[index];
|
||||||
push_allocator(context.default_allocator);
|
push_allocator(context.default_allocator);
|
||||||
free(viewer.open_source); free(viewer.open_name); free(viewer.open_result); free(viewer.open_error);
|
free(viewer.open_source); free(viewer.open_name); free(viewer.open_folder); free(viewer.open_result); free(viewer.open_error);
|
||||||
viewer.open_source = copy_string(capture.path);
|
viewer.open_source = copy_string(capture.path);
|
||||||
viewer.open_name = copy_string(capture.name);
|
viewer.open_name = copy_string(capture.name);
|
||||||
|
viewer.open_folder = copy_string(folder);
|
||||||
viewer.open_result = "";
|
viewer.open_result = "";
|
||||||
viewer.open_error = "";
|
viewer.open_error = "";
|
||||||
viewer.open_busy = true;
|
viewer.open_busy = true;
|
||||||
viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "open original");
|
viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "write original");
|
||||||
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not open %", capture.name); return; }
|
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not write % out", capture.name); return; }
|
||||||
thread_start(*viewer.open_thread);
|
thread_start(*viewer.open_thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1007,14 +1311,26 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
|
|||||||
m, original, ok, error := mcap_read_without_original(viewer.open_source,, temp);
|
m, original, ok, error := mcap_read_without_original(viewer.open_source,, temp);
|
||||||
if !ok { viewer.open_error = copy_string(error); return 0; }
|
if !ok { viewer.open_error = copy_string(error); return 0; }
|
||||||
|
|
||||||
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%");
|
exporting := viewer.open_folder.count > 0;
|
||||||
else base := "/tmp";
|
folder := viewer.open_folder;
|
||||||
folder := tprint("%/%", base, APP_FOLDER_NAME);
|
if !exporting {
|
||||||
|
folder = tprint("%/%", platform_temporary_folder(), 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; }
|
||||||
extension := path_extension(m.original_name);
|
extension := path_extension(m.original_name);
|
||||||
path := tprint("%/%.%", folder, viewer.open_name, ifx extension then extension else "bin");
|
if !extension extension = "bin";
|
||||||
|
path := tprint("%/%.%", folder, viewer.open_name, extension);
|
||||||
_, size, exists := file_modtime_and_size(path);
|
_, size, exists := file_modtime_and_size(path);
|
||||||
if !exists || size != original.size { // written out before, it is used again
|
// Written out before, it is used again. An export does not overwrite another file of that name.
|
||||||
|
if exporting && exists && size != original.size {
|
||||||
|
for 2..1000 {
|
||||||
|
path = tprint("%/% (%).%", folder, viewer.open_name, it, extension);
|
||||||
|
_, size, exists = file_modtime_and_size(path);
|
||||||
|
if !exists || size == original.size break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists || size != original.size {
|
||||||
ok, error = mcap_extract_original(original, path);
|
ok, error = mcap_extract_original(original, path);
|
||||||
if !ok { viewer.open_error = copy_string(error); return 0; }
|
if !ok { viewer.open_error = copy_string(error); return 0; }
|
||||||
}
|
}
|
||||||
@ -1022,14 +1338,212 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each frame: opens what viewer_open_proc wrote, once it is done.
|
// Each frame: opens what viewer_open_proc wrote, or says where it was exported, once it is done.
|
||||||
viewer_open_frame :: () {
|
viewer_open_frame :: () {
|
||||||
if !viewer.open_started || viewer.open_busy return;
|
if !viewer.open_started || viewer.open_busy return;
|
||||||
thread_is_done(*viewer.open_thread, -1);
|
thread_is_done(*viewer.open_thread, -1);
|
||||||
thread_deinit(*viewer.open_thread);
|
thread_deinit(*viewer.open_thread);
|
||||||
viewer.open_started = false;
|
viewer.open_started = false;
|
||||||
if viewer.open_result platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
|
if !viewer.open_result toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
|
||||||
else toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
|
else if viewer.open_folder toast_show(.Success, "Exported % to %", viewer.open_name, viewer.open_result);
|
||||||
|
else platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folders Export offers: the [export] folder lines in settings.config, else the Downloads folder.
|
||||||
|
viewer_export_choices :: () -> [] string {
|
||||||
|
if app_user_settings.export_folders return app_user_settings.export_folders;
|
||||||
|
#if OS == .WINDOWS downloads := tprint("%/Downloads", windows_expand_environment("%USERPROFILE%"));
|
||||||
|
else downloads := posix_expand_home("~/Downloads");
|
||||||
|
choices := NewArray(1, string,, temp);
|
||||||
|
choices[0] = downloads;
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asks where to export the capture's original, starting from the default folder.
|
||||||
|
viewer_export_ask :: (index: s32) {
|
||||||
|
viewer.export_index = index;
|
||||||
|
viewer.export_choice = 0;
|
||||||
|
viewer.export_add = false;
|
||||||
|
viewer.export_make_default = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_export_set_buffer :: (folder: string) {
|
||||||
|
count := min(folder.count, viewer.export_buffer.count - 1);
|
||||||
|
memcpy(viewer.export_buffer.data, folder.data, count);
|
||||||
|
viewer.export_buffer[count] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puts folder in [export]: first when it is to be the default, else at the end if it is not there yet.
|
||||||
|
viewer_export_remember :: (folder: string, make_default: bool) {
|
||||||
|
folders := *app_user_settings.export_folders;
|
||||||
|
changed := false;
|
||||||
|
if !folders.count {
|
||||||
|
// The list was only the Downloads folder: it stays offered.
|
||||||
|
array_add(folders, copy_string(viewer_export_choices()[0],, context.default_allocator));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
at := -1;
|
||||||
|
for folders.* if it == folder { at = it_index; break; }
|
||||||
|
if make_default && at != 0 {
|
||||||
|
copy := copy_string(folder,, context.default_allocator);
|
||||||
|
if at > 0 { free(folders.*[at],, context.default_allocator); array_ordered_remove_by_index(folders, at); }
|
||||||
|
array_insert_at(folders, copy, 0);
|
||||||
|
changed = true;
|
||||||
|
} else if at < 0 {
|
||||||
|
array_add(folders, copy_string(folder,, context.default_allocator));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if changed app_save_user_settings_file_and_toast("the export folders");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folder for viewer.export_index, as a modal popup.
|
||||||
|
viewer_export_popup :: () { using ImGui;
|
||||||
|
POPUP :: "Export capture";
|
||||||
|
if viewer.export_index >= 0 && !IsPopupOpen(POPUP) OpenPopup(POPUP);
|
||||||
|
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
|
||||||
|
index := viewer.export_index;
|
||||||
|
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed {
|
||||||
|
viewer.export_index = -1;
|
||||||
|
CloseCurrentPopup();
|
||||||
|
EndPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
capture := *viewer.captures[index];
|
||||||
|
Text("Export %", capture.name);
|
||||||
|
TextColored(app_theme.palette.dim, "Writes the file as it was imported, unchanged, into the folder.");
|
||||||
|
Spacing();
|
||||||
|
|
||||||
|
choices := viewer_export_choices();
|
||||||
|
if viewer.export_choice >= choices.count viewer.export_choice = 0;
|
||||||
|
custom := viewer.export_choice < 0;
|
||||||
|
SetNextItemWidth(GetFontSize() * 30);
|
||||||
|
if BeginCombo("##folders", temp_c_string(ifx custom then "Custom..." else choices[viewer.export_choice])) {
|
||||||
|
for choices {
|
||||||
|
if Selectable(temp_c_string(it), it_index == viewer.export_choice) viewer.export_choice = it_index;
|
||||||
|
if it_index == 0 && IsItemHovered() SetTooltip("The default");
|
||||||
|
}
|
||||||
|
Separator();
|
||||||
|
if Selectable("Custom...", custom) && !custom {
|
||||||
|
viewer.export_choice = -1;
|
||||||
|
viewer_export_set_buffer(choices[0]); // somewhere to start from
|
||||||
|
}
|
||||||
|
EndCombo();
|
||||||
|
}
|
||||||
|
SameLine();
|
||||||
|
Text("Folder");
|
||||||
|
custom = viewer.export_choice < 0;
|
||||||
|
|
||||||
|
folder: string;
|
||||||
|
if custom {
|
||||||
|
SetNextItemWidth(GetFontSize() * 30);
|
||||||
|
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
|
||||||
|
#if OS == .WINDOWS || OS == .MACOS {
|
||||||
|
SameLine();
|
||||||
|
if Button("Browse...") {
|
||||||
|
chosen := platform_choose_folder("Choose a folder to export to");
|
||||||
|
if chosen viewer_export_set_buffer(chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folder = trim(to_string(viewer.export_buffer.data));
|
||||||
|
Checkbox("Add it to the list", *viewer.export_add);
|
||||||
|
if IsItemHovered() SetTooltip("Adds a folder line under [export] in %", APP_USER_SETTINGS_FILE_NAME);
|
||||||
|
} else {
|
||||||
|
folder = choices[viewer.export_choice];
|
||||||
|
}
|
||||||
|
if custom || viewer.export_choice > 0 {
|
||||||
|
Checkbox("Make it the default", *viewer.export_make_default);
|
||||||
|
if IsItemHovered() SetTooltip("Puts it first under [export] in %", APP_USER_SETTINGS_FILE_NAME);
|
||||||
|
}
|
||||||
|
Spacing();
|
||||||
|
|
||||||
|
// Enter and Escape are the open dropdown's while it is up.
|
||||||
|
keys := IsWindowFocused();
|
||||||
|
if !folder BeginDisabled();
|
||||||
|
if Button("Export") || (folder && keys && IsKeyPressed(.Enter)) {
|
||||||
|
viewer.export_index = -1;
|
||||||
|
CloseCurrentPopup();
|
||||||
|
if (custom || viewer.export_choice > 0) && viewer.export_make_default viewer_export_remember(folder, true);
|
||||||
|
else if custom && viewer.export_add viewer_export_remember(folder, false);
|
||||||
|
viewer_export_original(index, folder);
|
||||||
|
}
|
||||||
|
if !folder EndDisabled();
|
||||||
|
SameLine();
|
||||||
|
if Button("Cancel") || (keys && IsKeyPressed(.Escape)) {
|
||||||
|
viewer.export_index = -1;
|
||||||
|
CloseCurrentPopup();
|
||||||
|
}
|
||||||
|
EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- copying the selected capture
|
||||||
|
|
||||||
|
// The selected capture: the one outlined in the grid and shown in the Capture window. -1 when none.
|
||||||
|
viewer_selected :: () -> s32 {
|
||||||
|
index := viewer.full_index;
|
||||||
|
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed return -1;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-Shift-C (copy_path in [hotkeys]): the selected capture's path, as the system writes paths.
|
||||||
|
viewer_copy_path :: () {
|
||||||
|
index := viewer_selected();
|
||||||
|
if index < 0 { toast_show(.Error, "Select a capture to copy its path"); return; }
|
||||||
|
capture := *viewer.captures[index];
|
||||||
|
path := capture.path;
|
||||||
|
#if OS == .WINDOWS path = replace(path, "/", "\\",, temp);
|
||||||
|
clipboard_set_text(path);
|
||||||
|
toast_show(.Success, "Copied the path of %", capture.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-C (copy_image in [hotkeys]): the selected capture's image, as the Capture window shows it
|
||||||
|
// (annotations and all; a video's frame), decoded on a thread of its own and put on the clipboard
|
||||||
|
// by viewer_copy_frame.
|
||||||
|
viewer_copy_image :: () {
|
||||||
|
index := viewer_selected();
|
||||||
|
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
|
||||||
|
capture := *viewer.captures[index];
|
||||||
|
#if OS == .LINUX {
|
||||||
|
toast_show(.Error, "Copying images is not available on this system yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if viewer.copy_started { toast_show(.Error, "Still copying %", viewer.copy_name); return; }
|
||||||
|
push_allocator(context.default_allocator);
|
||||||
|
free(viewer.copy_source); free(viewer.copy_name);
|
||||||
|
viewer.copy_source = copy_string(capture.path);
|
||||||
|
viewer.copy_name = copy_string(capture.name);
|
||||||
|
viewer.copy_done = .{};
|
||||||
|
viewer.copy_busy = true;
|
||||||
|
// Reading an image takes a few MB of temporary storage, as on the viewer's workers.
|
||||||
|
viewer.copy_started = thread_init(*viewer.copy_thread, viewer_copy_proc, temporary_storage_size = 1024 * 1024, name = "copy image");
|
||||||
|
if !viewer.copy_started { viewer.copy_busy = false; toast_show(.Error, "Could not copy %", capture.name); return; }
|
||||||
|
thread_start(*viewer.copy_thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_copy_proc :: (thread: *Thread) -> s64 {
|
||||||
|
context.allocator = context.default_allocator;
|
||||||
|
defer atomic_swap(*viewer.copy_busy, false);
|
||||||
|
viewer.copy_done = viewer_decode_full(viewer.copy_source);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each frame: puts what viewer_copy_proc decoded on the clipboard, once it is done.
|
||||||
|
viewer_copy_frame :: () {
|
||||||
|
if !viewer.copy_started || viewer.copy_busy return;
|
||||||
|
thread_is_done(*viewer.copy_thread, -1);
|
||||||
|
thread_deinit(*viewer.copy_thread);
|
||||||
|
viewer.copy_started = false;
|
||||||
|
done := *viewer.copy_done;
|
||||||
|
defer viewer_free_pixels(done);
|
||||||
|
if done.failed { toast_show(.Error, "Could not read the image of %; see the Event Log", viewer.copy_name); return; }
|
||||||
|
pixels := []u8.{cast(s64) done.width * done.height * 4, done.pixels};
|
||||||
|
if !platform_copy_image_to_clipboard(pixels, done.width, done.height) {
|
||||||
|
log_error("[viewer] Could not put the image of % on the clipboard.", viewer.copy_source);
|
||||||
|
toast_show(.Error, "Could not put % on the clipboard", viewer.copy_name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scaled := ifx done.width != done.source_width || done.height != done.source_height then tprint(", scaled down from % x %", done.source_width, done.source_height) else "";
|
||||||
|
log("[viewer] Copied the image of % (% x %%).", viewer.copy_source, done.width, done.height, scaled);
|
||||||
|
toast_show(.Success, "Copied % (% x %%)", viewer.copy_name, done.width, done.height, scaled);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer_grid :: () { using ImGui;
|
viewer_grid :: () { using ImGui;
|
||||||
@ -1132,6 +1646,13 @@ viewer_full_window :: () { using ImGui;
|
|||||||
TextColored(app_theme.palette.dim, "% x %", viewer.full_source_width, viewer.full_source_height);
|
TextColored(app_theme.palette.dim, "% x %", viewer.full_source_width, viewer.full_source_height);
|
||||||
}
|
}
|
||||||
if viewer.full_loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
|
if viewer.full_loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
|
||||||
|
SameLine();
|
||||||
|
exporting := viewer.open_started;
|
||||||
|
if exporting BeginDisabled();
|
||||||
|
if SmallButton("Export...") viewer_export_ask(viewer.full_index);
|
||||||
|
if exporting EndDisabled();
|
||||||
|
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported, unchanged, to a folder");
|
||||||
|
if exporting && viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Exporting..."); }
|
||||||
TextColored(app_theme.palette.dim, "Left / Right: previous / next");
|
TextColored(app_theme.palette.dim, "Left / Right: previous / next");
|
||||||
|
|
||||||
// Zoom is a percentage of the size the capture was taken at. A fit sets it from the window, larger
|
// Zoom is a percentage of the size the capture was taken at. A fit sets it from the window, larger
|
||||||
@ -1164,7 +1685,7 @@ viewer_full_window :: () { using ImGui;
|
|||||||
if SmallButton(temp_c_string(ifx entry.media == .VIDEO then "Play in the default player" else "Open in the default viewer")) viewer_open_original(viewer.full_index);
|
if SmallButton(temp_c_string(ifx entry.media == .VIDEO then "Play in the default player" else "Open in the default viewer")) viewer_open_original(viewer.full_index);
|
||||||
if busy EndDisabled();
|
if busy EndDisabled();
|
||||||
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported to the temporary folder, and opens it");
|
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported to the temporary folder, and opens it");
|
||||||
if busy { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); }
|
if busy && !viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); }
|
||||||
}
|
}
|
||||||
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
|
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
|
||||||
labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai
|
labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai
|
||||||
@ -1212,3 +1733,7 @@ viewer_full_window :: () { using ImGui;
|
|||||||
}
|
}
|
||||||
End();
|
End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#scope_file
|
||||||
|
|
||||||
|
Watcher :: #import "File_Watcher";
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
// jai generate.jai - -backend_sdl3_gpu3
|
// jai generate.jai - -backend_sdl3_gpu3
|
||||||
//
|
//
|
||||||
|
|
||||||
using ImGui;
|
// Not ImGui.Image: the app has its own Image (modules/Image_Decode), and this file is #load-ed into
|
||||||
|
// the app's scope. Keep the except when generating this file again.
|
||||||
|
using,except(Image) ImGui;
|
||||||
|
|
||||||
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
|
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
|
||||||
ImplSDL3_InitForOpenGL :: (window: *SDL_Window, sdl_gl_context: *void) -> bool #foreign imgui_sdl3_gpu3 "_Z28ImGui_ImplSDL3_InitForOpenGLP10SDL_WindowPv";
|
ImplSDL3_InitForOpenGL :: (window: *SDL_Window, sdl_gl_context: *void) -> bool #foreign imgui_sdl3_gpu3 "_Z28ImGui_ImplSDL3_InitForOpenGLP10SDL_WindowPv";
|
||||||
|
|||||||
@ -30,6 +30,8 @@ app_debug_panel_ui :: () {
|
|||||||
Text("Time to first frame = % ms", FF(perf.time_to_first_frame_ms, 1));
|
Text("Time to first frame = % ms", FF(perf.time_to_first_frame_ms, 1));
|
||||||
Checkbox("Frame Times window", *app.show_frame_time_graph);
|
Checkbox("Frame Times window", *app.show_frame_time_graph);
|
||||||
ButtonTooltip(to_string(toggle_frame_times_hotkey));
|
ButtonTooltip(to_string(toggle_frame_times_hotkey));
|
||||||
|
Checkbox("Command Line window", *app.show_help_panel);
|
||||||
|
ButtonTooltip("The command-line options; mcap --help opens it");
|
||||||
Spacing();
|
Spacing();
|
||||||
|
|
||||||
SeparatorText("Memory Usage");
|
SeparatorText("Memory Usage");
|
||||||
@ -104,6 +106,70 @@ app_frame_times_ui :: () {
|
|||||||
frame_time_graph();
|
frame_time_graph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The command-line options, opened by mcap -h or --help, or from the Debug Panel. Keep in step with
|
||||||
|
// the Command line table in README.md.
|
||||||
|
app_help_panel_ui :: () {
|
||||||
|
if !app.show_help_panel return;
|
||||||
|
using ImGui;
|
||||||
|
|
||||||
|
SetNextWindowSize(imgui_scale(Vector2.{760, 560}), .FirstUseEver);
|
||||||
|
if app.help_panel_focus { SetNextWindowFocus(); app.help_panel_focus = false; }
|
||||||
|
push_imgui_window("Command Line", p_open=*app.show_help_panel);
|
||||||
|
|
||||||
|
Text("% % [FILE] [options]", APP_NAME, VERSION);
|
||||||
|
SameLine();
|
||||||
|
if SmallButton("Copy") {
|
||||||
|
builder: String_Builder;
|
||||||
|
builder.allocator = temp;
|
||||||
|
for APP_COMMAND_LINE_OPTIONS print_to_builder(*builder, "%\t%\n", it.option, it.text);
|
||||||
|
clipboard_set_text(builder_to_string(*builder,, temp));
|
||||||
|
toast_show(.Success, "Copied the command-line options");
|
||||||
|
}
|
||||||
|
TextColored(app_theme.palette.dim, "-import, -scan, -segment, -stats and -erase still open the window, to show their progress. README.md has the details.");
|
||||||
|
Spacing();
|
||||||
|
|
||||||
|
if BeginTable("options", 2, .Borders | .RowBg | .SizingStretchProp | .ScrollY) {
|
||||||
|
TableSetupColumn("Option", .WidthFixed);
|
||||||
|
TableSetupColumn("What it does", .WidthStretch);
|
||||||
|
TableSetupScrollFreeze(0, 1);
|
||||||
|
TableHeadersRow();
|
||||||
|
for APP_COMMAND_LINE_OPTIONS {
|
||||||
|
TableNextRow();
|
||||||
|
TableNextColumn();
|
||||||
|
TextUnformatted(it.option);
|
||||||
|
TableNextColumn();
|
||||||
|
TextWrapped("%", it.text);
|
||||||
|
}
|
||||||
|
EndTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command_Line_Option :: struct { option: string; text: string; }
|
||||||
|
|
||||||
|
APP_COMMAND_LINE_OPTIONS :: Command_Line_Option.[
|
||||||
|
.{"FILE (first)", "Opens FILE: an .mcap, image or video. With mcap already running, it opens in view mode beside it."},
|
||||||
|
.{"-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."},
|
||||||
|
.{"-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."},
|
||||||
|
.{"-scan", "Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed."},
|
||||||
|
.{"-segment", "Segments the images not yet segmented in the library folders set to segment, then exits: 0, or 1 if any image failed."},
|
||||||
|
.{"-stats", "Logs the Stats window's numbers once the catalog has loaded, then exits; with -search, for the matches."},
|
||||||
|
.{"-erase WHAT", "Erases ocr, labels, segments or all from every file in the library, then exits (for testing)."},
|
||||||
|
.{"-record SECONDS", "Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed."},
|
||||||
|
.{"-record-region X Y W H", "With -record: records that region instead."},
|
||||||
|
.{"-record-quit", "With -record: quits while still recording, to check the recording is kept."},
|
||||||
|
.{"-record-ui-visible", "With -record: leaves the border and toolbar in screenshots, to look at them."},
|
||||||
|
.{"-cold", "Loads the library as on a cold start (its files dropped from the system's file cache first). With -stats, a startup benchmark."},
|
||||||
|
.{"-no-catalog-cache", "Reads every capture from its file, as without the catalog cache (for measuring); the cache is still written."},
|
||||||
|
.{"-catalog-threads N", "Loads the catalog on N threads instead of all the background group's (for measuring)."},
|
||||||
|
.{"-allow-multiple-instances", "Runs beside another copy instead of bringing that one forward."},
|
||||||
|
.{"-headless", "No message boxes: a failure is logged and the app exits with 1."},
|
||||||
|
.{"run_tests", "Runs the test suites (debug builds); with -headless, exits with 0 if all passed."},
|
||||||
|
.{"-version", "Prints the version, then exits."},
|
||||||
|
.{"-h, --help", "Opens this window."},
|
||||||
|
];
|
||||||
|
|
||||||
frame_time_graph :: () {
|
frame_time_graph :: () {
|
||||||
using ImGui;
|
using ImGui;
|
||||||
|
|
||||||
|
|||||||
@ -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"] {
|
||||||
@ -133,3 +140,12 @@ platform_set_thread_affinity :: (thread: *Thread, affinity: CPUID_Type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
XDG :: #import "XDG";
|
XDG :: #import "XDG";
|
||||||
|
|
||||||
|
// Drops what the page cache holds of the file, so the next read comes from the disk (-cold). Clean
|
||||||
|
// pages go at once; pages not yet written back stay. False when it cannot be opened.
|
||||||
|
platform_evict_file_cache :: (path: string) -> bool {
|
||||||
|
fd := POSIX.open(temp_c_string(path), POSIX.O_RDONLY | POSIX.O_CLOEXEC);
|
||||||
|
if fd < 0 return false;
|
||||||
|
defer POSIX.close(fd);
|
||||||
|
return POSIX.posix_fadvise(fd, 0, 0, POSIX.POSIX_FADV_DONTNEED) == 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -70,7 +70,100 @@ 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";
|
||||||
#import "Objective_C/AppKit";
|
#import "Objective_C/AppKit";
|
||||||
|
|
||||||
|
// macOS lets no unprivileged program empty its file cache of a file (only `sudo purge`, all of it),
|
||||||
|
// so -cold reads whatever is cached.
|
||||||
|
platform_evict_file_cache :: (path: string) -> bool {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Platform :: struct { // Windows
|
|||||||
global_hotkeys_failed: string; // the combinations that could not be registered, for the main window; heap
|
global_hotkeys_failed: string; // the combinations that could not be registered, for the main window; heap
|
||||||
tray_icon_configured: bool; // nid and its icon are ready; adding it can still fail while Explorer is not
|
tray_icon_configured: bool; // nid and its icon are ready; adding it can still fail while Explorer is not
|
||||||
tray_icon_failed: bool; // minimizing then goes to the taskbar; the main window says so
|
tray_icon_failed: bool; // minimizing then goes to the taskbar; the main window says so
|
||||||
|
tray_icon_added: bool; // the icon is in the notification area now
|
||||||
|
|
||||||
hwnd: HWND;
|
hwnd: HWND;
|
||||||
|
|
||||||
@ -173,8 +174,10 @@ WndProc :: (hWnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT
|
|||||||
if swallow_hotkey_character && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) return 0;
|
if swallow_hotkey_character && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) return 0;
|
||||||
|
|
||||||
if taskbar_created_message && message == taskbar_created_message {
|
if taskbar_created_message && message == taskbar_created_message {
|
||||||
// Without the icon, a window hidden in the tray could only come back by hotkey.
|
// The icon stays for as long as the app runs, so it goes back. Without it, a window hidden
|
||||||
if app.minimized_to_tray && !Win32_Show_Tray_Icon() {
|
// in the tray could only come back by hotkey.
|
||||||
|
tray_icon_added = false;
|
||||||
|
if !Win32_Show_Tray_Icon() && app.minimized_to_tray {
|
||||||
ShowWindow(hwnd, SW_MINIMIZE);
|
ShowWindow(hwnd, SW_MINIMIZE);
|
||||||
app.minimized_to_tray = false;
|
app.minimized_to_tray = false;
|
||||||
}
|
}
|
||||||
@ -184,6 +187,9 @@ WndProc :: (hWnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT
|
|||||||
if message == {
|
if message == {
|
||||||
case WM_SYSCOMMAND;
|
case WM_SYSCOMMAND;
|
||||||
SC_MINIMIZE :: 0xF020; SC_CLOSE :: 0xF060;
|
SC_MINIMIZE :: 0xF020; SC_CLOSE :: 0xF060;
|
||||||
|
// View mode (app/open_file.jai) has no tray: close ends it, and minimize is the system's own.
|
||||||
|
if app.view_mode && (wParam & 0xFFF0) == SC_CLOSE { app.should_exit = true; return 0; }
|
||||||
|
if app.view_mode && (wParam & 0xFFF0) == SC_MINIMIZE return DefWindowProcW(hWnd, message, wParam, lParam);
|
||||||
if (wParam & 0xFFF0) == {
|
if (wParam & 0xFFF0) == {
|
||||||
case SC_CLOSE; #through;
|
case SC_CLOSE; #through;
|
||||||
case SC_MINIMIZE;
|
case SC_MINIMIZE;
|
||||||
@ -298,6 +304,7 @@ platform_apply_global_hotkeys :: (actions: [] Hotkey_Action) { using platform;
|
|||||||
id := global_hotkey_id(action_index, binding_index);
|
id := global_hotkey_id(action_index, binding_index);
|
||||||
if !RegisterHotKey(hwnd, id, modifiers, vk) {
|
if !RegisterHotKey(hwnd, id, modifiers, vk) {
|
||||||
log_error("[hotkeys] Could not register the global hotkey % (%); another program may be using it.", to_string(combo), action.name);
|
log_error("[hotkeys] Could not register the global hotkey % (%); another program may be using it.", to_string(combo), action.name);
|
||||||
|
if vk == VK_SNAPSHOT log_error("[hotkeys] PrintScreen is taken by Snagit when it runs, and by Windows when Settings > Accessibility > Keyboard > \"Use the Print screen key to open screen capture\" is on.");
|
||||||
array_add(*failed, to_string(combo));
|
array_add(*failed, to_string(combo));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -323,6 +330,7 @@ win32_virtual_key :: (key: ImGui.Key) -> u32 {
|
|||||||
if k >= cast(s64) ImGui.Key._0 && k <= cast(s64) ImGui.Key._9 return cast(u32) (#char "0" + k - cast(s64) ImGui.Key._0);
|
if k >= cast(s64) ImGui.Key._0 && k <= cast(s64) ImGui.Key._9 return cast(u32) (#char "0" + k - cast(s64) ImGui.Key._0);
|
||||||
if k >= cast(s64) ImGui.Key.F1 && k <= cast(s64) ImGui.Key.F24 return cast(u32) (VK_F1 + k - cast(s64) ImGui.Key.F1);
|
if k >= cast(s64) ImGui.Key.F1 && k <= cast(s64) ImGui.Key.F24 return cast(u32) (VK_F1 + k - cast(s64) ImGui.Key.F1);
|
||||||
if key == {
|
if key == {
|
||||||
|
case .PrintScreen; return VK_SNAPSHOT;
|
||||||
case .Space; return VK_SPACE;
|
case .Space; return VK_SPACE;
|
||||||
case .Enter; return VK_RETURN;
|
case .Enter; return VK_RETURN;
|
||||||
case .Tab; return VK_TAB;
|
case .Tab; return VK_TAB;
|
||||||
@ -341,9 +349,11 @@ win32_virtual_key :: (key: ImGui.Key) -> u32 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also fails while Explorer is not running, so it is checked on every call.
|
// Adds the icon unless it is already there. Fails while Explorer is not running, so a failed add
|
||||||
|
// is tried again on the next call.
|
||||||
Win32_Show_Tray_Icon :: () -> bool { using platform;
|
Win32_Show_Tray_Icon :: () -> bool { using platform;
|
||||||
if !tray_icon_configured return false;
|
if !tray_icon_configured return false;
|
||||||
|
if tray_icon_added return true;
|
||||||
if !cast(bool) Shell_NotifyIconW(NIM_ADD, *nid) {
|
if !cast(bool) Shell_NotifyIconW(NIM_ADD, *nid) {
|
||||||
log_error("Shell_NotifyIconW(NIM_ADD) failed; minimizing to the taskbar instead of the tray.");
|
log_error("Shell_NotifyIconW(NIM_ADD) failed; minimizing to the taskbar instead of the tray.");
|
||||||
os_log_error();
|
os_log_error();
|
||||||
@ -351,11 +361,13 @@ Win32_Show_Tray_Icon :: () -> bool { using platform;
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
tray_icon_failed = false; // an earlier failure while Explorer was down has passed
|
tray_icon_failed = false; // an earlier failure while Explorer was down has passed
|
||||||
|
tray_icon_added = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Win32_Hide_Tray_Icon :: () { using platform;
|
Win32_Hide_Tray_Icon :: () { using platform;
|
||||||
if tray_icon_configured Shell_NotifyIconW(NIM_DELETE, *nid);
|
if tray_icon_added Shell_NotifyIconW(NIM_DELETE, *nid);
|
||||||
|
tray_icon_added = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Win32_Set_Tray_Icon :: () { using platform;
|
Win32_Set_Tray_Icon :: () { using platform;
|
||||||
@ -381,13 +393,9 @@ Win32_Set_Tray_Icon :: () { using platform;
|
|||||||
nid.hIcon = tray_icon;
|
nid.hIcon = tray_icon;
|
||||||
memcpy(nid.szTip.data, Tooltip_Text_u16, Tooltip_Text.count * size_of(u16));
|
memcpy(nid.szTip.data, Tooltip_Text_u16, Tooltip_Text.count * size_of(u16));
|
||||||
// Configured even if this first add fails: at autostart Explorer's notification area may not be
|
// Configured even if this first add fails: at autostart Explorer's notification area may not be
|
||||||
// ready yet, and Win32_Show_Tray_Icon adds the icon again on every minimize.
|
// ready yet, and Win32_Show_Tray_Icon tries again on every minimize and when Explorer starts.
|
||||||
tray_icon_configured = true;
|
tray_icon_configured = true;
|
||||||
if !cast(bool) Shell_NotifyIconW(NIM_ADD, *nid) {
|
Win32_Show_Tray_Icon();
|
||||||
log_error("Shell_NotifyIconW(NIM_ADD) failed; minimizing to the taskbar until it succeeds.");
|
|
||||||
os_log_error();
|
|
||||||
tray_icon_failed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImGui rescales the fonts per monitor by itself, but the theme's sizes are baked at one scale, so
|
// ImGui rescales the fonts per monitor by itself, but the theme's sizes are baked at one scale, so
|
||||||
@ -426,7 +434,6 @@ Win32_Restore_Window_From_Tray :: () {
|
|||||||
if app.minimized_to_tray {
|
if app.minimized_to_tray {
|
||||||
ShowWindow(hwnd, SW_SHOW);
|
ShowWindow(hwnd, SW_SHOW);
|
||||||
app.minimized_to_tray = false;
|
app.minimized_to_tray = false;
|
||||||
Win32_Hide_Tray_Icon();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,6 +651,18 @@ platform_map_file_read_only :: (path: string) -> (data: string, ok: bool) {
|
|||||||
return string.{size, cast(*u8) view}, true;
|
return string.{size, cast(*u8) view}, true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drops what the system's file cache holds of the file, so the next read comes from the disk (-cold).
|
||||||
|
// Opening a file unbuffered makes the cache manager flush and purge its pages, when nothing has the
|
||||||
|
// file mapped. False when it cannot be opened.
|
||||||
|
platform_evict_file_cache :: (path: string) -> bool {
|
||||||
|
FILE_FLAG_NO_BUFFERING :: 0x20000000;
|
||||||
|
wide := utf8_to_wide(path,, temp);
|
||||||
|
handle := CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, null, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING);
|
||||||
|
if handle == INVALID_HANDLE_VALUE return false;
|
||||||
|
CloseHandle(handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// "" when the variable is unset or empty. In the current allocator.
|
// "" when the variable is unset or empty. In the current allocator.
|
||||||
windows_environment_variable :: (name: string) -> string {
|
windows_environment_variable :: (name: string) -> string {
|
||||||
name_w := utf8_to_wide(name,, temp);
|
name_w := utf8_to_wide(name,, temp);
|
||||||
@ -829,6 +848,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 {
|
||||||
|
|||||||
@ -49,15 +49,8 @@ platform_restore_window :: () {
|
|||||||
// A global hotkey arrives while another app is active, and raising the window alone does not
|
// A global hotkey arrives while another app is active, and raising the window alone does not
|
||||||
// make this the active app.
|
// make this the active app.
|
||||||
NSApplication.activateIgnoringOtherApps(NSApplication.sharedApplication(), YES);
|
NSApplication.activateIgnoringOtherApps(NSApplication.sharedApplication(), YES);
|
||||||
platform_hide_tray_icon();
|
|
||||||
}
|
}
|
||||||
#if OS == .LINUX {
|
|
||||||
// Right now, this doesn't work on my XFCE Arch Desktop, so I'm just going to leave the icon in the tray. Sad.
|
|
||||||
if platform.desktop_environment == .XFCE {
|
|
||||||
log("platform_hide_tray_icon is broken on XFCE, so it is currently disabled!");
|
|
||||||
} else platform_hide_tray_icon();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.minimized_to_tray = false;
|
app.minimized_to_tray = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
src/base/platform/win32_gdi_bindings.jai
Normal file
68
src/base/platform/win32_gdi_bindings.jai
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Win32 calls and types for the GDI windows the app draws itself (app/screen_capture_win32.jai's
|
||||||
|
// overlay, app/screen_record_win32.jai's border and toolbar) that modules/Windows.jai does not have.
|
||||||
|
|
||||||
|
|
||||||
|
gdi32 :: #library,system "gdi32";
|
||||||
|
version_dll :: #library,system "version";
|
||||||
|
|
||||||
|
HFONT :: HANDLE;
|
||||||
|
HRGN :: HANDLE;
|
||||||
|
|
||||||
|
PAINTSTRUCT :: struct {
|
||||||
|
hdc: HDC;
|
||||||
|
fErase: BOOL;
|
||||||
|
rcPaint: RECT;
|
||||||
|
fRestore: BOOL;
|
||||||
|
fIncUpdate: BOOL;
|
||||||
|
rgbReserved: [32] u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
CAPTUREBLT :: 0x40000000;
|
||||||
|
GA_ROOT :: 2;
|
||||||
|
WA_INACTIVE :: 0;
|
||||||
|
WM_ACTIVATE :: 0x0006;
|
||||||
|
TRANSPARENT :: 1;
|
||||||
|
CLEARTYPE_QUALITY :: 5;
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000;
|
||||||
|
|
||||||
|
GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32;
|
||||||
|
BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32;
|
||||||
|
EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32;
|
||||||
|
FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32;
|
||||||
|
GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32;
|
||||||
|
GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32;
|
||||||
|
GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32;
|
||||||
|
GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32;
|
||||||
|
GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32;
|
||||||
|
capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos";
|
||||||
|
|
||||||
|
CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32;
|
||||||
|
CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32;
|
||||||
|
SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32;
|
||||||
|
SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32;
|
||||||
|
SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32;
|
||||||
|
TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32;
|
||||||
|
GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32;
|
||||||
|
CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32,
|
||||||
|
strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32,
|
||||||
|
pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32;
|
||||||
|
|
||||||
|
OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32;
|
||||||
|
QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32;
|
||||||
|
|
||||||
|
GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll;
|
||||||
|
GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll;
|
||||||
|
VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll;
|
||||||
|
|
||||||
|
// The recording's border and toolbar (app/screen_record_win32.jai).
|
||||||
|
MA_NOACTIVATE :: 3;
|
||||||
|
LWA_ALPHA :: 0x2;
|
||||||
|
RGN_DIFF :: 4;
|
||||||
|
WDA_EXCLUDEFROMCAPTURE :: 0x11; // Windows 10 2004 on: left out of screenshots and recordings
|
||||||
|
|
||||||
|
ShowWindowAsync :: (hwnd: HWND, show: s32) -> BOOL #foreign user32;
|
||||||
|
SetWindowRgn :: (hwnd: HWND, region: HRGN, redraw: BOOL) -> s32 #foreign user32;
|
||||||
|
SetLayeredWindowAttributes :: (hwnd: HWND, key: u32, alpha: u8, flags: u32) -> BOOL #foreign user32;
|
||||||
|
SetWindowDisplayAffinity :: (hwnd: HWND, affinity: u32) -> BOOL #foreign user32;
|
||||||
|
CombineRgn :: (destination: HRGN, a: HRGN, b: HRGN, mode: s32) -> s32 #foreign gdi32;
|
||||||
|
Ellipse :: (dc: HDC, left: s32, top: s32, right: s32, bottom: s32) -> BOOL #foreign gdi32;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// The catalog's pieces on their own (app/catalog.jai, app/viewer.jai): the search query's parsing and
|
// The catalog's pieces on their own (app/catalog.jai, app/viewer.jai): the search query's parsing and
|
||||||
// matching, the RTF stripper on Snagit's annotations, and the grid's order by date.
|
// matching, the RTF stripper on Snagit's annotations, the grid's order by date, and the catalog cache.
|
||||||
|
|
||||||
#scope_file
|
#scope_file
|
||||||
|
|
||||||
@ -136,6 +136,81 @@ order_tests :: () {
|
|||||||
expect(viewer_display_order("2025-01-01 00:00:00", "b", "2025-01-01 00:00:00", "a") < 0, "the same date: by name, newest first");
|
expect(viewer_display_order("2025-01-01 00:00:00", "b", "2025-01-01 00:00:00", "a") < 0, "the same date: by name, newest first");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The catalog cache (app/catalog.jai): an entry written and read back is the same, and only the
|
||||||
|
// records whose file is unchanged are given again.
|
||||||
|
cache_tests :: () {
|
||||||
|
group("cache");
|
||||||
|
folder := viewer_folder();
|
||||||
|
entry := New(Catalog_Entry);
|
||||||
|
entry.date = copy_string("2026-10-07 08:11:15"); entry.title = copy_string("KiCad"); entry.url = copy_string("https://x");
|
||||||
|
entry.app = copy_string("kicad"); entry.notes = copy_string("late\nnote"); entry.label_list = copy_string("f6d0");
|
||||||
|
entry.video_codec = copy_string("h264"); entry.segment_classes = copy_string("ade20k");
|
||||||
|
entry.media = .VIDEO; entry.duration = 12.5; entry.frame_rate = 30; entry.segment_count = 2;
|
||||||
|
entry.embedding = array_copy(u16.[0x3C00, 0x4000, 0x4200]);
|
||||||
|
array_add(*entry.labels, .{0.9, copy_string("Schematic")}, .{0.4, copy_string("PCB layout")});
|
||||||
|
array_add(*entry.segments, .{copy_string("person"), 0.8, 0.25});
|
||||||
|
section := array_add(*entry.ocr);
|
||||||
|
section.* = .{model = copy_string("ppocr"), engine = copy_string("ort"), image = copy_string("rendered"), image_width = 640, image_height = 480, date = copy_string("2026-10-08T21:04:11Z")};
|
||||||
|
array_add(*section.lines, .{1, 2, 3, 4, 0.75, copy_string("0.1uF : Capacitor_SMD"), .[1, 2, 3, 4, 5, 6, 7, 8]});
|
||||||
|
entry.file_size, entry.file_modified = 1234, 5678;
|
||||||
|
|
||||||
|
records := Catalog_Cache_Record.[.{"A/one.mcap", entry}, .{"A/two.mcap", entry}];
|
||||||
|
bytes := catalog_cache_serialize(records);
|
||||||
|
defer array_free(bytes);
|
||||||
|
|
||||||
|
// one.mcap unchanged; two.mcap changed since; three.mcap not in the cache.
|
||||||
|
job: Catalog_Cache_Index;
|
||||||
|
job.data = cast(string) bytes;
|
||||||
|
captures := Catalog_Item.[
|
||||||
|
.{index = 0, path = tprint("%/A/one.mcap", folder), size = 1234, modified = 5678},
|
||||||
|
.{index = 1, path = tprint("%/A/two.mcap", folder), size = 1234, modified = 9999},
|
||||||
|
.{index = 2, path = tprint("%/A/three.mcap", folder), size = 1, modified = 1}];
|
||||||
|
job.captures = captures;
|
||||||
|
matched: [3] bool;
|
||||||
|
problem := catalog_cache_index(*job, matched);
|
||||||
|
expect(problem == "", "a cache it wrote is read whole: %", problem);
|
||||||
|
expect(job.records == 2 && job.items.count == 1 && job.items[0].index == 0 && job.stale == 1, "only the unchanged file's record is given: % records, % items, % stale", job.records, job.items.count, job.stale);
|
||||||
|
expect(matched[0] && !matched[1] && !matched[2], "the others are read from their files");
|
||||||
|
|
||||||
|
if job.items.count {
|
||||||
|
d := Deserializer.{count = bytes.count, data = bytes.data, cursor = job.items[0].offset};
|
||||||
|
back := catalog_cache_read_entry(*d);
|
||||||
|
expect(back != null, "the entry reads back");
|
||||||
|
if back {
|
||||||
|
catalog_finish(back, captures[0].path);
|
||||||
|
expect(back.date == entry.date && back.title == entry.title && back.url == entry.url && back.app == entry.app && back.notes == entry.notes, "its texts");
|
||||||
|
expect(back.media == .VIDEO && back.duration == 12.5 && back.frame_rate == 30 && back.video_codec == "h264", "its media");
|
||||||
|
expect(back.label_list == "f6d0" && back.labels.count == 2 && back.labels[1].name == "PCB layout" && back.labels[0].score == 0.9, "its labels");
|
||||||
|
expect(back.embedding.count == 3 && back.embedding[2] == 0x4200, "its image vector");
|
||||||
|
expect(back.segment_count == 2 && back.segments.count == 1 && back.segments[0].class == "person" && back.segments[0].area == 0.25, "its segments");
|
||||||
|
ok := back.ocr.count == 1 && back.ocr[0].model == "ppocr" && back.ocr[0].image_height == 480 && back.ocr[0].lines.count == 1;
|
||||||
|
if ok { line := back.ocr[0].lines[0]; ok = line.text == "0.1uF : Capacitor_SMD" && line.height == 4 && line.confidence == 0.75 && line.corners[7] == 8; }
|
||||||
|
expect(ok, "its OCR lines");
|
||||||
|
expect(back.find_ocr == "0.1uf : capacitor_smd\n" && back.find_folder == "a" && back.find_labels == "schematic\n", "catalog_finish makes its folder and lowercased copies: %, %, %", back.find_ocr, back.find_folder, back.find_labels);
|
||||||
|
catalog_entry_free(back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array_free(job.items);
|
||||||
|
array_free(job.misses);
|
||||||
|
|
||||||
|
// A cache from another version is not used at all.
|
||||||
|
(cast(*u32) (bytes.data + CATALOG_CACHE_MAGIC.count)).* = CATALOG_CACHE_VERSION + 1;
|
||||||
|
other: Catalog_Cache_Index;
|
||||||
|
other.data = cast(string) bytes;
|
||||||
|
other.captures = captures;
|
||||||
|
matched = .[false, false, false];
|
||||||
|
expect(catalog_cache_index(*other, matched) != "" && !other.items.count, "another version's cache gives nothing");
|
||||||
|
// Truncated: the records before the damage are still good.
|
||||||
|
(cast(*u32) (bytes.data + CATALOG_CACHE_MAGIC.count)).* = CATALOG_CACHE_VERSION;
|
||||||
|
short: Catalog_Cache_Index;
|
||||||
|
short.data = string.{bytes.count - 10, bytes.data};
|
||||||
|
short.captures = captures;
|
||||||
|
matched = .[false, false, false];
|
||||||
|
expect(catalog_cache_index(*short, matched) != "" && short.items.count == 1, "a cache cut short gives the records before the cut");
|
||||||
|
array_free(other.items); array_free(short.items);
|
||||||
|
catalog_entry_free(entry);
|
||||||
|
}
|
||||||
|
|
||||||
#scope_export
|
#scope_export
|
||||||
|
|
||||||
run_catalog_tests :: (builder: *String_Builder = null) -> bool {
|
run_catalog_tests :: (builder: *String_Builder = null) -> bool {
|
||||||
@ -146,5 +221,6 @@ run_catalog_tests :: (builder: *String_Builder = null) -> bool {
|
|||||||
match_tests();
|
match_tests();
|
||||||
rtf_tests();
|
rtf_tests();
|
||||||
order_tests();
|
order_tests();
|
||||||
|
cache_tests();
|
||||||
return report();
|
return report();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user