mono/packages/media/cpp/src/main.cpp

482 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
namespace {
std::string join_src_semicolons(const std::vector<std::string> &v) {
std::string s;
for (size_t i = 0; i < v.size(); ++i) {
if (i)
s += ';';
s += v[i];
}
return s;
}
} // namespace
#include <laserpants/dotenv/dotenv.h>
#include <CLI/CLI.hpp>
#ifdef _WIN32
#include <process.h>
#else
#include <unistd.h>
#endif
#include "core/output_path.hpp"
#include "core/resize.hpp"
#include "core/transform.hpp"
#include "http/serve.hpp"
#include "ipc/ipc_serve.hpp"
#if defined(_WIN32)
#include "core/glob_paths.hpp"
#include "win/register_explorer.hpp"
#include "win/resize_progress_ui.hpp"
#include "win/resize_ui.hpp"
#include "win/ui_singleton.hpp"
#include "win/ui_next/launch_ui_next.h"
#endif
#ifndef MEDIA_IMG_VERSION
#define MEDIA_IMG_VERSION "0.1.0"
#endif
static int forward_kbot(const std::vector<std::string> &args) {
const char *exe = std::getenv("KBOT_EXE");
if (exe == nullptr || exe[0] == '\0') {
std::cerr
<< "KBOT_EXE is not set. Set it to the kbot binary path (e.g. packages/kbot/cpp/dist/kbot.exe).\n";
return 1;
}
std::vector<std::string> storage;
storage.emplace_back(exe);
storage.insert(storage.end(), args.begin(), args.end());
std::vector<char *> argv;
for (auto &s : storage) {
argv.push_back(s.data());
}
argv.push_back(nullptr);
#ifdef _WIN32
const int rc = _spawnvp(_P_WAIT, exe, argv.data());
if (rc < 0) {
perror("_spawnvp");
return 1;
}
return rc;
#else
execvp(exe, argv.data());
perror("execvp");
return 127;
#endif
}
int main(int argc, char **argv) {
dotenv::init(dotenv::Preserve);
CLI::App app{"media-img — resize (CLI), serve (REST), ipc (JSON lines)", "media-img"};
app.set_version_flag("-v,--version", std::string(MEDIA_IMG_VERSION));
app.require_subcommand(1);
std::string in_path;
std::string out_path;
std::vector<std::string> src_list;
std::string dst_flag;
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 / transform an image (libvips, Sharp-like options)");
resize_cmd->add_option("input", in_path, "Input path, glob (*, ?, **), or http(s):// URL")->required(false);
resize_cmd->add_option(
"output", out_path,
"Output file/dir, or omit when there is exactly one input → write under cwd (sanitized name)");
resize_cmd->add_option("--src", src_list, "Input (repeat for multiple); use with --dst; Explorer passes several files")
->expected(-1);
resize_cmd->add_option("--dst", dst_flag, "Same as positional output; directory if multiple inputs");
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)");
bool resize_no_cache = false;
std::string resize_cache_dir;
int url_timeout_sec = 5;
int url_max_redirects = 20;
resize_cmd->add_flag("--no-cache", resize_no_cache, "Disable output cache (default: cache on)");
resize_cmd->add_option("--cache-dir", resize_cache_dir, "Cache root (default: <cwd>/cache/images)");
resize_cmd->add_option("--url-timeout", url_timeout_sec, "HTTP(S) fetch timeout (seconds, 0 = libcurl default)")
->default_val(5);
resize_cmd
->add_option("--url-max-redirects", url_max_redirects, "Max redirects when fetching URL inputs")
->default_val(20);
#if defined(_WIN32)
bool resize_ui = false;
resize_cmd->add_flag("--ui", resize_ui,
"Windows: native dialog for paths/options; optional --src/--dst seed the dialog");
bool resize_ui_next = false;
resize_cmd->add_flag("--ui-next", resize_ui_next,
"Windows: new Win32++ ribbon UI with drag-drop queue and settings panel");
#endif
// ── transform (AI image editing) ──────────────────────────────────
std::string tf_input;
std::string tf_output;
std::string tf_prompt;
std::string tf_provider = "google";
std::string tf_model = "gemini-2.0-flash-exp";
std::string tf_api_key;
auto *transform_cmd = app.add_subcommand("transform", "AI image editing (Gemini / Google)");
transform_cmd->add_option("input", tf_input, "Input image path")->required(true);
transform_cmd->add_option("output", tf_output, "Output path (omit = auto from input + prompt)");
transform_cmd->add_option("-p,--prompt", tf_prompt, "Editing prompt")->required(true);
transform_cmd->add_option("--provider", tf_provider, "AI provider (google)")->default_val("google");
transform_cmd->add_option("--model", tf_model, "Model name")->default_val("gemini-2.0-flash-exp");
transform_cmd->add_option("--api-key", tf_api_key, "API key (or set IMAGE_TRANSFORM_GOOGLE_API_KEY env)");
// ── serve ───────────────────────────────────────────────────────────
std::string host = "127.0.0.1";
int port = 8080;
bool serve_no_cache = false;
std::string serve_cache_dir;
auto *serve_cmd = app.add_subcommand("serve", "Run HTTP REST server");
serve_cmd->add_option("--host", host, "Bind address")->default_val("127.0.0.1");
serve_cmd->add_option("-p,--port", port, "TCP port")->default_val(8080);
serve_cmd->add_flag("--no-cache", serve_no_cache,
"Server default: cache off when JSON omits \"cache\" (JSON may still enable per request)");
serve_cmd->add_option("--cache-dir", serve_cache_dir, "Server default cache dir when JSON omits \"cache_dir\"");
std::string ipc_host = "127.0.0.1";
int ipc_port = 9333;
std::string ipc_unix;
bool ipc_no_cache = false;
std::string ipc_cache_dir;
auto *ipc_cmd = app.add_subcommand("ipc", "Run JSON-line IPC server (TCP; Unix socket on non-Windows)");
ipc_cmd->add_option("--host", ipc_host, "Bind address")->default_val("127.0.0.1");
ipc_cmd->add_option("-p,--port", ipc_port, "TCP port")->default_val(9333);
ipc_cmd->add_option("--unix", ipc_unix, "Unix domain socket path (not Windows)");
ipc_cmd->add_flag("--no-cache", ipc_no_cache,
"Server default: cache off when JSON omits \"cache\" (JSON may still enable per request)");
ipc_cmd->add_option("--cache-dir", ipc_cache_dir, "Server default cache dir when JSON omits \"cache_dir\"");
auto *kbot_cmd = app.add_subcommand("kbot", "Forward remaining args to kbot (KBOT_EXE)");
kbot_cmd->allow_extras(true);
std::string reg_group = "PM-Media";
bool reg_unregister = false;
bool reg_dry = false;
bool reg_no_refresh = false;
std::string reg_media_bin;
std::string reg_explorer_script;
std::string reg_explorer_convert_script;
std::string reg_explorer_ui_script;
std::string reg_widths = "1980,1200,800,400";
auto *reg_cmd = app.add_subcommand(
"register-explorer",
"Register Windows Explorer menus: resize presets + Convert to JPG (explorer-*.ps1 scripts)");
reg_cmd->add_option("--group", reg_group)->default_val("PM-Media");
reg_cmd->add_flag("--unregister", reg_unregister);
reg_cmd->add_flag("--dry", reg_dry);
reg_cmd->add_flag("--no-refresh-shell", reg_no_refresh);
reg_cmd->add_option("--media-bin", reg_media_bin, "Path to pm-image.exe (default: this executable)");
reg_cmd->add_option("--explorer-script", reg_explorer_script,
"Path to explorer-resize.ps1 (default: <exe_dir>\\scripts\\explorer-resize.ps1)");
reg_cmd->add_option("--explorer-convert-script", reg_explorer_convert_script,
"Path to explorer-convert.ps1 (default: <exe_dir>\\scripts\\explorer-convert.ps1)");
reg_cmd->add_option("--explorer-ui-script", reg_explorer_ui_script,
"Path to explorer-ui.ps1 (default: <exe_dir>\\scripts\\explorer-ui.ps1)");
reg_cmd->add_option("--widths", reg_widths)->default_val("1980,1200,800,400");
CLI11_PARSE(app, argc, argv);
#if defined(_WIN32)
bool resize_ui_mode = false;
#endif
if (resize_cmd->parsed()) {
#if defined(_WIN32)
struct UiSingletonOwner {
bool mutex = false;
bool bridge = false;
~UiSingletonOwner() {
if (bridge)
media::win::destroy_ui_singleton_bridge();
if (mutex)
media::win::release_ui_singleton_mutex();
}
} ui_singleton;
#endif
media::ResizeOptions opt;
std::string in_path_e;
std::string out_path_e;
#if defined(_WIN32)
if (resize_ui_next) {
std::vector<std::string> initial;
if (!src_list.empty())
initial = src_list;
else if (!in_path.empty())
initial.push_back(in_path);
return media::win::launch_ui_next(initial);
}
if (resize_ui) {
if (!src_list.empty() && !in_path.empty()) {
std::cerr << "resize: use either positional input or --src, not both\n";
return 1;
}
{
const std::string forward_seed = src_list.empty() ? in_path : join_src_semicolons(src_list);
if (!media::win::try_acquire_ui_singleton_mutex()) {
if (!forward_seed.empty()) {
if (!media::win::forward_resize_ui_paths_to_primary(forward_seed))
std::cerr << "media-img: could not reach the running resize UI (try again in a moment)\n";
} else {
std::cerr << "media-img: resize UI is already running\n";
}
return 0;
}
ui_singleton.mutex = true;
if (!media::win::create_ui_singleton_bridge()) {
std::cerr << "media-img: could not create UI bridge window\n";
return 1;
}
ui_singleton.bridge = true;
}
resize_ui_mode = true;
media::ResizeOptions initial{};
initial.max_width = max_w;
initial.max_height = max_h;
initial.format = format;
initial.fit = fit;
initial.position = position;
initial.kernel = kernel;
initial.background = background;
initial.quality = quality;
initial.png_compression = png_compression;
initial.rotate = rotate;
initial.flip = flip;
initial.flop = flop;
initial.autorotate = !no_autorotate;
initial.strip_metadata = !no_strip;
initial.without_enlargement = !allow_enlargement;
initial.cache_enabled = !resize_no_cache;
initial.cache_dir = resize_cache_dir;
initial.url_timeout_sec = url_timeout_sec;
initial.url_max_redirects = url_max_redirects;
std::string ui_in = src_list.empty() ? in_path : join_src_semicolons(src_list);
std::string ui_out = dst_flag.empty() ? out_path : dst_flag;
if (!media::win::show_resize_ui(opt, ui_in, ui_out, initial))
return 0;
in_path_e = std::move(ui_in);
out_path_e = std::move(ui_out);
} else
#endif
{
const bool use_src_dst = !src_list.empty() || !dst_flag.empty();
if (use_src_dst) {
if (src_list.empty() || dst_flag.empty()) {
std::cerr << "resize: --src (one or more) and --dst must be used together\n";
return 1;
}
in_path_e = join_src_semicolons(src_list);
out_path_e = dst_flag;
} else if (in_path.empty()) {
std::cerr << "resize: provide input (and output, or omit output to write under the current directory)\n";
return 1;
} else if (out_path.empty()) {
std::string derr;
out_path_e = media::default_output_path_for_resize(in_path, format, derr, {});
if (out_path_e.empty()) {
std::cerr << derr << "\n";
return 1;
}
in_path_e = in_path;
} else {
in_path_e = in_path;
out_path_e = out_path;
}
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;
opt.cache_enabled = !resize_no_cache;
opt.cache_dir = resize_cache_dir;
opt.url_timeout_sec = url_timeout_sec;
opt.url_max_redirects = url_max_redirects;
}
#if defined(_WIN32)
if (resize_ui_mode) {
std::string xerr;
const std::string expanded_in = media::expand_resize_ui_inputs(in_path_e, xerr);
if (expanded_in.empty()) {
std::cerr << xerr << "\n";
return 1;
}
in_path_e = expanded_in;
}
#endif
if (out_path_e.empty()) {
std::string derr;
out_path_e = media::default_output_path_for_resize(in_path_e, opt.format, derr, opt.output_stem_suffix);
if (out_path_e.empty() && !derr.empty()) {
std::cerr << derr << "\n";
return 1;
}
}
std::string err;
media::ResizeBatchResult batch;
#if defined(_WIN32)
bool batch_ok;
{
std::string preview_err;
auto jobs_preview =
media::pair_resize_paths(in_path_e, out_path_e, preview_err,
out_path_e.empty() ? &opt.format : nullptr,
out_path_e.empty() ? &opt.output_stem_suffix : nullptr);
if (!preview_err.empty()) {
std::cerr << preview_err << "\n";
return 1;
}
const bool use_progress = resize_ui_mode || jobs_preview.size() > 1;
if (use_progress)
batch_ok = media::win::run_resize_batch_with_progress_ui(in_path_e, out_path_e, opt, err, &batch,
resize_ui_mode);
else
batch_ok = media::resize_batch(in_path_e, out_path_e, opt, err, &batch);
}
#else
const bool batch_ok = media::resize_batch(in_path_e, out_path_e, opt, err, &batch);
#endif
if (!batch_ok) {
std::cerr << err << "\n";
return 1;
}
if (batch.count > 1)
std::cout << batch.count << " file(s) written\n";
return 0;
}
if (transform_cmd->parsed()) {
media::TransformOptions topts;
topts.provider = tf_provider;
topts.model = tf_model;
topts.prompt = tf_prompt;
topts.api_key = tf_api_key;
if (topts.api_key.empty()) {
const char* env_key = std::getenv("IMAGE_TRANSFORM_GOOGLE_API_KEY");
if (env_key && env_key[0] != '\0') topts.api_key = env_key;
}
auto progress = [](const std::string& msg) {
std::cerr << msg << "\n";
};
auto result = media::transform_image(tf_input, tf_output, topts, progress);
if (!result.ok) {
std::cerr << "transform error: " << result.error << "\n";
return 1;
}
std::cout << result.output_path << "\n";
if (!result.ai_text.empty())
std::cerr << "AI: " << result.ai_text << "\n";
return 0;
}
if (serve_cmd->parsed()) {
media::CacheServerDefaults cd;
cd.enabled = !serve_no_cache;
cd.cache_dir = serve_cache_dir;
return media::http::run_server(host, port, cd);
}
if (ipc_cmd->parsed()) {
media::CacheServerDefaults cd;
cd.enabled = !ipc_no_cache;
cd.cache_dir = ipc_cache_dir;
#if defined(_WIN32)
if (!ipc_unix.empty()) {
std::cerr << "media-img: --unix is not supported on Windows; use --host and --port.\n";
return 1;
}
return media::ipc::run_tcp_server(ipc_host, ipc_port, cd);
#else
if (!ipc_unix.empty()) {
return media::ipc::run_unix_server(ipc_unix, cd);
}
return media::ipc::run_tcp_server(ipc_host, ipc_port, cd);
#endif
}
if (kbot_cmd->parsed()) {
return forward_kbot(kbot_cmd->remaining());
}
if (reg_cmd->parsed()) {
#if defined(_WIN32)
media::win::RegisterExplorerOptions o;
o.group = reg_group;
o.unregister = reg_unregister;
o.dry = reg_dry;
o.refresh_shell = !reg_no_refresh;
o.media_bin = reg_media_bin;
o.explorer_script = reg_explorer_script;
o.explorer_convert_script = reg_explorer_convert_script;
o.explorer_ui_script = reg_explorer_ui_script;
o.widths = reg_widths;
return media::win::register_explorer_run(o);
#else
std::cerr << "media-img: register-explorer is only available on Windows.\n";
return 1;
#endif
}
std::cout << app.help() << "\n";
return 0;
}