diff --git a/packages/media/cpp/CMakeLists.txt b/packages/media/cpp/CMakeLists.txt index 335873dd..b11be26f 100644 --- a/packages/media/cpp/CMakeLists.txt +++ b/packages/media/cpp/CMakeLists.txt @@ -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() diff --git a/packages/media/cpp/dist/media-img.exe b/packages/media/cpp/dist/media-img.exe index 0e3f78e6..7bf96619 100644 Binary files a/packages/media/cpp/dist/media-img.exe and b/packages/media/cpp/dist/media-img.exe differ diff --git a/packages/media/cpp/src/core/glob_paths.cpp b/packages/media/cpp/src/core/glob_paths.cpp index 8b3eeae3..68dc90e1 100644 --- a/packages/media/cpp/src/core/glob_paths.cpp +++ b/packages/media/cpp/src/core/glob_paths.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace fs = std::filesystem; @@ -168,6 +169,123 @@ std::vector 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(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 &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 split_semicolon_paths(const std::string &input_spec) { + std::vector 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 segments; + if (input_spec.find(';') == std::string::npos) + segments.push_back(input_spec); + else + segments = split_semicolon_paths(input_spec); + + std::set 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> pair_resize_paths(const std::string &input_spec, const std::string &output_spec, std::string &err_out) { diff --git a/packages/media/cpp/src/core/glob_paths.hpp b/packages/media/cpp/src/core/glob_paths.hpp index b0fb0624..08cde4f1 100644 --- a/packages/media/cpp/src/core/glob_paths.hpp +++ b/packages/media/cpp/src/core/glob_paths.hpp @@ -22,6 +22,13 @@ bool has_dst_template(const std::string &output_spec); */ std::vector 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). diff --git a/packages/media/cpp/src/main.cpp b/packages/media/cpp/src/main.cpp index 8483592f..d8f67e4f 100644 --- a/packages/media/cpp/src/main.cpp +++ b/packages/media/cpp/src/main.cpp @@ -34,6 +34,7 @@ std::string join_src_semicolons(const std::vector &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); } diff --git a/packages/media/cpp/src/win/resize_progress_ui.cpp b/packages/media/cpp/src/win/resize_progress_ui.cpp index a43db90b..c96726d6 100644 --- a/packages/media/cpp/src/win/resize_progress_ui.cpp +++ b/packages/media/cpp/src/win/resize_progress_ui.cpp @@ -6,6 +6,9 @@ #include #include +#include +#include +#include #include #include @@ -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> job_q; + std::mutex job_q_mutex; + for (const auto &j : jobs) { + std::lock_guard 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(i + 1), static_cast(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 job; + { + std::lock_guard lk(job_q_mutex); + if (job_q.empty()) + break; + job = job_q.front(); + job_q.pop(); + } + PostMessageW(hwnd, WM_PI_PROG, static_cast(idx + 1), static_cast(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); }); diff --git a/packages/media/cpp/src/win/resize_progress_ui.hpp b/packages/media/cpp/src/win/resize_progress_ui.hpp index ab363e79..ea982da5 100644 --- a/packages/media/cpp/src/win/resize_progress_ui.hpp +++ b/packages/media/cpp/src/win/resize_progress_ui.hpp @@ -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 diff --git a/packages/media/cpp/src/win/resize_ui.cpp b/packages/media/cpp/src/win/resize_ui.cpp index 346a97d4..15688571 100644 --- a/packages/media/cpp/src/win/resize_ui.cpp +++ b/packages/media/cpp/src/win/resize_ui.cpp @@ -1,4 +1,6 @@ -#include "resize_ui.hpp" +#include "win/resize_ui.hpp" + +#include "win/ui_singleton.hpp" #include #include @@ -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: diff --git a/packages/media/cpp/src/win/ui_singleton.cpp b/packages/media/cpp/src/win/ui_singleton.cpp new file mode 100644 index 00000000..8e5f5819 --- /dev/null +++ b/packages/media/cpp/src/win/ui_singleton.cpp @@ -0,0 +1,156 @@ +#include "win/ui_singleton.hpp" + +#include +#include + +#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(s.size()), nullptr, 0); + if (n <= 0) + return L""; + std::wstring w(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(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(lp); + if (!cds || cds->dwData != 1 || !cds->lpData || cds->cbData == 0) + return FALSE; + const char *bytes = static_cast(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(n) + 1, L'\0'); + GetWindowTextW(g_merge_input_edit, cur.data(), n + 1); + cur.resize(static_cast(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 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(buf.size()); + cds.lpData = buf.data(); + + DWORD_PTR result = 0; + const LRESULT r = + SendMessageTimeoutW(bridge, WM_COPYDATA, 0, reinterpret_cast(&cds), SMTO_ABORTIFHUNG, 15000, &result); + return r != 0; +} + +} // namespace media::win diff --git a/packages/media/cpp/src/win/ui_singleton.hpp b/packages/media/cpp/src/win/ui_singleton.hpp new file mode 100644 index 00000000..3bfcbfdd --- /dev/null +++ b/packages/media/cpp/src/win/ui_singleton.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +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