# pm-image (C++) CMake-based **`pm-image`** binary: **CLI**, **HTTP REST** (`serve`), and **line-delimited JSON IPC** (`ipc`). Image processing uses **libvips** — the same engine as **[Sharp](https://sharp.pixelplumbing.com/)** (Node.js), exposed with a similar option model. Optional **`.env`** is loaded from the working directory ([laserpants/dotenv-cpp](https://github.com/laserpants/dotenv-cpp)). - **API reference (underlying library):** [libvips `VipsImage`](https://www.libvips.org/API/current/class.Image.html) ## Prerequisites | Requirement | Notes | |-------------|--------| | CMake | ≥ 3.20 | | C++ compiler | C++17 | | **libvips** | **Required** — pkg-config (Unix) or `third_party/vips-dev-*` / `VIPS_ROOT` (Windows) | | Git | For `FetchContent` (CLI11, Asio, httplib, json, **libcurl**, dotenv, [p-ranav/glob](https://github.com/p-ranav/glob), [PicoSHA2](https://github.com/okdshin/PicoSHA2) for cache keys) | | Node.js | Optional — `npm run test:media` (Node 18+) | ### Installing libvips **Debian / Ubuntu** ```bash sudo apt install libvips-dev pkg-config ``` **macOS (Homebrew)** ```bash brew install vips pkg-config ``` **Windows (official dev bundle — recommended)** ```powershell npm run setup:vips ``` Downloads [build-win64-mxe `vips-dev-x64-all`](https://github.com/libvips/build-win64-mxe/releases) into `third_party/vips-dev-*`. CMake adds that path automatically; DLLs are copied to `dist/` on link. Pin the version with **`MEDIA_VIPS_VERSION`** (default `8.18.2`) if needed. Alternatively set **`VIPS_ROOT`** or **`CMAKE_PREFIX_PATH`** to a tree with `include/vips/vips.h` and `lib/libvips.lib`. ## Build ```bash cd packages/media/cpp cmake --preset release cmake --build --preset release ``` Binary: **`dist/pm-image`** (`.exe` on Windows). ### Windows installer (NSIS) From `packages/media/cpp`, after a release build: ```bash npm run build:installer ``` Produces **`dist/pm-image-Setup.exe`**, installs `pm-image.exe`, libvips DLLs, `vips-modules-8.18`, and `scripts/explorer-resize.ps1`, and prepends the install directory to the **user** `PATH`. Uninstaller is registered under Add/Remove Programs. ### Explorer context menu (`pm-image register-explorer`) Implemented in **C++** (Windows registry). The NSIS installer runs `pm-image.exe register-explorer` after copying files; uninstall runs `register-explorer --unregister --no-refresh-shell`. ```bash pm-image register-explorer pm-image register-explorer --dry pm-image register-explorer --unregister ``` Registers a **PM-Media** cascading menu on image extensions (including **`.avif`**, **`.arw`**, **`.webp`**, TIFF, etc.), on **folders**, and on the **folder background** (empty area). **Resize** presets (default widths **1980, 1200, 800, 400**): **in place** or **copy** as `${SRC_NAME}_${width}${SRC_EXT}`. **Convert to JPG** writes `${SRC_NAME}_converted.jpg` via `explorer-convert.ps1`. Override widths with `--widths 1920,800`. Under `SystemFileAssociations`, each extension is registered as **`.ext`**, **`.EXT`**, and **`.Ext`** so Explorer picks up the menu regardless of filename casing. Defaults: **`--media-bin`** = this executable; **`--explorer-script`** / **`--explorer-convert-script`** = `\\scripts\\explorer-*.ps1`, or `packages/media/scripts\\…` from a dev `cpp\\dist` build. `pm-media register-explorer` forwards argv to `pm-image.exe` for convenience. Context menus run `powershell.exe -WindowStyle Hidden`; re-run `pm-image register-explorer` after updates so registry command lines refresh. ## Formats — same idea as Sharp / libvips Sharp wraps libvips: **decode → process → encode**. We do the same with `vips_image_new_from_file` and format-specific savers. | | Supported | |---|-----------| | **Resize / geometry** | `fit`, dimensions, crop (`cover` + `position`), letterbox (`contain` + `background`), rotate, flip, flop, EXIF `autorotate` | | **Output (first-class in code)** | **JPEG** (`Q`, strip), **PNG** (`compression`), **WebP** (`Q`, strip), **TIFF** | | **AVIF / HEIC** | Via `format` / file extension and `quality` (libvips HEIF/AVIF saver — needs libheif in your libvips build) | | **Anything else libvips knows** | Fallback: `vips_image_write_to_file` from extension (e.g. **GIF**, **JP2K**, … depending on how libvips was built) | **Input** types match **whatever your libvips build can load** (the Windows `vips-dev-x64-all` bundle includes broad loader support). Set output format with the **`output` path extension** or JSON / CLI **`format`** (`webp`, `avif`, `jpg`, …). ## Sharp-like options (`resize` / JSON) | Sharp concept | `pm-image` / JSON field | Notes | |---------------|---------------------------|--------| | `resize.fit` | `fit` | `inside`, `cover`, `contain`, `fill`, `outside` | | `resize.position` | `position` | `centre`, `attention`, `entropy`, … → libvips *interesting* | | `resize.kernel` | `kernel` | `nearest`, `cubic`, `mitchell`, `lanczos2`, `lanczos3` (default) | | `jpeg|webp|… quality` | `quality` | 1–100 | | `png compression` | `png_compression` | 0–9 | | `withoutEnlargement` | `without_enlargement` | Default **true**; CLI `--allow-enlargement` flips | | EXIF orientation | `autorotate` | Default **true**; CLI `--no-autorotate` | | Strip metadata | `strip_metadata` | Default **true**; CLI `--no-strip` | | `rotate` | `rotate` | 0, 90, 180, 270 (after autorotate) | | `flip` / `flop` | `flip` / `flop` | | | Letterbox | `background` | `#rrggbb` for `contain` | **Windows:** `pm-image resize --ui` opens a **native Win32** dialog to choose input/output paths, max dimensions, fit mode, quality, and enlargement / autorotate / strip options. Other CLI flags seed the dialog; **`--src` / `--dst` are not allowed** with `--ui`. If you cancel, the command exits without processing. **REST** `POST /v1/resize` and **IPC** use the same JSON keys as the table, plus the fields in the **“Batch paths & cache”** section below. --- ## Batch paths & cache: globs, variables, caching Use this when **one** `resize` invocation (CLI, or a single REST/IPC request) should match **many** inputs or build **per-file** output paths. See also [docs/Examples.md](../docs/Examples.md) (TypeScript / Sharp reference). ### Summary | Topic | What it does | |-------|----------------| | **Input glob** | `*` / `?` / `**` in `input` expand to a list of files ([p-ranav/glob](https://github.com/p-ranav/glob)). | | **HTTP(S) URL** | `input` may be `http://` or `https://` — image is fetched with **libcurl** (follows redirects; `--url-timeout` default **5** s; `--url-max-redirects` default **20**). Cache key is URL + options (no local mtime). | | **Omit `output` (CLI)** | If the input resolves to **exactly one** file or URL, you may omit the second positional argument: the file is written to the **current working directory** using a **sanitized basename** (same rules as `sanitizeFilename` in [`packages/acl`](../../acl/src/vfs/path-sanitizer.ts) — illegal/control chars, Windows reserved names, trailing dots/spaces, 255-byte UTF-8 cap). URLs without a path extension default to **`.jpg`**. `--format` overrides the output extension when set. | | **Destination variables** | `output` may contain `${SRC_DIR}`, `${SRC_NAME}`, `${SRC_FILE_EXT}` (or `&{…}`) — expanded **per matched input**. | | **expand_glob** | JSON `false`: treat `input` / `output` as literal paths (no glob expansion). Templates still apply if `output` contains `${SRC_` / `&{SRC_`. | | **Output cache** | SHA-256 key from input path + size + mtime + options; default dir `/cache/images/`. | **Not supported in C++:** Bash **extglob** (e.g. `*.+(jpg)`). Use `*.jpg`, `**/*.jpg`, or separate runs. **Bare** `{SRC_NAME}` without `$` / `&` is not a placeholder — use `${SRC_NAME}` or `&{SRC_NAME}`. ### Input globs - **Syntax:** `*`, `?`, and `**` for recursion. Paths are resolved from the current working directory unless absolute. - **Multiple files → directory output:** `output` must be an **existing** directory, **or** a **new** directory given with a **trailing** `/` or `\` (parent dirs are created). - **Single file** from a literal path or a glob that matches one file: `output` can be a full file path, or a directory (trailing sep) to keep the original filename. **CLI:** use positional `input`/`output` or `--src` / `--dst` together. You can omit `output` only when `input` is a single path or URL (not a multi-match glob). ```bash pm-image resize './photos/**/*.jpg' ./out/ pm-image resize --src './shots/*.png' --dst ./thumbs/ # URL → ./200.jpg under cwd (picsum path segment "200", default extension .jpg) pm-image resize 'https://picsum.photos/200' --max-width 400 --url-timeout 30 ``` **JSON:** same strings in `"input"` and `"output"`. When `expand_glob` is true (default), glob expansion runs when the pattern contains `*`, `?`, or `**`. ### Destination variables (`${SRC_*}` / `&{SRC_*}`) Placeholders are expanded **after** inputs are resolved (glob or single file). Each output path is built from the **absolute** input file for that row. | Placeholder | Meaning | |-------------|---------| | **SRC_DIR** | Parent directory of the current input (generic path, `/` separators). | | **SRC_NAME** | Filename **stem** without extension (`photo` for `photo.JPG`). | | **SRC_FILE_EXT** | Extension **with** leading dot (e.g. `.jpg`), or empty if none. | Use cases: write beside each source (`${SRC_DIR}/out/${SRC_NAME}.webp`), suffix stems (`${SRC_NAME}_thumb.jpg`), or change extension via `format` / path. ```bash pm-image resize --src ./photo.jpg --dst '${SRC_DIR}/${SRC_NAME}_medium.jpg' --max-width 800 pm-image resize --src './shots/*.jpg' --dst '${SRC_DIR}/${SRC_NAME}.webp' --max-width 1920 ``` **REST / IPC** — same strings in JSON (escape quotes in shell as needed): ```bash curl -s -X POST http://127.0.0.1:8080/v1/resize \ -H 'Content-Type: application/json' \ -d '{"input":"/data/in.png","output":"/out/${SRC_NAME}_thumb.webp","max_width":256}' ``` **Responses:** `{"ok":true}` for a single output; if more than one file is produced, `{"ok":true,"count":N,"outputs":["..."]}`. ### JSON reference (batch + cache) | Field | Type | Default | Purpose | |-------|------|---------|---------| | `input` | string | required | Source path or glob. | | `output` | string | required | File path, directory, or template with `${SRC_*}` / `&{SRC_*}`. | | `expand_glob` | bool | `true` | If `false`, no glob expansion; paths are literal. | | `cache` | bool | `true` (or server default from `serve` / `ipc` flags) | Enable/disable cache for this request. | | `cache_dir` | string | empty → `/cache/images` (or server `--cache-dir`) | Root directory for cached blobs. | | `url_timeout_sec` | int | `5` | Total + connect timeout for HTTP(S) fetch (seconds; `0` = libcurl default). | | `url_max_redirects` | int | `20` | Max redirects when fetching URLs. | All **resize** options (`max_width`, `fit`, …) participate in the same JSON body. **REST / IPC** always require `"output"` in JSON (no automatic path). ### Output cache - **Default:** caching is **on**; root dir **`cache/images`** under the process **current working directory** (override with `--cache-dir` or JSON `cache_dir`). - **Key:** **SHA-256** (PicoSHA2) over canonical input path, file size, modification time, and a stable encoding of **all** resize options — change any of these and you get a miss. - **Storage:** `/XX/` (two-letter shard). - **Hit:** copy cached bytes to `output`; **libvips is not initialized** for that job. - **Miss:** run resize, then **best-effort** store into cache (failure to store does not fail the request). **CLI (`resize`):** `--no-cache`, `--cache-dir `. **`serve` / `ipc`:** same flags set **defaults** for requests that omit `cache` / `cache_dir`. Per-request JSON can still set `"cache": true` or `"cache_dir": "/path"` to override. **Batch + cache:** glob batches run **sequentially** (one file after another); each file may hit or miss the cache independently. ### `serve` example with cache and glob ```bash pm-image serve --host 127.0.0.1 -p 8080 --cache-dir /var/cache/pm-image ``` ```bash curl -s -X POST http://127.0.0.1:8080/v1/resize \ -H 'Content-Type: application/json' \ -d '{"input":"/data/in/*.jpg","output":"/data/out/","max_width":400,"cache":true,"cache_dir":"/var/cache/pm-image"}' ``` ## Concurrency - **HTTP `serve`**: cpp-httplib default thread pool (`CPPHTTPLIB_THREAD_POOL_COUNT` — see upstream `httplib.h`). - **libvips**: processing is thread-safe per image; configure process-wide concurrency with `VIPS_CONCURRENCY` (or `vips_concurrency_set` in code later if needed). ## CLI examples Paths below use Unix style; on Windows run `pm-image.exe` and use `.\` or full paths as needed. ### Help and version ```bash pm-image --help pm-image resize --help pm-image -v ``` ### `resize` — fit inside a box (default), write WebP / AVIF by extension ```bash # Max 800×600, stay inside the box, Lanczos3 (default), write JPEG quality 85 (default) pm-image resize photo.jpg out.jpg --max-width 800 --max-height 600 # Same, explicit quality pm-image resize photo.jpg out.jpg --max-width 800 --max-height 600 -q 90 # WebP output (quality applies) pm-image resize photo.jpg thumb.webp --max-width 400 --max-height 400 -q 82 # AVIF output (quality applies; needs HEIF/AVIF support in your libvips build) pm-image resize photo.png out.avif --max-width 1200 --max-height 1200 -q 50 # Force output format when the path has no extension you trust pm-image resize in.tif /tmp/out --format webp --max-width 512 ``` ### `resize` — square images (1:1) Use the **same** `--max-width` and `--max-height` (that value is the square side in pixels). Pick **`--fit`**: | `fit` | Result | |-------|--------| | **`cover`** | Fills the square; crops overflow (default crop: `--position centre`, or `attention` / `entropy` for smart crop). | | **`contain`** | Full image inside the square; **letterboxing** on two sides if needed (`--background`). | | **`fill`** | Stretches to the square (ignores aspect ratio). | ```bash # 512×512 crop-to-square (avatars, thumbnails) pm-image resize portrait.jpg avatar.jpg --fit cover --max-width 512 --max-height 512 # 1080×1080 WebP, smart crop on subject pm-image resize product.png grid.webp --fit cover --max-width 1080 --max-height 1080 --position attention -q 85 # Square canvas, no crop — padded bands with a colour pm-image resize panoramic.jpg square.jpg --fit contain --max-width 800 --max-height 800 --background '#111111' # Exact square by stretching (rare) pm-image resize any.jpg out.jpg --fit fill --max-width 256 --max-height 256 ``` **REST / IPC JSON:** e.g. `"max_width": 512, "max_height": 512, "fit": "cover", "position": "attention"`. ### `resize` — cover (crop), contain (letterbox), rotate / flip ```bash # Cover: fill 1200×630, crop centre (use --position attention for smart crop) pm-image resize wide.jpg social.jpg --fit cover --max-width 1200 --max-height 630 # Contain: fit inside 800×600 canvas, letterbox with a background pm-image resize logo.png padded.png --fit contain --max-width 800 --max-height 600 --background '#1a1a1a' # EXIF autorotate (default), then rotate 90° CCW, vertical flip pm-image resize img.jpg rotated.jpg --max-width 1024 --rotate 90 --flip ``` ### `serve` — HTTP REST ```bash # Default: http://127.0.0.1:8080 — GET /health, POST /v1/resize with JSON body pm-image serve --host 127.0.0.1 -p 8080 ``` Example resize request (paths must be readable/writable by the server process): ```bash curl -s http://127.0.0.1:8080/health curl -s -X POST http://127.0.0.1:8080/v1/resize \ -H 'Content-Type: application/json' \ -d '{"input":"/path/in.png","output":"/path/out.webp","max_width":400,"quality":80}' ``` Optional: `"cache":false`, `"expand_glob":false`, `"cache_dir":"..."` — see **Batch paths & cache** above. ### `ipc` — one JSON line per connection (TCP; Unix socket on Linux/macOS) ```bash pm-image ipc --host 127.0.0.1 -p 9333 --cache-dir ./cache/images # elsewhere: send a single line, read one line back, e.g. # {"input":"/tmp/a.jpg","output":"/tmp/b.webp","max_width":320,"format":"webp","cache":true} ``` Same JSON fields as REST (`input`, `output`, globs, `expand_glob`, `cache`, `cache_dir`, resize options). ### `kbot` — forward to another binary (optional) Requires **`KBOT_EXE`** pointing at the kbot executable; remaining args are passed through. ```bash export KBOT_EXE=/path/to/kbot # Windows: set KBOT_EXE=C:\path\to\kbot.exe pm-image kbot ai --prompt "hello" ``` ## Tests ```bash npm run test:media ``` Requires a built `dist/pm-image` **linked against libvips** and fixture PNGs (`npm run generate:assets` if missing). The suite covers **REST**, **IPC (TCP)**, optional **Unix socket** on non-Windows, **destination templates** (`npm run test:media:templates`), and **recursive glob + `${SRC_DIR}` / `${SRC_NAME}`** (`npm run test:media:glob` — outputs under `tests/assets/glob-in/**/out/`, gitignored, for manual inspection). **HTTP URL smoke tests** (downloads from **picsum.photos**, needs network): ```bash npm run test:media:url ``` ## License See [LICENSE](LICENSE) in this directory when present.