diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 8a433b95..2476beeb 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -42,6 +42,7 @@ #include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_workspace.h" #include "features/gallery/preview_bandwidth.h" +#include "features/gallery/preview_resolution.h" #include "filesystem_utf8.h" #include "localization.h" #include "platform.h" @@ -1034,6 +1035,9 @@ struct GuiApplication::SlintUi { bool portable_service_dialog_initialized = false; bool sponsorship_prompt_pending = false; std::string recent_model_signature; + gallery::PreviewResolutionDebouncer recent_preview_resolution; + gallery::PreviewResolution recent_preview_size; + std::unordered_map recent_preview_images; std::optional recent_group_filter; std::vector recent_filter_groups; slint::Timer timer; @@ -2580,6 +2584,7 @@ void GuiApplication::Tick() { HandleRecentConnections(); if (was_reloading_recent_connections && !reload_recent_connections_) { ui_->recent_model_signature.clear(); + ui_->recent_preview_images.clear(); } HandleConnectionStatusChange(); HandlePendingPresenceProbe(); @@ -3271,8 +3276,20 @@ void GuiApplication::SyncMainWindow() { return props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_; }; + const auto preview_size = ui_->recent_preview_resolution.Update( + gallery::PreviewResolution::Viewport( + ui_->main->get_recent_preview_width(), + ui_->main->get_recent_preview_height(), + ui_->main->window().scale_factor()), + gallery::PreviewResolutionDebouncer::Clock::now()); + const bool save_previews = config_center_->IsSaveRemotePreviews(); + if (!(preview_size == ui_->recent_preview_size) || !save_previews) { + ui_->recent_preview_size = preview_size; + ui_->recent_preview_images.clear(); + } std::ostringstream signature_builder; - signature_builder << config_center_->IsSaveRemotePreviews() << '\n' + signature_builder << save_previews << '\n' + << preview_size.width << ':' << preview_size.height << '\n' << lan_mode << '\n' << bool(ui_->recent_group_filter) << '\0' << ui_->recent_group_filter.value_or("") << '\n'; @@ -3319,18 +3336,26 @@ void GuiApplication::SyncMainWindow() { !item.lan && device_presence_cache_.IsOnline(connection.remote_id); item.connected = is_connected(connection.remote_id); item.platform = UiText(HostPlatformName(connection.platform)); - std::vector rgba; - int image_width = 0; - int image_height = 0; - if (config_center_->IsSaveRemotePreviews() && - !connection.image_path.empty() && - thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, - &image_height)) { - slint::SharedPixelBuffer pixels(image_width, - image_height); - std::memcpy(pixels.begin(), rgba.data(), rgba.size()); - item.thumbnail = slint::Image(std::move(pixels)); - item.has_thumbnail = true; + if (save_previews && !connection.image_path.empty()) { + const auto path = connection.image_path.string(); + auto cached = ui_->recent_preview_images.find(path); + if (cached == ui_->recent_preview_images.end()) { + std::vector rgba; + int image_width = 0, image_height = 0; + if (thumbnail_->DecodeImage(connection.image_path, preview_size.width, + preview_size.height, &rgba, &image_width, + &image_height)) { + slint::SharedPixelBuffer pixels(image_width, + image_height); + std::memcpy(pixels.begin(), rgba.data(), rgba.size()); + cached = ui_->recent_preview_images.emplace( + path, slint::Image(std::move(pixels))).first; + } + } + if (cached != ui_->recent_preview_images.end()) { + item.thumbnail = cached->second; + item.has_thumbnail = true; + } } model.push_back(std::move(item)); } diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index d8f1c0be..ba944449 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -355,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() { if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) { uint32_t now_time = SDL_GetTicks(); if (now_time - recent_connection_image_save_time_ >= 50) { - int ret = thumbnail_->LoadThumbnail(recent_connections_, - &recent_connection_image_width_, - &recent_connection_image_height_); + int ret = thumbnail_->LoadThumbnail(recent_connections_); if (!ret) { LOG_INFO("Load recent connection thumbnails"); } diff --git a/apps/desktop/src/gui/runtime/runtime_state.h b/apps/desktop/src/gui/runtime/runtime_state.h index 5abc1da3..8487ad15 100644 --- a/apps/desktop/src/gui/runtime/runtime_state.h +++ b/apps/desktop/src/gui/runtime/runtime_state.h @@ -59,8 +59,6 @@ struct RecentConnectionsState { recent_connections_; std::vector recent_connection_ids_; std::vector gallery_presence_ids_; - int recent_connection_image_width_ = 160; - int recent_connection_image_height_ = 90; uint32_t recent_connection_image_save_time_ = 0; DevicePresenceCache device_presence_cache_; std::atomic need_to_send_recent_connections_{true}; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 15698f41..67232ce5 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -548,6 +548,8 @@ export component MainWindow inherits Window { in property signal-reconnecting: false; in property signal-tls-error: false; in property <[RecentConnection]> recent-connections; + out property recent-preview-width: max(1px, (recent-scroll.width - 24px) / 3); + out property recent-preview-height: 80px; in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")]; in-out property recent-group-index: 0; callback recent-group-selected(int); @@ -1561,7 +1563,7 @@ export component MainWindow inherits Window { spacing: 12px; alignment: start; for connection[index] in root.recent-connections: connection-card := Rectangle { - width: (recent-scroll.width - 24px) / 3; + width: root.recent-preview-width; height: 126px; background: transparent; private property available: connection.lan || connection.online || connection.connected; @@ -1584,7 +1586,7 @@ export component MainWindow inherits Window { x: 0px; y: 0px; width: parent.width; - height: 80px; + height: root.recent-preview-height; background: !connection-card.available ? DeviceCardStyle.offline-preview : connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) : connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) : diff --git a/apps/desktop/src/thumbnail/thumbnail.cpp b/apps/desktop/src/thumbnail/thumbnail.cpp index 6d97f252..dd4af952 100644 --- a/apps/desktop/src/thumbnail/thumbnail.cpp +++ b/apps/desktop/src/thumbnail/thumbnail.cpp @@ -11,11 +11,13 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -30,53 +32,41 @@ #include "stb_image_write.h" namespace crossdesk { +namespace { -void ScaleNv12ToABGR(char* src, int src_w, int src_h, int dst_w, int dst_h, - char* dst_rgba) { - uint8_t* y = reinterpret_cast(src); - uint8_t* uv = y + src_w * src_h; +// Keep enough detail for high-DPI cards, independently of the display used +// when the session closes. Preserve the source aspect ratio without bars. +constexpr int kPreviewMaxEdge = 1024; - float src_aspect = float(src_w) / src_h; - float dst_aspect = float(dst_w) / dst_h; - int fit_w = dst_w, fit_h = dst_h; - if (src_aspect > dst_aspect) { - fit_h = int(dst_w / src_aspect); - } else { - fit_w = int(dst_h * src_aspect); - } +bool ScaleNv12ToABGR(const char* src, int src_w, int src_h, int dst_w, + int dst_h, uint8_t* dst_rgba) { + const auto* y = reinterpret_cast(src); + const auto* uv = y + size_t(src_w) * src_h; + const int src_cw = (src_w + 1) / 2, src_ch = (src_h + 1) / 2; + const int dst_cw = (dst_w + 1) / 2, dst_ch = (dst_h + 1) / 2; + std::vector y_i420(size_t(src_w) * src_h); + std::vector u_i420(size_t(src_cw) * src_ch); + std::vector v_i420(u_i420.size()); + if (libyuv::NV12ToI420(y, src_w, uv, src_cw * 2, y_i420.data(), src_w, + u_i420.data(), src_cw, v_i420.data(), src_cw, + src_w, src_h) != 0) + return false; - std::vector y_i420(src_w * src_h); - std::vector u_i420((src_w / 2) * (src_h / 2)); - std::vector v_i420((src_w / 2) * (src_h / 2)); - libyuv::NV12ToI420(y, src_w, uv, src_w, y_i420.data(), src_w, u_i420.data(), - src_w / 2, v_i420.data(), src_w / 2, src_w, src_h); - - std::vector y_fit(fit_w * fit_h); - std::vector u_fit((fit_w + 1) / 2 * (fit_h + 1) / 2); - std::vector v_fit((fit_w + 1) / 2 * (fit_h + 1) / 2); - libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_w / 2, - v_i420.data(), src_w / 2, src_w, src_h, y_fit.data(), fit_w, - u_fit.data(), (fit_w + 1) / 2, v_fit.data(), - (fit_w + 1) / 2, fit_w, fit_h, libyuv::kFilterBilinear); - - std::vector abgr(fit_w * fit_h * 4); - libyuv::I420ToABGR(y_fit.data(), fit_w, u_fit.data(), (fit_w + 1) / 2, - v_fit.data(), (fit_w + 1) / 2, abgr.data(), fit_w * 4, - fit_w, fit_h); - - std::memset(dst_rgba, 0, dst_w * dst_h * 4); - for (int i = 0; i < dst_w * dst_h; ++i) { - dst_rgba[i * 4 + 3] = static_cast(0xFF); - } - - for (int row = 0; row < fit_h; ++row) { - int dst_offset = - ((row + (dst_h - fit_h) / 2) * dst_w + (dst_w - fit_w) / 2) * 4; - std::memcpy(dst_rgba + dst_offset, abgr.data() + row * fit_w * 4, - fit_w * 4); - } + std::vector y_fit(size_t(dst_w) * dst_h); + std::vector u_fit(size_t(dst_cw) * dst_ch); + std::vector v_fit(u_fit.size()); + if (libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_cw, + v_i420.data(), src_cw, src_w, src_h, y_fit.data(), dst_w, + u_fit.data(), dst_cw, v_fit.data(), dst_cw, dst_w, + dst_h, libyuv::kFilterBox) != 0) + return false; + return libyuv::I420ToABGR(y_fit.data(), dst_w, u_fit.data(), dst_cw, + v_fit.data(), dst_cw, dst_rgba, dst_w * 4, + dst_w, dst_h) == 0; } +} // namespace + Thumbnail::Thumbnail(std::string save_path) { if (!save_path.empty()) { save_path_ = save_path; @@ -98,19 +88,6 @@ Thumbnail::Thumbnail(std::string save_path, unsigned char* aes128_key, std::filesystem::create_directories(save_path_); } -Thumbnail::~Thumbnail() { - if (rgba_buffer_) { - delete[] rgba_buffer_; - rgba_buffer_ = nullptr; - } -} - -int Thumbnail::SetThumbnailDpiScale(float dpi_scale) { - thumbnail_width_ = static_cast(thumbnail_width_ * dpi_scale); - thumbnail_height_ = static_cast(thumbnail_height_ * dpi_scale); - return 0; -} - namespace { bool IsRemoteId(const std::string& id) { @@ -260,14 +237,15 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height, const auto image = std::filesystem::path(save_path_) / (remote_id + ".png"); if (save_previews_ && nv12 && width > 0 && height > 0) { - if (!rgba_buffer_) { - rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4]; - } - ScaleNv12ToABGR(const_cast(nv12), width, height, thumbnail_width_, - thumbnail_height_, rgba_buffer_); - if (!stbi_write_png(image.string().c_str(), thumbnail_width_, - thumbnail_height_, 4, rgba_buffer_, - thumbnail_width_ * 4)) + const double scale = std::min(1.0, double(kPreviewMaxEdge) / + std::max(width, height)); + const int image_width = std::max(1, int(std::lround(width * scale))); + const int image_height = std::max(1, int(std::lround(height * scale))); + std::vector rgba(size_t(image_width) * image_height * 4); + if (!ScaleNv12ToABGR(nv12, width, height, image_width, image_height, + rgba.data()) || + !stbi_write_png(image.string().c_str(), image_width, image_height, 4, + rgba.data(), image_width * 4)) return -1; } else if (!save_previews_) { std::error_code error; @@ -278,11 +256,8 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height, } int Thumbnail::LoadThumbnail( - std::vector>& recent_connections, - int* width, int* height) { + std::vector>& recent_connections) { std::lock_guard lock(mutex_); - if (width) *width = thumbnail_width_; - if (height) *height = thumbnail_height_; return LoadThumbnailLocked(recent_connections); } @@ -366,27 +341,69 @@ int Thumbnail::LoadThumbnailLocked( } bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, + int target_width, int target_height, std::vector* rgba, int* width, int* height) const { - if (!rgba || !width || !height) { + if (!rgba || !width || !height || target_width <= 0 || target_height <= 0) { return false; } - - int channels = 0; - unsigned char* pixels = - stbi_load(image_path.string().c_str(), width, height, &channels, 4); - if (!pixels || *width <= 0 || *height <= 0) { - if (pixels) { - stbi_image_free(pixels); - } - rgba->clear(); + rgba->clear(); + *width = *height = 0; + int source_width = 0, source_height = 0, channels = 0; + std::unique_lock lock(mutex_); + const std::unique_ptr pixels( + stbi_load(image_path.string().c_str(), &source_width, &source_height, + &channels, 4), stbi_image_free); + lock.unlock(); + if (!pixels || source_width <= 0 || source_height <= 0) return false; - } - const size_t byte_count = - static_cast(*width) * static_cast(*height) * 4; - rgba->assign(pixels, pixels + byte_count); - stbi_image_free(pixels); + // Match image-fit: cover, cropping once from the original cached image. + int crop_width = source_width, crop_height = source_height; + if (int64_t(source_width) * target_height > + int64_t(source_height) * target_width) + crop_width = std::max(1, int(int64_t(source_height) * target_width / + target_height)); + else + crop_height = std::max(1, int(int64_t(source_width) * target_height / + target_width)); + const double scale = std::min({1.0, double(crop_width) / target_width, + double(crop_height) / target_height}); + const int output_width = std::max(1, int(std::lround(target_width * scale))); + const int output_height = std::max(1, int(std::lround(target_height * scale))); + const auto* cropped = pixels.get() + + (size_t((source_height - crop_height) / 2) * source_width + + (source_width - crop_width) / 2) * 4; + + // ARGBScale falls back to bilinear for general box downsampling. Scale + // separate color planes to get area filtering without another YUV conversion. + const size_t source_area = size_t(crop_width) * crop_height; + const size_t output_area = size_t(output_width) * output_height; + std::vector source_planes(source_area * 4); + std::vector output_planes(output_area * 4); + libyuv::SplitARGBPlane(cropped, source_width * 4, + source_planes.data(), crop_width, + source_planes.data() + source_area, crop_width, + source_planes.data() + source_area * 2, crop_width, + source_planes.data() + source_area * 3, crop_width, + crop_width, crop_height); + for (size_t channel = 0; channel < 4; ++channel) { + if (libyuv::ScalePlane(source_planes.data() + source_area * channel, + crop_width, crop_width, crop_height, + output_planes.data() + output_area * channel, + output_width, output_width, output_height, + libyuv::kFilterBox) != 0) + return false; + } + rgba->resize(output_area * 4); + libyuv::MergeARGBPlane(output_planes.data(), output_width, + output_planes.data() + output_area, output_width, + output_planes.data() + output_area * 2, output_width, + output_planes.data() + output_area * 3, output_width, + rgba->data(), output_width * 4, + output_width, output_height); + *width = output_width; + *height = output_height; return true; } diff --git a/apps/desktop/src/thumbnail/thumbnail.h b/apps/desktop/src/thumbnail/thumbnail.h index 174cf501..be2fcdf3 100644 --- a/apps/desktop/src/thumbnail/thumbnail.h +++ b/apps/desktop/src/thumbnail/thumbnail.h @@ -1,7 +1,7 @@ /* * @Author: DI JUNKUN * @Date: 2024-11-07 - * Copyright (c) 2024 by DI JUNKUN, All Rights Reserved. + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ #ifndef _THUMBNAIL_H_ @@ -39,10 +39,6 @@ class Thumbnail { Thumbnail(std::string save_path); explicit Thumbnail(std::string save_path, unsigned char* aes128_key, unsigned char* aes128_iv); - ~Thumbnail(); - - public: - int SetThumbnailDpiScale(float dpi_scale); // Serialized with writes so disabling also clears any in-flight preview. int SetSavePreviews(bool enabled); @@ -57,10 +53,12 @@ class Thumbnail { int LoadThumbnail( std::vector>& - recent_connections, - int* width, int* height); + recent_connections); + // Crop from the saved master and downsample for a physical-pixel viewport. + // Low-resolution legacy images are never enlarged in the cache. bool DecodeImage(const std::filesystem::path& image_path, + int target_width, int target_height, std::vector* rgba, int* width, int* height) const; @@ -100,11 +98,8 @@ class Thumbnail { unsigned char* iv); private: - std::mutex mutex_; + mutable std::mutex mutex_; bool save_previews_ = true; - int thumbnail_width_ = 160; - int thumbnail_height_ = 90; - char* rgba_buffer_ = nullptr; std::string save_path_ = "thumbnails/"; unsigned char aes128_key_[16];