media:cpp : ui 1/2

This commit is contained in:
lovebird 2026-04-14 01:16:34 +02:00
parent 8ead230cc2
commit cb3e596736
10 changed files with 425 additions and 12 deletions

View File

@ -154,6 +154,7 @@ if(WIN32)
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/register_explorer.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_ui.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_progress_ui.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/ui_singleton.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/media-img-win.manifest"
)
endif()

Binary file not shown.

View File

@ -6,6 +6,7 @@
#include <algorithm>
#include <cctype>
#include <set>
#include <unordered_set>
namespace fs = std::filesystem;
@ -168,6 +169,123 @@ std::vector<std::string> expand_input_paths(const std::string &input_spec, std::
return out;
}
namespace {
void ext_to_lower_ascii(std::string &e) {
for (char &c : e) {
if (c >= 'A' && c <= 'Z')
c = static_cast<char>(c - 'A' + 'a');
}
}
bool is_supported_image_extension(const std::string &ext_with_dot) {
static const char *k[] = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif",
".jpe", ".jfif", ".avif", ".arw", ".heic", ".jf"};
std::string e = ext_with_dot;
ext_to_lower_ascii(e);
for (const char *ref : k) {
if (e == ref)
return true;
}
return false;
}
void collect_images_recursive(const fs::path &dir, std::set<std::string> &out) {
std::error_code ec;
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
if (ec)
return;
const fs::recursive_directory_iterator end;
for (; it != end; it.increment(ec)) {
if (ec)
break;
const fs::directory_entry &ent = *it;
std::error_code fe;
if (!ent.is_regular_file(fe) || fe)
continue;
const std::string ext = ent.path().extension().string();
if (!is_supported_image_extension(ext))
continue;
fs::path canon = fs::weakly_canonical(ent.path(), fe);
if (fe)
canon = fs::absolute(ent.path());
out.insert(canon.generic_string());
}
}
std::vector<std::string> split_semicolon_paths(const std::string &input_spec) {
std::vector<std::string> parts;
std::string cur;
for (char c : input_spec) {
if (c == ';') {
trim_in_place(cur);
if (!cur.empty())
parts.push_back(std::move(cur));
cur.clear();
} else {
cur.push_back(c);
}
}
trim_in_place(cur);
if (!cur.empty())
parts.push_back(std::move(cur));
return parts;
}
} // namespace
std::string expand_resize_ui_inputs(const std::string &input_spec, std::string &err_out) {
err_out.clear();
if (input_spec.empty()) {
err_out = "input is empty";
return {};
}
if (is_http_url(input_spec))
return input_spec;
if (has_glob_tokens(input_spec) || input_spec.find("**") != std::string::npos)
return input_spec;
std::vector<std::string> segments;
if (input_spec.find(';') == std::string::npos)
segments.push_back(input_spec);
else
segments = split_semicolon_paths(input_spec);
std::set<std::string> collected;
for (const auto &seg : segments) {
std::string s = seg;
trim_in_place(s);
if (s.empty())
continue;
fs::path p(s);
std::error_code ec;
if (fs::is_directory(p, ec)) {
collect_images_recursive(p, collected);
} else if (fs::is_regular_file(p, ec)) {
fs::path canon = fs::weakly_canonical(fs::absolute(p), ec);
if (ec)
canon = fs::absolute(p);
collected.insert(canon.generic_string());
} else {
err_out = "not found: " + s;
return {};
}
}
if (collected.empty()) {
err_out = "resize: no image files in selection";
return {};
}
std::string joined;
for (const auto &path : collected) {
if (!joined.empty())
joined.push_back(';');
joined += path;
}
return joined;
}
std::vector<std::pair<std::string, fs::path>> pair_resize_paths(const std::string &input_spec,
const std::string &output_spec,
std::string &err_out) {

View File

@ -22,6 +22,13 @@ bool has_dst_template(const std::string &output_spec);
*/
std::vector<std::string> expand_input_paths(const std::string &input_spec, std::string &err_out);
/**
* For `resize --ui`: expand semicolon-separated paths; recurse into directories and collect
* supported image files (same extensions as Explorer registration). Leaves glob/URL specs unchanged.
* @return New semicolon-separated list, or empty with err_out set.
*/
std::string expand_resize_ui_inputs(const std::string &semicolon_or_single_path, std::string &err_out);
/**
* Map inputs to output paths: optional per-file dst templates, one output file,
* or one file per input under a directory (trailing separator or existing directory).

View File

@ -34,6 +34,7 @@ std::string join_src_semicolons(const std::vector<std::string> &v) {
#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
@ -194,7 +195,23 @@ int main(int argc, char **argv) {
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;
@ -205,6 +222,25 @@ int main(int argc, char **argv) {
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;
@ -280,6 +316,18 @@ int main(int argc, char **argv) {
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);
@ -300,8 +348,10 @@ int main(int argc, char **argv) {
std::cerr << preview_err << "\n";
return 1;
}
if (jobs_preview.size() > 1)
batch_ok = media::win::run_resize_batch_with_progress_ui(in_path_e, out_path_e, opt, err, &batch);
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);
}

View File

@ -6,6 +6,9 @@
#include <windows.h>
#include <commctrl.h>
#include <filesystem>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
@ -85,7 +88,8 @@ static bool register_prog_class() {
} // namespace
bool run_resize_batch_with_progress_ui(const std::string &input_spec, const std::string &output_spec,
const ResizeOptions &opt, std::string &err_out, ResizeBatchResult *out_stats) {
const ResizeOptions &opt, std::string &err_out, ResizeBatchResult *out_stats,
bool always_show_progress) {
std::string pair_err;
auto jobs = media::pair_resize_paths(input_spec, output_spec, pair_err);
if (!pair_err.empty()) {
@ -96,7 +100,7 @@ bool run_resize_batch_with_progress_ui(const std::string &input_spec, const std:
err_out = "no resize jobs";
return false;
}
if (jobs.size() == 1)
if (!always_show_progress && jobs.size() == 1)
return media::resize_batch(input_spec, output_spec, opt, err_out, out_stats);
INITCOMMONCONTROLSEX icc{};
@ -134,12 +138,49 @@ bool run_resize_batch_with_progress_ui(const std::string &input_spec, const std:
bool ok = false;
std::string worker_err;
// Job queue + mutex: single consumer worker today; same mutex can guard pushes from other
// producers (e.g. WM_COPYDATA) with a std::condition_variable if streaming jobs are added later.
namespace fs = std::filesystem;
std::queue<std::pair<std::string, fs::path>> job_q;
std::mutex job_q_mutex;
for (const auto &j : jobs) {
std::lock_guard<std::mutex> lk(job_q_mutex);
job_q.push(j);
}
std::thread worker([&]() {
ok = media::resize_batch(
input_spec, output_spec, opt, worker_err, out_stats,
[&](std::size_t i, std::size_t total) {
PostMessageW(hwnd, WM_PI_PROG, static_cast<WPARAM>(i + 1), static_cast<LPARAM>(total));
});
if (out_stats) {
out_stats->count = 0;
out_stats->outputs.clear();
}
const std::size_t total = jobs.size();
std::size_t idx = 0;
while (true) {
std::pair<std::string, fs::path> job;
{
std::lock_guard<std::mutex> lk(job_q_mutex);
if (job_q.empty())
break;
job = job_q.front();
job_q.pop();
}
PostMessageW(hwnd, WM_PI_PROG, static_cast<WPARAM>(idx + 1), static_cast<LPARAM>(total));
std::error_code ec;
fs::create_directories(job.second.parent_path(), ec);
std::string one_err;
if (!media::resize_file(job.first, job.second.string(), opt, one_err)) {
ok = false;
worker_err = job.first + ": " + one_err;
PostMessageW(hwnd, WM_PI_DONE, 0, 0);
return;
}
if (out_stats) {
++out_stats->count;
out_stats->outputs.push_back(job.second.string());
}
++idx;
}
ok = true;
PostMessageW(hwnd, WM_PI_DONE, 0, 0);
});

View File

@ -6,8 +6,12 @@
namespace media::win {
/** Windows: run `resize_batch` with a native progress bar when multiple files; single job uses `resize_batch` only. */
/**
* Windows: run resize jobs with a native progress bar.
* @param always_show_progress If true (e.g. `resize --ui`), show the window even for a single file.
*/
bool run_resize_batch_with_progress_ui(const std::string &input_spec, const std::string &output_spec,
const ResizeOptions &opt, std::string &err_out, ResizeBatchResult *out_stats);
const ResizeOptions &opt, std::string &err_out, ResizeBatchResult *out_stats,
bool always_show_progress = false);
} // namespace media::win

