media server 2/3 - vips

This commit is contained in:
lovebird 2026-04-12 23:17:00 +02:00
parent 148b0f6e57
commit d7b70fab8d
12 changed files with 663 additions and 201 deletions

View File

@ -4,3 +4,7 @@ dist/
CMakeUserPresets.json
.vs/
*.user
# Windows libvips SDK (see scripts/fetch-vips-windows.ps1)
third_party/vips-dev-*/
third_party/vips-dev-x64-all*.zip

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20)
project(media-image-service
VERSION 0.1.0
DESCRIPTION "Polymech image resize service (CLI, REST, IPC)"
DESCRIPTION "Polymech image resize service (CLI, REST, IPC) libvips"
LANGUAGES CXX
)
@ -16,6 +16,16 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Windows: official libvips dev tree (see scripts/fetch-vips-windows.ps1) vips-dev-* under third_party/
if(WIN32)
file(GLOB _MEDIA_VIPS_SDK "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vips-dev-*")
if(_MEDIA_VIPS_SDK)
list(APPEND CMAKE_PREFIX_PATH ${_MEDIA_VIPS_SDK})
endif()
endif()
include(FetchContent)
set(JSON_BuildTests OFF CACHE BOOL "" FORCE)
@ -48,13 +58,6 @@ FetchContent_Declare(
GIT_SHALLOW TRUE
)
FetchContent_Declare(
stb
GIT_REPOSITORY https://github.com/nothings/stb.git
GIT_TAG master
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(cli11 asio nlohmann_json cpp_httplib)
# laserpants/dotenv-cpp load .env (same pattern as packages/kbot/cpp).
@ -72,13 +75,7 @@ add_library(laserpants_dotenv INTERFACE)
target_include_directories(laserpants_dotenv INTERFACE ${laserpants_dotenv_SOURCE_DIR}/include)
add_library(laserpants::dotenv ALIAS laserpants_dotenv)
FetchContent_GetProperties(stb)
if(NOT stb_POPULATED)
FetchContent_Populate(stb)
endif()
add_library(stb_headers INTERFACE)
target_include_directories(stb_headers INTERFACE ${stb_SOURCE_DIR})
find_package(Vips REQUIRED)
add_executable(media-img
src/main.cpp
@ -110,9 +107,23 @@ target_link_libraries(media-img PRIVATE
nlohmann_json::nlohmann_json
httplib::httplib
laserpants::dotenv
stb_headers
Vips::vips
)
# GObject (g_object_ref / g_object_unref) not re-exported through libvips import lib on MSVC.
if(WIN32)
file(GLOB _MEDIA_VIPS_SDK_LIST "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vips-dev-*")
if(_MEDIA_VIPS_SDK_LIST)
list(GET _MEDIA_VIPS_SDK_LIST 0 _MEDIA_VIPS_SDK)
if(EXISTS "${_MEDIA_VIPS_SDK}/lib/libgobject-2.0.lib")
target_link_libraries(media-img PRIVATE
"${_MEDIA_VIPS_SDK}/lib/libgobject-2.0.lib"
"${_MEDIA_VIPS_SDK}/lib/libglib-2.0.lib"
)
endif()
endif()
endif()
if(UNIX AND NOT APPLE)
target_link_libraries(media-img PRIVATE pthread)
endif()
@ -120,3 +131,21 @@ endif()
target_compile_definitions(media-img PRIVATE
"MEDIA_IMG_VERSION=\"${PROJECT_VERSION}\""
)
# Runtime: libvips and deps are DLLs next to media-img.exe (Windows dev bundle bin/).
if(WIN32)
file(GLOB _MEDIA_VIPS_SDK_LIST "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vips-dev-*")
if(_MEDIA_VIPS_SDK_LIST)
list(GET _MEDIA_VIPS_SDK_LIST 0 _MEDIA_VIPS_SDK)
if(EXISTS "${_MEDIA_VIPS_SDK}/bin")
add_custom_command(
TARGET media-img
POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_directory
"${_MEDIA_VIPS_SDK}/bin"
"${CMAKE_SOURCE_DIR}/dist"
COMMENT "Copy libvips DLLs to dist/"
)
endif()
endif()
endif()

View File

@ -1,120 +1,97 @@
# media-img (C++)
CMake-based **`media-img`** binary: **CLI resize**, **HTTP REST** (`serve`), and **line-delimited JSON IPC** (`ipc`) over **TCP** (all platforms) or **Unix domain sockets** (non-Windows). Loads optional **`.env`** from the working directory via [laserpants/dotenv-cpp](https://github.com/laserpants/dotenv-cpp) (same idea as kbot).
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)).
## Image stack (not libvips)
Processing uses **stb** ([nothings/stb](https://github.com/nothings/stb)): `stb_image`, **stb_image_resize2** (`stbir_*`), and `stb_image_write`. There is **no** [libvips](https://www.libvips.org/) / glib dependency in this package.
## Concurrency and defaults
| Area | Behavior |
|------|----------|
| **HTTP `serve`** | Uses **cpp-httplib**s default **thread pool**: `CPPHTTPLIB_THREAD_POOL_COUNT` is **`max(8, hardware_concurrency() - 1)`** when hardware concurrency is known (see `httplib.h` in the fetched dependency). There is **no** extra app-level job queue or `MEDIA_*` concurrency cap yet—each accepted request runs resize on the pool. |
| **IPC `ipc`** | **One JSON line per accepted connection**; the handler replies with one line and returns (sequential per socket). For parallel work, open **multiple connections** or run multiple processes. |
| **CLI `resize`** | Single invocation, single file pair. |
**CLI defaults** (see `src/main.cpp`):
- `serve`: `--host 127.0.0.1`, `--port 8080`
- `ipc`: `--host 127.0.0.1`, `--port 9333`, or `--unix <path>` (Unix only)
**REST**
- `GET /health``{"ok":true,"service":"media-img"}`
- `POST /v1/resize` — JSON body: `input`, `output` (paths), optional `max_width`, `max_height`, `format` (`png` / `jpg` / `jpeg`)
**IPC** — one line per request, newline-terminated JSON:
- Success: `{"ok":true}`
- Failure: `{"ok":false,"error":"..."}`
- **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 (MSVC, GCC, Clang) |
| Git | For `FetchContent` dependencies |
| Node.js | Optional; for integration tests (`npm run test:media`) — **Node 18+** recommended (`fetch`, `AbortSignal.timeout`) |
| C++ compiler | C++17 |
| **libvips** | **Required** — pkg-config (Unix) or `third_party/vips-dev-*` / `VIPS_ROOT` (Windows) |
| Git | For `FetchContent` (CLI11, Asio, httplib, json, dotenv) |
| Node.js | Optional — `npm run test:media` (Node 18+) |
## Quick start (build)
### Installing libvips
From `packages/media/cpp`:
**Debian / Ubuntu**
```bash
npm install # optional if you only use cmake
npm run build:release
sudo apt install libvips-dev pkg-config
```
Artifacts: **`dist/media-img`** (or **`dist/media-img.exe`** on Windows).
**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
```
### Presets
Binary: **`dist/media-img`** (`.exe` on Windows).
| Preset | Role |
|--------|------|
| `dev` | Debug build |
| `release` | Release build |
## 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` | 1100 |
| `png compression` | `png_compression` | 09 |
| `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` accepts the same keys as columns in the table (plus `input`, `output` paths).
**IPC** sends one JSON object per line with the same keys.
## 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 overview
```bash
media-img --help
media-img -v,--version
media-img resize --help
media-img serve --help
media-img ipc --help
```
| Subcommand | Description |
|------------|-------------|
| `resize <input> <output>` | Resize file on disk (`--max-width`, `--max-height`, `--format`) |
| `serve` | HTTP server (`--host`, `-p/--port`) |
| `ipc` | JSON-line IPC: TCP (`--host`, `--port`) or **`--unix`** (Unix only) |
| `kbot ...` | Forwards extra arguments to **`KBOT_EXE`** (optional AI / kbot workflows; build kbot separately) |
`kbot` subcommand forwards to **`KBOT_EXE`** (optional).
Example:
## Tests
```bash
set KBOT_EXE=C:\path\to\kbot.exe
media-img kbot ai --prompt "hello"
```
## Integration tests (REST + IPC)
Tests mirror the style of **kbot** orchestrators (e.g. spawn binary, wait for listen, talk over TCP): see **`orchestrator/test-media.mjs`**. They cover:
- **REST**: `GET /health`, `POST /v1/resize` (PNG + JPEG output), error path for missing input
- **IPC TCP**: line JSON request/response, error path
- **IPC Unix**: same protocol on a **Unix socket** (skipped on Windows — use TCP there)
```bash
npm run build:release
npm run generate:assets # if tests/assets PNGs are missing
npm run test:media
```
| Script | Purpose |
|--------|---------|
| `npm run test:media` | REST + IPC (TCP + Unix where supported) |
| `npm run test:media:rest` | `--rest-only` |
| `npm run test:media:ipc` | `--ipc-only` |
Env: **`MEDIA_IMG_TEST_UNIX`** — Unix socket path for the UDS test (default `/tmp/media-img-test.sock`).
## Test fixtures
Under **`tests/assets/`**:
- **`build-fixtures.mjs`** — Generates RGB PNGs (no extra npm deps). Run: `node tests/assets/build-fixtures.mjs` (also exposed as **`npm run generate:assets`**).
- Additional subfolders (`in/`, `out/`, `*_webp/`, watermark samples, etc.) are available for broader manual or future tests.
## Related docs
- **`ROLLOUT.md`** — Phased rollout notes.
- **`polymech.md`** — Broader Polymech context (if present).
Requires a built `dist/media-img` **linked against libvips** and fixture PNGs (`npm run generate:assets` if missing).
## License

View File

@ -0,0 +1,60 @@
#[=======================================================================[
FindVips libvips (Sharp uses the same library).
Linux / macOS: `pkg-config vips` after installing:
Debian/Ubuntu: sudo apt install libvips-dev
macOS: brew install vips
Windows: vcpkg install vips:x64-windows, then use the vcpkg toolchain file or set
CMAKE_PREFIX_PATH to the installed prefix; alternatively set VIPS_ROOT.
#]=======================================================================]
include(FindPackageHandleStandardArgs)
if(DEFINED ENV{VIPS_ROOT} AND NOT VIPS_ROOT)
set(VIPS_ROOT "$ENV{VIPS_ROOT}")
endif()
if(VIPS_ROOT)
list(APPEND CMAKE_PREFIX_PATH "${VIPS_ROOT}")
endif()
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(VIPS IMPORTED_TARGET vips)
endif()
if(TARGET PkgConfig::VIPS)
if(NOT TARGET Vips::vips)
add_library(Vips::vips ALIAS PkgConfig::VIPS)
endif()
set(Vips_FOUND TRUE)
else()
find_path(
Vips_INCLUDE_DIR
NAMES vips/vips.h
PATHS "${CMAKE_PREFIX_PATH}"
PATH_SUFFIXES include
)
find_library(
Vips_LIBRARY
NAMES vips libvips vips-8.0
PATHS "${CMAKE_PREFIX_PATH}"
PATH_SUFFIXES lib lib64
)
find_package_handle_standard_args(Vips DEFAULT_MSG Vips_LIBRARY Vips_INCLUDE_DIR)
if(Vips_FOUND)
add_library(Vips::vips UNKNOWN IMPORTED)
get_filename_component(Vips_LIBRARY_DIR "${Vips_LIBRARY}" DIRECTORY)
# vips.h includes glib.h Windows SDK bundles glib headers next to vips.
set(_VIPS_INCLUDES
"${Vips_INCLUDE_DIR}"
"${Vips_INCLUDE_DIR}/glib-2.0"
"${Vips_LIBRARY_DIR}/glib-2.0/include")
set_target_properties(
Vips::vips
PROPERTIES
IMPORTED_LOCATION "${Vips_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${_VIPS_INCLUDES}"
)
endif()
endif()

View File

@ -14,6 +14,7 @@
"clean": "cmake -E rm -rf build dist",
"rebuild": "npm run clean && npm run build",
"generate:assets": "node tests/assets/build-fixtures.mjs",
"setup:vips": "powershell -ExecutionPolicy Bypass -File scripts/fetch-vips-windows.ps1",
"test:media": "node orchestrator/test-media.mjs",
"test:media:rest": "node orchestrator/test-media.mjs --rest-only",
"test:media:ipc": "node orchestrator/test-media.mjs --ipc-only",

View File

@ -0,0 +1,17 @@
# Download official libvips MSVC dev bundle (x64, "all" loaders) into third_party/.
# https://github.com/libvips/build-win64-mxe/releases
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$third = Join-Path $root 'third_party'
New-Item -ItemType Directory -Force -Path $third | Out-Null
$version = if ($env:MEDIA_VIPS_VERSION) { $env:MEDIA_VIPS_VERSION } else { '8.18.2' }
$name = "vips-dev-x64-all-$version.zip"
$url = "https://github.com/libvips/build-win64-mxe/releases/download/v$version/$name"
$zip = Join-Path $third $name
Write-Host "Downloading $url ..."
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
Write-Host "Extracting to $third ..."
Expand-Archive -Path $zip -DestinationPath $third -Force
Write-Host "Done. CMake will pick up third_party/vips-dev-* automatically."

View File

@ -1,95 +1,404 @@
#include "resize.hpp"
#include <vips/vips.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <vector>
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "stb_image.h"
#include "stb_image_resize2.h"
#include "stb_image_write.h"
#include <mutex>
#include <string>
namespace media {
static void compute_target_size(int iw, int ih, const ResizeOptions& opt, int* ow, int* oh) {
*ow = iw;
*oh = ih;
if (opt.max_width <= 0 && opt.max_height <= 0)
return;
double scale = 1.0;
if (opt.max_width > 0 && iw > opt.max_width)
scale = std::min(scale, static_cast<double>(opt.max_width) / static_cast<double>(iw));
if (opt.max_height > 0 && ih > opt.max_height)
scale = std::min(scale, static_cast<double>(opt.max_height) / static_cast<double>(ih));
if (scale >= 1.0)
return;
*ow = std::max(1, static_cast<int>(iw * scale));
*oh = std::max(1, static_cast<int>(ih * scale));
namespace {
std::once_flag g_vips_init;
void ensure_vips() {
std::call_once(g_vips_init, []() {
if (vips_init("media-img"))
std::abort();
});
}
bool resize_file(const std::string& input_path, const std::string& output_path,
const ResizeOptions& opt, std::string& err_out) {
int iw = 0, ih = 0, channels = 0;
unsigned char* data =
stbi_load(input_path.c_str(), &iw, &ih, &channels, 0);
if (!data) {
err_out = std::string("stbi_load failed: ") + stbi_failure_reason();
return false;
}
std::string vips_err() {
const char *buf = vips_error_buffer();
std::string s = buf ? buf : "vips error";
vips_error_clear();
return s;
}
int ow = iw, oh = ih;
compute_target_size(iw, ih, opt, &ow, &oh);
const int out_channels = channels;
std::vector<unsigned char> out(static_cast<size_t>(ow) * static_cast<size_t>(oh) * static_cast<size_t>(out_channels));
stbir_pixel_layout layout = STBIR_RGBA;
if (channels == 1)
layout = STBIR_1CHANNEL;
else if (channels == 2)
layout = STBIR_2CHANNEL;
else if (channels == 3)
layout = STBIR_RGB;
else if (channels == 4)
layout = STBIR_RGBA;
if (!stbir_resize_uint8_srgb(data, iw, ih, 0, out.data(), ow, oh, 0, layout)) {
stbi_image_free(data);
err_out = "stbir_resize_uint8_srgb failed";
return false;
}
stbi_image_free(data);
std::string fmt = opt.format;
if (fmt.empty()) {
auto dot = output_path.rfind('.');
if (dot != std::string::npos)
fmt = output_path.substr(dot + 1);
}
for (auto& c : fmt) {
void to_lower(std::string &s) {
for (char &c : s) {
if (c >= 'A' && c <= 'Z')
c = static_cast<char>(c - 'A' + 'a');
}
}
int ok = 0;
if (fmt == "jpg" || fmt == "jpeg") {
ok = stbi_write_jpg(output_path.c_str(), ow, oh, out_channels, out.data(), 90);
} else {
if (fmt != "png" && !fmt.empty()) {
err_out = "warning: unsupported format '" + fmt + "', writing PNG";
VipsKernel parse_kernel(const std::string &k) {
std::string x = k;
to_lower(x);
if (x == "nearest")
return VIPS_KERNEL_NEAREST;
if (x == "cubic")
return VIPS_KERNEL_CUBIC;
if (x == "mitchell")
return VIPS_KERNEL_MITCHELL;
if (x == "lanczos2")
return VIPS_KERNEL_LANCZOS2;
return VIPS_KERNEL_LANCZOS3;
}
VipsInteresting parse_interesting(const std::string &p) {
std::string x = p;
to_lower(x);
if (x == "attention" || x == "entropy")
return VIPS_INTERESTING_ATTENTION;
if (x == "low" || x == "left" || x == "top")
return VIPS_INTERESTING_LOW;
if (x == "high" || x == "right" || x == "bottom")
return VIPS_INTERESTING_HIGH;
return VIPS_INTERESTING_CENTRE;
}
bool parse_rgb_background(const std::string &bg, double out[3]) {
if (bg.empty() || bg[0] != '#' || bg.size() < 4)
return false;
try {
if (bg.size() == 7) {
out[0] = static_cast<double>(std::stoi(bg.substr(1, 2), nullptr, 16));
out[1] = static_cast<double>(std::stoi(bg.substr(3, 2), nullptr, 16));
out[2] = static_cast<double>(std::stoi(bg.substr(5, 2), nullptr, 16));
return true;
}
ok = stbi_write_png(output_path.c_str(), ow, oh, out_channels, out.data(), ow * out_channels);
if (bg.size() == 4) {
out[0] = static_cast<double>(std::stoi(bg.substr(1, 1), nullptr, 16) * 17);
out[1] = static_cast<double>(std::stoi(bg.substr(2, 1), nullptr, 16) * 17);
out[2] = static_cast<double>(std::stoi(bg.substr(3, 1), nullptr, 16) * 17);
return true;
}
} catch (...) {
}
return false;
}
std::string output_format(const std::string &path, const ResizeOptions &opt) {
std::string fmt = opt.format;
if (fmt.empty()) {
const auto dot = path.rfind('.');
if (dot != std::string::npos)
fmt = path.substr(dot + 1);
}
to_lower(fmt);
return fmt;
}
bool save_image(VipsImage *in, const std::string &path, const ResizeOptions &opt, std::string &err_out) {
const std::string fmt = output_format(path, opt);
const gboolean strip = opt.strip_metadata ? TRUE : FALSE;
if (fmt == "jpg" || fmt == "jpeg") {
if (vips_jpegsave(in, path.c_str(), "Q", opt.quality, "optimize_coding", TRUE, "strip", strip, NULL))
goto fail;
return true;
}
if (fmt == "png") {
if (vips_pngsave(in, path.c_str(), "compression", opt.png_compression, NULL))
goto fail;
return true;
}
if (fmt == "webp") {
if (vips_webpsave(in, path.c_str(), "Q", opt.quality, "strip", strip, NULL))
goto fail;
return true;
}
if (fmt == "tif" || fmt == "tiff") {
if (vips_tiffsave(in, path.c_str(), NULL))
goto fail;
return true;
}
if (fmt == "avif" || fmt == "heic" || fmt == "heif") {
if (vips_image_write_to_file(in, path.c_str(), "Q", opt.quality, NULL))
goto fail;
return true;
}
if (!ok) {
err_out = "stbi_write failed";
if (vips_image_write_to_file(in, path.c_str(), NULL))
goto fail;
return true;
fail:
err_out = vips_err();
return false;
}
bool apply_user_rotate(VipsImage *in, VipsImage **out, int deg, std::string &err_out) {
deg = ((deg % 360) + 360) % 360;
if (deg == 0) {
*out = in;
g_object_ref(in);
return true;
}
VipsAngle a = VIPS_ANGLE_D0;
if (deg == 90)
a = VIPS_ANGLE_D90;
else if (deg == 180)
a = VIPS_ANGLE_D180;
else if (deg == 270)
a = VIPS_ANGLE_D270;
else {
err_out = "rotate must be 0, 90, 180, or 270";
return false;
}
if (vips_rot(in, out, a, NULL)) {
err_out = vips_err();
return false;
}
return true;
}
} // namespace
void apply_resize_options_from_json(const nlohmann::json &j, ResizeOptions &opt) {
auto num = [&](const char *key, int &dest) {
if (!j.contains(key) || j[key].is_null())
return;
if (j[key].is_number_integer())
dest = j[key].get<int>();
};
auto num_or_bool = [&](const char *key, bool &dest) {
if (!j.contains(key) || j[key].is_null())
return;
if (j[key].is_boolean())
dest = j[key].get<bool>();
else if (j[key].is_number_integer())
dest = j[key].get<int>() != 0;
};
auto str = [&](const char *key, std::string &dest) {
if (!j.contains(key) || !j[key].is_string())
return;
dest = j[key].get<std::string>();
};
num("max_width", opt.max_width);
num("max_height", opt.max_height);
str("format", opt.format);
str("fit", opt.fit);
str("position", opt.position);
str("kernel", opt.kernel);
num("quality", opt.quality);
num("png_compression", opt.png_compression);
num_or_bool("without_enlargement", opt.without_enlargement);
num_or_bool("autorotate", opt.autorotate);
num_or_bool("strip_metadata", opt.strip_metadata);
num("rotate", opt.rotate);
num_or_bool("flip", opt.flip);
num_or_bool("flop", opt.flop);
str("background", opt.background);
}
bool resize_file(const std::string &input_path, const std::string &output_path, const ResizeOptions &opt_in,
std::string &err_out) {
ensure_vips();
ResizeOptions opt = opt_in;
to_lower(opt.fit);
to_lower(opt.kernel);
to_lower(opt.position);
VipsImage *base = vips_image_new_from_file(input_path.c_str(), "access", VIPS_ACCESS_SEQUENTIAL, NULL);
if (!base) {
err_out = vips_err();
return false;
}
VipsImage *cur = base;
VipsImage *next = nullptr;
if (opt.autorotate) {
if (vips_autorot(cur, &next, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
}
if (opt.rotate != 0) {
if (!apply_user_rotate(cur, &next, opt.rotate, err_out)) {
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
}
if (opt.flip) {
if (vips_flip(cur, &next, VIPS_DIRECTION_VERTICAL, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
}
if (opt.flop) {
if (vips_flip(cur, &next, VIPS_DIRECTION_HORIZONTAL, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
}
const int iw = vips_image_get_width(cur);
const int ih = vips_image_get_height(cur);
const int W = opt.max_width;
const int H = opt.max_height;
const VipsKernel vk = parse_kernel(opt.kernel);
const VipsInteresting vi = parse_interesting(opt.position);
const bool want_resize = (W > 0 || H > 0) || opt.fit == "fill";
if (!want_resize) {
if (!save_image(cur, output_path, opt, err_out)) {
g_object_unref(cur);
return false;
}
g_object_unref(cur);
return true;
}
if (opt.fit == "fill") {
if (W <= 0 || H <= 0) {
err_out = "fill fit requires both max_width and max_height";
g_object_unref(cur);
return false;
}
const double sx = static_cast<double>(W) / static_cast<double>(iw);
const double sy = static_cast<double>(H) / static_cast<double>(ih);
if (vips_resize(cur, &next, sx, "vscale", sy, "kernel", vk, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
} else if (opt.fit == "inside" || opt.fit == "contain") {
if (W <= 0 && H <= 0) {
err_out = "inside/contain requires max_width and/or max_height";
g_object_unref(cur);
return false;
}
double scale = 1.0;
if (W > 0 && H > 0) {
scale = std::min(static_cast<double>(W) / static_cast<double>(iw),
static_cast<double>(H) / static_cast<double>(ih));
} else if (W > 0) {
scale = static_cast<double>(W) / static_cast<double>(iw);
} else {
scale = static_cast<double>(H) / static_cast<double>(ih);
}
if (opt.without_enlargement)
scale = std::min(scale, 1.0);
if (scale <= 0.0) {
err_out = "invalid geometry";
g_object_unref(cur);
return false;
}
if (vips_resize(cur, &next, scale, "kernel", vk, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
if (opt.fit == "contain" && W > 0 && H > 0) {
const int cw = vips_image_get_width(cur);
const int ch = vips_image_get_height(cur);
if (cw < W || ch < H) {
double bg[3] = {255.0, 255.0, 255.0};
if (!parse_rgb_background(opt.background, bg))
parse_rgb_background("#ffffff", bg);
const int left = (W - cw) / 2;
const int top = (H - ch) / 2;
VipsArrayDouble *bg_a = vips_array_double_newv(3, bg[0], bg[1], bg[2]);
const int e = vips_embed(cur, &next, left, top, W, H, "extend", VIPS_EXTEND_BACKGROUND,
"background", bg_a, NULL);
vips_area_unref(VIPS_AREA(bg_a));
if (e) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
}
}
} else if (opt.fit == "cover") {
if (W <= 0 || H <= 0) {
err_out = "cover fit requires max_width and max_height";
g_object_unref(cur);
return false;
}
const double sx = static_cast<double>(W) / static_cast<double>(iw);
const double sy = static_cast<double>(H) / static_cast<double>(ih);
const double sc = std::max(sx, sy);
if (vips_resize(cur, &next, sc, "kernel", vk, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
const int rw = vips_image_get_width(cur);
const int rh = vips_image_get_height(cur);
const int left = std::max(0, (rw - W) / 2);
const int top = std::max(0, (rh - H) / 2);
if (vi == VIPS_INTERESTING_ATTENTION) {
if (vips_smartcrop(cur, &next, W, H, "interesting", VIPS_INTERESTING_ATTENTION, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
} else {
if (vips_extract_area(cur, &next, left, top, W, H, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
}
g_object_unref(cur);
cur = next;
} else if (opt.fit == "outside") {
if (W <= 0 || H <= 0) {
err_out = "outside fit requires max_width and max_height";
g_object_unref(cur);
return false;
}
const double sx = static_cast<double>(W) / static_cast<double>(iw);
const double sy = static_cast<double>(H) / static_cast<double>(ih);
double sc = std::max(sx, sy);
if (opt.without_enlargement)
sc = std::min(sc, 1.0);
if (vips_resize(cur, &next, sc, "kernel", vk, NULL)) {
err_out = vips_err();
g_object_unref(cur);
return false;
}
g_object_unref(cur);
cur = next;
} else {
err_out = std::string("unknown fit: ") + opt.fit;
g_object_unref(cur);
return false;
}
if (!save_image(cur, output_path, opt, err_out)) {
g_object_unref(cur);
return false;
}
g_object_unref(cur);
return true;
}
} // namespace media

View File

@ -2,17 +2,58 @@
#include <string>
#include <nlohmann/json.hpp>
namespace media {
/**
* Sharp-like processing options (libvips backend).
* @see https://sharp.pixelplumbing.com/api-resize
* @see https://www.libvips.org/API/current/class.Image.html
*/
struct ResizeOptions {
int max_width = 0; // 0 = no limit (respect max_height)
int max_height = 0; // 0 = no limit
/** "png", "jpg", "jpeg" — if empty, infer from output path extension */
int max_width = 0; // 0 = unconstrained (use with max_height)
int max_height = 0; // 0 = unconstrained
/** Output container: png, jpg, jpeg, webp, tif, tiff, avif, heic, … — empty = infer from output path */
std::string format;
/**
* How the image should fit the target box (max_width × max_height when both set).
* inside default; not larger than box (Sharp inside / contain bounds).
* cover fill box, crop overflow (centre or `position`).
* contain fit inside box; letterbox to exact WxH using `background` when both dimensions set.
* fill stretch to exact WxH (ignores aspect).
* outside at least as large as the box on both axes (no crop); may exceed WxH.
*/
std::string fit = "inside";
/** For `cover`: centre, attention, entropy, low, high — maps to libvips “interesting”. */
std::string position = "centre";
/** nearest, cubic, mitchell, lanczos2, lanczos3 (default, closest to Sharp). */
std::string kernel = "lanczos3";
int quality = 85; /**< JPEG / WebP / AVIF-style quality 1100 */
int png_compression = 6; /**< PNG DEFLATE 09 */
bool without_enlargement = true;
bool autorotate = true; /**< Apply EXIF orientation (like Sharp default). */
bool strip_metadata = true; /**< Strip EXIF etc. on save where supported */
/** Degrees: 0, 90, 180, 270 — applied after autorotate. */
int rotate = 0;
bool flip = false; /**< Vertical flip */
bool flop = false; /**< Horizontal flip */
/** Letterbox / embed: `#rrggbb` or `#rgb` */
std::string background = "#ffffff";
};
/** Resize image on disk. At least one of max_width / max_height should be > 0. */
bool resize_file(const std::string& input_path, const std::string& output_path,
const ResizeOptions& opt, std::string& err_out);
bool resize_file(const std::string& input_path, const std::string& output_path, const ResizeOptions& opt,
std::string& err_out);
/** Merge JSON keys into `opt` (REST / IPC). Unknown keys ignored. */
void apply_resize_options_from_json(const nlohmann::json& j, ResizeOptions& opt);
} // namespace media

View File

@ -35,12 +35,7 @@ int run_server(const std::string& host, int port) {
const std::string in = body["input"].get<std::string>();
const std::string out = body["output"].get<std::string>();
media::ResizeOptions opt;
if (body.contains("max_width"))
opt.max_width = body["max_width"].get<int>();
if (body.contains("max_height"))
opt.max_height = body["max_height"].get<int>();
if (body.contains("format") && body["format"].is_string())
opt.format = body["format"].get<std::string>();
media::apply_resize_options_from_json(body, opt);
std::string err;
if (!media::resize_file(in, out, opt, err)) {

View File

@ -32,12 +32,7 @@ static int handle_session(asio::ip::tcp::socket sock) {
return 0;
}
media::ResizeOptions opt;
if (j.contains("max_width"))
opt.max_width = j["max_width"].get<int>();
if (j.contains("max_height"))
opt.max_height = j["max_height"].get<int>();
if (j.contains("format") && j["format"].is_string())
opt.format = j["format"].get<std::string>();
media::apply_resize_options_from_json(j, opt);
std::string err;
bool ok = media::resize_file(j["input"].get<std::string>(), j["output"].get<std::string>(), opt, err);
nlohmann::json out = ok ? nlohmann::json{{"ok", true}} : nlohmann::json{{"ok", false}, {"error", err}};
@ -88,12 +83,7 @@ int run_unix_server(const std::string& path) {
continue;
}
media::ResizeOptions opt;
if (j.contains("max_width"))
opt.max_width = j["max_width"].get<int>();
if (j.contains("max_height"))
opt.max_height = j["max_height"].get<int>();
if (j.contains("format") && j["format"].is_string())
opt.format = j["format"].get<std::string>();
media::apply_resize_options_from_json(j, opt);
std::string err;
bool ok = media::resize_file(j["input"].get<std::string>(), j["output"].get<std::string>(), opt, err);
nlohmann::json out = ok ? nlohmann::json{{"ok", true}} : nlohmann::json{{"ok", false}, {"error", err}};

View File

@ -62,13 +62,40 @@ int main(int argc, char **argv) {
int max_w = 0;
int max_h = 0;
std::string format;
std::string fit = "inside";
std::string position = "centre";
std::string kernel = "lanczos3";
std::string background = "#ffffff";
int quality = 85;
int png_compression = 6;
int rotate = 0;
bool flip = false;
bool flop = false;
bool no_autorotate = false;
bool no_strip = false;
bool allow_enlargement = false;
auto *resize_cmd = app.add_subcommand("resize", "Resize an image file");
auto *resize_cmd = app.add_subcommand("resize", "Resize / transform an image (libvips, Sharp-like options)");
resize_cmd->add_option("input", in_path, "Input image path")->required();
resize_cmd->add_option("output", out_path, "Output image path")->required();
resize_cmd->add_option("--max-width", max_w, "Max width (0 = no limit)");
resize_cmd->add_option("--max-height", max_h, "Max height (0 = no limit)");
resize_cmd->add_option("--format", format, "Output format: png, jpg, jpeg (default: from extension)");
resize_cmd->add_option("--max-width", max_w, "Target / max width (0 = no limit)");
resize_cmd->add_option("--max-height", max_h, "Target / max height (0 = no limit)");
resize_cmd->add_option("--format", format, "Output format (default: from extension)");
resize_cmd
->add_option("--fit", fit,
"inside|cover|contain|fill|outside — see Sharp resize.fit")
->default_val("inside");
resize_cmd->add_option("--position", position, "For cover: centre|attention|entropy|…")->default_val("centre");
resize_cmd->add_option("--kernel", kernel, "nearest|cubic|mitchell|lanczos2|lanczos3")->default_val("lanczos3");
resize_cmd->add_option("-q,--quality", quality, "JPEG/WebP/AVIF quality 1100")->default_val(85);
resize_cmd->add_option("--png-compression", png_compression, "PNG DEFLATE 09")->default_val(6);
resize_cmd->add_option("--background", background, "Letterbox colour #rrggbb (contain)");
resize_cmd->add_option("--rotate", rotate, "Rotate 0|90|180|270 after EXIF autorotate")->default_val(0);
resize_cmd->add_flag("--flip", flip, "Vertical flip");
resize_cmd->add_flag("--flop", flop, "Horizontal flop");
resize_cmd->add_flag("--no-autorotate", no_autorotate, "Disable EXIF orientation");
resize_cmd->add_flag("--no-strip", no_strip, "Keep metadata on output");
resize_cmd->add_flag("--allow-enlargement", allow_enlargement, "Allow upscaling (inside/contain/outside)");
std::string host = "127.0.0.1";
int port = 8080;
@ -94,6 +121,18 @@ int main(int argc, char **argv) {
opt.max_width = max_w;
opt.max_height = max_h;
opt.format = format;
opt.fit = fit;
opt.position = position;
opt.kernel = kernel;
opt.background = background;
opt.quality = quality;
opt.png_compression = png_compression;
opt.rotate = rotate;
opt.flip = flip;
opt.flop = flop;
opt.autorotate = !no_autorotate;
opt.strip_metadata = !no_strip;
opt.without_enlargement = !allow_enlargement;
std::string err;
if (!media::resize_file(in_path, out_path, opt, err)) {
std::cerr << err << "\n";

View File

@ -23,7 +23,7 @@ node tests/assets/build-fixtures.mjs
| `stripes-512x64.png` | 512×64 | Horizontal bands |
| `checker-128x128.png` | 128×128 | Checkerboard (used by IPC Unix test on Linux/macOS) |
All are **RGB 8-bit** PNG (no alpha). **`media-img` uses STB**, not libvips.
All are **RGB 8-bit** PNG (no alpha). **`media-img` uses libvips** for processing (same stack as Sharp).
## Other folders