350 lines
17 KiB
Markdown
350 lines
17 KiB
Markdown
# media-img (C++)
|
||
|
||
CMake-based **`media-img`** 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/media-img`** (`.exe` on Windows).
|
||
|
||
### Windows installer (NSIS)
|
||
|
||
From `packages/media/cpp`, after a release build:
|
||
|
||
```bash
|
||
npm run build:installer
|
||
```
|
||
|
||
Produces **`dist/media-img-Setup.exe`**, installs `media-img.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 (`media-img register-explorer`)
|
||
|
||
Implemented in **C++** (Windows registry). The NSIS installer runs `media-img.exe register-explorer` after copying files; uninstall runs `register-explorer --unregister --no-refresh-shell`.
|
||
|
||
```bash
|
||
media-img register-explorer
|
||
media-img register-explorer --dry
|
||
media-img 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`** = `<exe_dir>\\scripts\\explorer-*.ps1`, or `packages/media/scripts\\…` from a dev `cpp\\dist` build.
|
||
|
||
`pm-media register-explorer` forwards argv to `media-img.exe` for convenience.
|
||
|
||
Context menus run `powershell.exe -WindowStyle Hidden`; re-run `media-img 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 | `media-img` / 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` |
|
||
|
||
**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 `<cwd>/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
|
||
./dist/media-img resize './photos/**/*.jpg' ./out/
|
||
./dist/media-img resize --src './shots/*.png' --dst ./thumbs/
|
||
# URL → ./200.jpg under cwd (picsum path segment "200", default extension .jpg)
|
||
./dist/media-img 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
|
||
./dist/media-img resize --src ./photo.jpg --dst '${SRC_DIR}/${SRC_NAME}_medium.jpg' --max-width 800
|
||
./dist/media-img 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 → `<cwd>/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:** `<cache_dir>/XX/<hex>` (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 <path>`.
|
||
|
||
**`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
|
||
./dist/media-img serve --host 127.0.0.1 -p 8080 --cache-dir /var/cache/media-img
|
||
```
|
||
|
||
```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/media-img"}'
|
||
```
|
||
|
||
## 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 `dist\media-img.exe` and use `.\` or full paths as needed.
|
||
|
||
### Help and version
|
||
|
||
```bash
|
||
./dist/media-img --help
|
||
./dist/media-img resize --help
|
||
./dist/media-img -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)
|
||
./dist/media-img resize photo.jpg out.jpg --max-width 800 --max-height 600
|
||
|
||
# Same, explicit quality
|
||
./dist/media-img resize photo.jpg out.jpg --max-width 800 --max-height 600 -q 90
|
||
|
||
# WebP output (quality applies)
|
||
./dist/media-img 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)
|
||
./dist/media-img resize photo.png out.avif --max-width 1200 --max-height 1200 -q 50
|
||
|
||
# Force output format when the path has no extension you trust
|
||
./dist/media-img 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)
|
||
./dist/media-img resize portrait.jpg avatar.jpg --fit cover --max-width 512 --max-height 512
|
||
|
||
# 1080×1080 WebP, smart crop on subject
|
||
./dist/media-img 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
|
||
./dist/media-img resize panoramic.jpg square.jpg --fit contain --max-width 800 --max-height 800 --background '#111111'
|
||
|
||
# Exact square by stretching (rare)
|
||
./dist/media-img 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)
|
||
./dist/media-img resize wide.jpg social.jpg --fit cover --max-width 1200 --max-height 630
|
||
|
||
# Contain: fit inside 800×600 canvas, letterbox with a background
|
||
./dist/media-img resize logo.png padded.png --fit contain --max-width 800 --max-height 600 --background '#1a1a1a'
|
||
|
||
# EXIF autorotate (default), then rotate 90° CCW, vertical flip
|
||
./dist/media-img 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
|
||
./dist/media-img 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
|
||
./dist/media-img 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
|
||
./dist/media-img kbot ai --prompt "hello"
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
npm run test:media
|
||
```
|
||
|
||
Requires a built `dist/media-img` **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.
|