View File

@ -1,4 +1,6 @@
#include "resize_ui.hpp"
#include "win/resize_ui.hpp"
#include "win/ui_singleton.hpp"
#include <windows.h>
#include <winerror.h>
@ -319,6 +321,7 @@ static LRESULT CALLBACK UiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
set_utf8_edit(st->h_in, *st->in_path);
set_utf8_edit(st->h_out, *st->out_path);
media::win::set_ui_merge_input_edit(st->h_in);
apply_message_font(hwnd);
{
RECT cr{};
@ -431,6 +434,7 @@ static LRESULT CALLBACK UiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
DestroyWindow(hwnd);
return 0;
case WM_DESTROY:
media::win::clear_ui_merge_input_edit();
PostQuitMessage(0);
return 0;
default:

View File

@ -0,0 +1,156 @@
#include "win/ui_singleton.hpp"
#include <string>
#include <vector>
#pragma comment(lib, "User32.lib")
namespace media::win {
namespace {
HWND g_merge_input_edit{};
HWND g_bridge_hwnd{};
HANDLE g_mutex{};
static const wchar_t kBridgeClass[] = L"MediaImgUiBridge";
static const wchar_t kBridgeTitle[] = L"media-img";
std::wstring utf8_to_wide(const std::string &s) {
if (s.empty())
return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
if (n <= 0)
return L"";
std::wstring w(static_cast<size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), w.data(), n);
return w;
}
LRESULT CALLBACK bridge_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
(void)wp;
if (msg == WM_COPYDATA) {
auto *cds = reinterpret_cast<COPYDATASTRUCT *>(lp);
if (!cds || cds->dwData != 1 || !cds->lpData || cds->cbData == 0)
return FALSE;
const char *bytes = static_cast<const char *>(cds->lpData);
size_t len = cds->cbData;
while (len > 0 && bytes[len - 1] == '\0')
--len;
if (len == 0)
return TRUE;
std::string payload(bytes, bytes + len);
if (g_merge_input_edit && IsWindow(g_merge_input_edit)) {
const int n = GetWindowTextLengthW(g_merge_input_edit);
std::wstring cur;
if (n > 0) {
cur.assign(static_cast<size_t>(n) + 1, L'\0');
GetWindowTextW(g_merge_input_edit, cur.data(), n + 1);
cur.resize(static_cast<size_t>(n));
}
std::wstring add = utf8_to_wide(payload);
if (!cur.empty() && !add.empty())
cur += L';';
cur += add;
SetWindowTextW(g_merge_input_edit, cur.c_str());
}
return TRUE;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
bool register_bridge_class_once() {
static bool tried = false;
if (tried)
return true;
tried = true;
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = bridge_wnd_proc;
wc.hInstance = GetModuleHandleW(nullptr);
wc.lpszClassName = kBridgeClass;
if (!RegisterClassExW(&wc)) {
if (GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
return false;
}
return true;
}
} // namespace
bool try_acquire_ui_singleton_mutex() {
g_mutex = CreateMutexW(nullptr, TRUE, L"Local\\MediaImgResizeUi_v1");
if (!g_mutex)
return false;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
CloseHandle(g_mutex);
g_mutex = nullptr;
return false;
}
return true;
}
void release_ui_singleton_mutex() {
if (!g_mutex)
return;
ReleaseMutex(g_mutex);
CloseHandle(g_mutex);
g_mutex = nullptr;
}
bool create_ui_singleton_bridge() {
if (g_bridge_hwnd)
return true;
if (!register_bridge_class_once())
return false;
#ifndef HWND_MESSAGE
#define HWND_MESSAGE ((HWND)(-3))
#endif
g_bridge_hwnd =
CreateWindowExW(0, kBridgeClass, kBridgeTitle, 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, GetModuleHandleW(nullptr),
nullptr);
return g_bridge_hwnd != nullptr;
}
void destroy_ui_singleton_bridge() {
if (g_bridge_hwnd) {
DestroyWindow(g_bridge_hwnd);
g_bridge_hwnd = nullptr;
}
}
void set_ui_merge_input_edit(HWND h) {
g_merge_input_edit = h;
}
void clear_ui_merge_input_edit() {
g_merge_input_edit = nullptr;
}
bool forward_resize_ui_paths_to_primary(const std::string &utf8_paths) {
HWND bridge = nullptr;
for (int i = 0; i < 80 && !bridge; ++i) {
bridge = FindWindowW(kBridgeClass, kBridgeTitle);
if (!bridge)
Sleep(25);
}
if (!bridge)
return false;
std::vector<char> buf;
buf.reserve(utf8_paths.size() + 1);
buf.assign(utf8_paths.begin(), utf8_paths.end());
buf.push_back('\0');
COPYDATASTRUCT cds{};
cds.dwData = 1;
cds.cbData = static_cast<DWORD>(buf.size());
cds.lpData = buf.data();
DWORD_PTR result = 0;
const LRESULT r =
SendMessageTimeoutW(bridge, WM_COPYDATA, 0, reinterpret_cast<LPARAM>(&cds), SMTO_ABORTIFHUNG, 15000, &result);
return r != 0;
}
} // namespace media::win

View File

@ -0,0 +1,32 @@
#pragma once
#include <string>
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
namespace media::win {
/** Create named mutex; returns true if this process is the primary UI instance. */
bool try_acquire_ui_singleton_mutex();
/** Release mutex from try_acquire_ui_singleton_mutex (primary only). */
void release_ui_singleton_mutex();
/** Message-only window that receives WM_COPYDATA from secondary instances. */
bool create_ui_singleton_bridge();
void destroy_ui_singleton_bridge();
/** Target edit control for merging forwarded paths (resize dialog input row). */
void set_ui_merge_input_edit(HWND h);
void clear_ui_merge_input_edit();
/**
* Find the bridge window and send UTF-8 paths (semicolon-separated) to the primary instance.
* Retries briefly to avoid races right after mutex creation.
*/
bool forward_resize_ui_paths_to_primary(const std::string &utf8_paths);
} // namespace media::win