421 lines
16 KiB
C++
421 lines
16 KiB
C++
#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 "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"
|
||
#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 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)");
|
||
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");
|
||
#endif
|
||
|
||
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 media-img.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) {
|
||
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()) {
|
||
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);
|
||
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 (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;
|
||
}
|