From d7b70fab8dafecae7194928f8dbbeceb061d56e9 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 12 Apr 2026 23:17:00 +0200 Subject: [PATCH] media server 2/3 - vips --- packages/media/cpp/.gitignore | 4 + packages/media/cpp/CMakeLists.txt | 61 ++- packages/media/cpp/README.md | 143 +++--- packages/media/cpp/cmake/FindVips.cmake | 60 +++ packages/media/cpp/package.json | 1 + .../media/cpp/scripts/fetch-vips-windows.ps1 | 17 + packages/media/cpp/src/core/resize.cpp | 455 +++++++++++++++--- packages/media/cpp/src/core/resize.hpp | 53 +- packages/media/cpp/src/http/serve.cpp | 7 +- packages/media/cpp/src/ipc/ipc_serve.cpp | 14 +- packages/media/cpp/src/main.cpp | 47 +- packages/media/cpp/tests/assets/README.md | 2 +- 12 files changed, 663 insertions(+), 201 deletions(-) create mode 100644 packages/media/cpp/cmake/FindVips.cmake create mode 100644 packages/media/cpp/scripts/fetch-vips-windows.ps1 diff --git a/packages/media/cpp/.gitignore b/packages/media/cpp/.gitignore index 7100d6e4..4088c43d 100644 --- a/packages/media/cpp/.gitignore +++ b/packages/media/cpp/.gitignore @@ -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 diff --git a/packages/media/cpp/CMakeLists.txt b/packages/media/cpp/CMakeLists.txt index 9d4d2f68..e053ece5 100644 --- a/packages/media/cpp/CMakeLists.txt +++ b/packages/media/cpp/CMakeLists.txt @@ -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() diff --git a/packages/media/cpp/README.md b/packages/media/cpp/README.md index 82b12c56..0b5daa15 100644 --- a/packages/media/cpp/README.md +++ b/packages/media/cpp/README.md @@ -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 ` (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` | 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` 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 ` | 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 diff --git a/packages/media/cpp/cmake/FindVips.cmake b/packages/media/cpp/cmake/FindVips.cmake new file mode 100644 index 00000000..5518d5c1 --- /dev/null +++ b/packages/media/cpp/cmake/FindVips.cmake @@ -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() diff --git a/packages/media/cpp/package.json b/packages/media/cpp/package.json index 033f2aee..bc99c63f 100644 --- a/packages/media/cpp/package.json +++ b/packages/media/cpp/package.json @@ -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", diff --git a/packages/media/cpp/scripts/fetch-vips-windows.ps1 b/packages/media/cpp/scripts/fetch-vips-windows.ps1 new file mode 100644 index 00000000..d41e981b --- /dev/null +++ b/packages/media/cpp/scripts/fetch-vips-windows.ps1 @@ -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." diff --git a/packages/media/cpp/src/core/resize.cpp b/packages/media/cpp/src/core/resize.cpp index 5fcc0f7d..2681b9e6 100644 --- a/packages/media/cpp/src/core/resize.cpp +++ b/packages/media/cpp/src/core/resize.cpp @@ -1,95 +1,404 @@ #include "resize.hpp" +#include + #include -#include -#include -#include - -#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 +#include 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(opt.max_width) / static_cast(iw)); - if (opt.max_height > 0 && ih > opt.max_height) - scale = std::min(scale, static_cast(opt.max_height) / static_cast(ih)); - if (scale >= 1.0) - return; - *ow = std::max(1, static_cast(iw * scale)); - *oh = std::max(1, static_cast(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 out(static_cast(ow) * static_cast(oh) * static_cast(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(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(std::stoi(bg.substr(1, 2), nullptr, 16)); + out[1] = static_cast(std::stoi(bg.substr(3, 2), nullptr, 16)); + out[2] = static_cast(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(std::stoi(bg.substr(1, 1), nullptr, 16) * 17); + out[1] = static_cast(std::stoi(bg.substr(2, 1), nullptr, 16) * 17); + out[2] = static_cast(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(); + }; + 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(); + else if (j[key].is_number_integer()) + dest = j[key].get() != 0; + }; + auto str = [&](const char *key, std::string &dest) { + if (!j.contains(key) || !j[key].is_string()) + return; + dest = j[key].get(); + }; + + 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(W) / static_cast(iw); + const double sy = static_cast(H) / static_cast(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(W) / static_cast(iw), + static_cast(H) / static_cast(ih)); + } else if (W > 0) { + scale = static_cast(W) / static_cast(iw); + } else { + scale = static_cast(H) / static_cast(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(W) / static_cast(iw); + const double sy = static_cast(H) / static_cast(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(W) / static_cast(iw); + const double sy = static_cast(H) / static_cast(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 diff --git a/packages/media/cpp/src/core/resize.hpp b/packages/media/cpp/src/core/resize.hpp index 5496dcaa..d0352bf2 100644 --- a/packages/media/cpp/src/core/resize.hpp +++ b/packages/media/cpp/src/core/resize.hpp @@ -2,17 +2,58 @@ #include +#include + 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 1–100 */ + int png_compression = 6; /**< PNG DEFLATE 0–9 */ + + 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 diff --git a/packages/media/cpp/src/http/serve.cpp b/packages/media/cpp/src/http/serve.cpp index b33f1801..a29ec683 100644 --- a/packages/media/cpp/src/http/serve.cpp +++ b/packages/media/cpp/src/http/serve.cpp @@ -35,12 +35,7 @@ int run_server(const std::string& host, int port) { const std::string in = body["input"].get(); const std::string out = body["output"].get(); media::ResizeOptions opt; - if (body.contains("max_width")) - opt.max_width = body["max_width"].get(); - if (body.contains("max_height")) - opt.max_height = body["max_height"].get(); - if (body.contains("format") && body["format"].is_string()) - opt.format = body["format"].get(); + media::apply_resize_options_from_json(body, opt); std::string err; if (!media::resize_file(in, out, opt, err)) { diff --git a/packages/media/cpp/src/ipc/ipc_serve.cpp b/packages/media/cpp/src/ipc/ipc_serve.cpp index 50e81f2c..220ef953 100644 --- a/packages/media/cpp/src/ipc/ipc_serve.cpp +++ b/packages/media/cpp/src/ipc/ipc_serve.cpp @@ -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(); - if (j.contains("max_height")) - opt.max_height = j["max_height"].get(); - if (j.contains("format") && j["format"].is_string()) - opt.format = j["format"].get(); + media::apply_resize_options_from_json(j, opt); std::string err; bool ok = media::resize_file(j["input"].get(), j["output"].get(), 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(); - if (j.contains("max_height")) - opt.max_height = j["max_height"].get(); - if (j.contains("format") && j["format"].is_string()) - opt.format = j["format"].get(); + media::apply_resize_options_from_json(j, opt); std::string err; bool ok = media::resize_file(j["input"].get(), j["output"].get(), opt, err); nlohmann::json out = ok ? nlohmann::json{{"ok", true}} : nlohmann::json{{"ok", false}, {"error", err}}; diff --git a/packages/media/cpp/src/main.cpp b/packages/media/cpp/src/main.cpp index 7d4067d3..f3cead56 100644 --- a/packages/media/cpp/src/main.cpp +++ b/packages/media/cpp/src/main.cpp @@ -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 1–100")->default_val(85); + resize_cmd->add_option("--png-compression", png_compression, "PNG DEFLATE 0–9")->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"; diff --git a/packages/media/cpp/tests/assets/README.md b/packages/media/cpp/tests/assets/README.md index 63e03dde..bbb24e05 100644 --- a/packages/media/cpp/tests/assets/README.md +++ b/packages/media/cpp/tests/assets/README.md @@ -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