diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 5fb66c07..7188a73c 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -37,6 +37,7 @@ #include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_workspace.h" +#include "features/gallery/preview_bandwidth.h" #include "localization.h" #include "platform.h" #include "platform/video_renderer.h" @@ -873,6 +874,14 @@ struct GuiApplication::SlintUi { slint::Image image; }; std::unordered_map gallery_previews; + struct PendingPreview { + std::shared_ptr props; + PreviewSubscription demand; + bool needs_sync = false; + }; + std::vector gallery_pending_previews; + std::vector gallery_bandwidth_demands; + gallery::PreviewBandwidthAllocator gallery_bandwidth_allocator; struct BatchOperation { std::string id, action, file; // Only explicit add-device requests carry credentials; never persisted. @@ -1321,6 +1330,8 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_gallery_frame_rate_index(ui_->gallery.stream_settings.FrameRateIndex()); ui_->main->set_gallery_resolution_draft(ui_->gallery.stream_settings.resolution); ui_->main->set_gallery_frame_rate_draft(ui_->gallery.stream_settings.FrameRateIndex()); + ui_->main->set_gallery_bandwidth_index(ui_->gallery.stream_settings.BandwidthIndex()); + ui_->main->set_gallery_bandwidth_draft(ui_->gallery.stream_settings.BandwidthIndex()); ui_->main->set_gallery_settings_error(""); } @@ -3721,10 +3732,12 @@ bool GuiApplication::SaveSettingsFromUi() { return false; } const auto wall_settings = gallery::StreamSettings::FromIndices( - main->get_gallery_resolution_draft(), main->get_gallery_frame_rate_draft()); + main->get_gallery_resolution_draft(), main->get_gallery_frame_rate_draft(), + main->get_gallery_bandwidth_draft()); const bool wall_changed = wall_settings && (wall_settings->resolution != ui_->gallery.stream_settings.resolution || - wall_settings->frame_rate != ui_->gallery.stream_settings.frame_rate); + wall_settings->frame_rate != ui_->gallery.stream_settings.frame_rate || + wall_settings->bandwidth_mbps != ui_->gallery.stream_settings.bandwidth_mbps); auto updated_wall = ui_->gallery; if (wall_settings) updated_wall.stream_settings = *wall_settings; // Persist the draft first, but leave live sessions unchanged until the @@ -5352,9 +5365,25 @@ void GuiApplication::SyncGallery() { old.connected != device.connected) ui_->gallery_selected_model->set_row_data(i, device); } + auto& pending_previews = ui_->gallery_pending_previews; + auto& bandwidth_demands = ui_->gallery_bandwidth_demands; + pending_previews.clear(); + bandwidth_demands.clear(); + const bool stream_window_visible = ui_->stream && + (*ui_->stream)->window().is_visible() && + !(*ui_->stream)->window().is_minimized(); + const auto stream_viewport = stream_window_visible + ? gallery::PreviewResolution::Viewport( + (*ui_->stream)->get_video_area_width(), + (*ui_->stream)->get_video_area_height(), + (*ui_->stream)->window().scale_factor()) + : gallery::PreviewResolution{}; { std::shared_lock lock(remote_sessions_mutex_); + pending_previews.reserve(remote_sessions_.size()); + bandwidth_demands.reserve(remote_sessions_.size()); for (const auto& [id, props] : remote_sessions_) { + if (!props || props->closing_) continue; if (!preview_ids.count(id)) { props->gallery_preview_width_ = 0; std::unique_lock conversion_lock(props->gallery_conversion_mutex_, @@ -5371,8 +5400,8 @@ void GuiApplication::SyncGallery() { props->connection_status_ == ConnectionStatus::Connected) { if (wall_mode && !ui_->gallery_hidden_active_ids.count(id)) props->wall_managed_ = true; - // Keep session quality and audio; wall settings override only the - // requested resolution/cadence and never overwrite session preferences. + // Keep session quality and audio; wall bandwidth, resolution and + // cadence requests never overwrite session preferences. PreviewSubscription demand; { std::lock_guard settings_lock(props->video_settings_mutex_); @@ -5383,42 +5412,66 @@ void GuiApplication::SyncGallery() { if (props->wall_managed_) wall.stream_settings.ApplyFrameRate(demand, props->preview_fps15_supported_); const bool needs_sync = props->preview_needs_sync_.exchange(false); + const bool stream_visible = stream_window_visible && + props->tab_opened_ && id == focused_remote_id_; if (props->preview_resolution_supported_) { - gallery::PreviewResolution stream_viewport; - if (props->tab_opened_ && id == focused_remote_id_ && ui_->stream && - (*ui_->stream)->window().is_visible() && - !(*ui_->stream)->window().is_minimized()) { - stream_viewport = gallery::PreviewResolution::Viewport( - (*ui_->stream)->get_video_area_width(), - (*ui_->stream)->get_video_area_height(), - (*ui_->stream)->window().scale_factor()); - } const auto resolution = props->preview_resolution_.Update( wall.stream_settings.Resolution( props->wall_managed_, ui_->gallery_focus == id ? gallery_viewport : gallery::PreviewResolution{}, - stream_viewport), now, needs_sync); + stream_visible ? stream_viewport : gallery::PreviewResolution{}), + now, needs_sync); demand.version = 2; demand.width = resolution.width; demand.height = resolution.height; } - const bool acknowledged = - props->preview_ack_.load() == props->preview_request_ && - props->preview_accepted_.load(); - if (needs_sync || !demand.SameSettings(props->preview_requested_) || - (!acknowledged && now - props->preview_request_at_ > 5s)) { - demand.request_id = ++props->preview_request_; - if (!demand.request_id) demand.request_id = ++props->preview_request_; - const auto message = demand.ToJson().dump(); - if (SendReliableDataFrame(props->peer_, message.data(), - message.size(), - props->control_data_label_.c_str()) == 0) - props->preview_requested_ = demand; - props->preview_request_at_ = now; + if (props->wall_managed_ && !preview_ids.count(id) && !stream_visible) + demand.fps = 0; + gallery::PreviewBandwidthDemand bandwidth; + if (props->preview_bandwidth_supported_) { + demand.max_bitrate_bps = 0; + if (props->wall_managed_ && demand.fps > 0) { + bandwidth = { + gallery::PreviewBandwidthDemand::Estimate( + demand.width, demand.height, + demand.fps == 60 ? demand.frame_rate : demand.fps), + stream_visible || (visible && ui_->gallery_focus == id)}; + } } + bandwidth_demands.push_back(bandwidth); + pending_previews.push_back({props, demand, needs_sync}); } } } + const auto& allocations = ui_->gallery_bandwidth_allocator.Allocate( + bandwidth_demands, wall.stream_settings.bandwidth_mbps * 1'000'000); + for (size_t i = 0; i < pending_previews.size(); ++i) { + if (bandwidth_demands[i].desired_bps == 0) continue; + auto& demand = pending_previews[i].demand; + // Quantize down to avoid resending subscriptions for sub-kbit rounding. + const int bitrate = allocations[i] / 1000 * 1000; + demand.max_bitrate_bps = bitrate; + if (!bitrate) demand.fps = 0; + } + for (auto& pending : pending_previews) { + auto& props = pending.props; + auto& demand = pending.demand; + const bool acknowledged = + props->preview_ack_.load() == props->preview_request_ && + props->preview_accepted_.load(); + if (pending.needs_sync || !demand.SameSettings(props->preview_requested_) || + (!acknowledged && now - props->preview_request_at_ > 5s)) { + demand.request_id = ++props->preview_request_; + if (!demand.request_id) demand.request_id = ++props->preview_request_; + const auto message = demand.ToJson().dump(); + if (SendReliableDataFrame(props->peer_, message.data(), message.size(), + props->control_data_label_.c_str()) == 0) + props->preview_requested_ = demand; + props->preview_request_at_ = now; + } + } + // Reuse capacity without retaining disconnected sessions between refreshes. + pending_previews.clear(); std::erase_if(ui_->gallery_previews, [&](const auto& item) { return !preview_ids.count(item.first); }); diff --git a/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h b/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h index 562786e1..16df2fac 100644 --- a/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h +++ b/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h @@ -23,22 +23,31 @@ struct StreamSettings { {}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080}, {2560, 1440}, {3840, 2160}, {}}}; static constexpr std::array kFrameRates{0, 15, 30, 60}; + static constexpr std::array kBandwidthMbps{2, 5, 10, 20, 50, 100}; int resolution = 0; int frame_rate = 0; // 0 follows the session's existing 30/60 FPS preference. + int bandwidth_mbps = 20; - static std::optional FromIndices(int resolution, int fps) { + static std::optional FromIndices(int resolution, int fps, + int bandwidth = 3) { if (resolution < 0 || resolution >= int(kResolutions.size()) || - fps < 0 || fps >= int(kFrameRates.size())) + fps < 0 || fps >= int(kFrameRates.size()) || + bandwidth < 0 || bandwidth >= int(kBandwidthMbps.size())) return {}; - return StreamSettings{resolution, kFrameRates[fps]}; + return StreamSettings{resolution, kFrameRates[fps], kBandwidthMbps[bandwidth]}; } int FrameRateIndex() const { return int(std::find(kFrameRates.begin(), kFrameRates.end(), frame_rate) - kFrameRates.begin()); } + int BandwidthIndex() const { + return int(std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth_mbps) - + kBandwidthMbps.begin()); + } bool Valid() const { return resolution >= 0 && resolution < int(kResolutions.size()) && - FrameRateIndex() < int(kFrameRates.size()); + FrameRateIndex() < int(kFrameRates.size()) && + BandwidthIndex() < int(kBandwidthMbps.size()); } static StreamSettings FromJson(const nlohmann::json& json) { try { @@ -50,13 +59,19 @@ struct StreamSettings { fps < 0 || fps > 60) return {}; // Retired sparse presets migrate to the lowest current setting. StreamSettings result{int(resolution), fps == 1 || fps == 8 ? 15 : int(fps)}; + if (json.contains("bandwidth_mbps") && json.at("bandwidth_mbps").is_number_integer()) { + const auto bandwidth = json.at("bandwidth_mbps").get(); + if (std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth) != + kBandwidthMbps.end()) result.bandwidth_mbps = int(bandwidth); + } return result.Valid() ? result : StreamSettings{}; } catch (...) { return {}; } } nlohmann::json ToJson() const { - return {{"resolution", resolution}, {"frame_rate", frame_rate}}; + return {{"resolution", resolution}, {"frame_rate", frame_rate}, + {"bandwidth_mbps", bandwidth_mbps}}; } PreviewResolution Resolution(bool wall_managed, PreviewResolution wall_view, PreviewResolution stream_view) const { diff --git a/apps/desktop/src/gui/features/gallery/preview_bandwidth.h b/apps/desktop/src/gui/features/gallery/preview_bandwidth.h new file mode 100644 index 00000000..1557088e --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/preview_bandwidth.h @@ -0,0 +1,99 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-13 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PREVIEW_BANDWIDTH_H_ +#define _PREVIEW_BANDWIDTH_H_ + +#include +#include +#include +#include + +namespace crossdesk::gallery { +struct PreviewBandwidthDemand { + int desired_bps = 0; + bool foreground = false; + + bool operator==(const PreviewBandwidthDemand& other) const { + return desired_bps == other.desired_bps && foreground == other.foreground; + } + + static int Estimate(int width, int height, int fps) { + // A demand ceiling, not a throughput measurement. Include room for video + // repair traffic; the sender's congestion control may allocate less. + const int64_t pixels = width > 0 && height > 0 + ? int64_t(width) * height : 3840LL * 2160; + const int64_t estimate = pixels * std::clamp(fps, 1, 60) * 15 / 100; + return int(std::clamp((estimate + 49'999) / 50'000 * 50'000, + 150'000, 20'000'000)); + } +}; + +inline std::vector AllocatePreviewBandwidth( + const std::vector& demands, int budget_bps) { + std::vector result(demands.size(), 0); + int remaining = std::max(0, budget_bps); + int active = 0; + int64_t desired_total = 0; + for (const auto& demand : demands) { + active += demand.desired_bps > 0; + desired_total += std::max(0, demand.desired_bps); + } + if (!active || !remaining) return result; + if (desired_total <= remaining) { + for (std::size_t i = 0; i < demands.size(); ++i) + result[i] = std::max(0, demands[i].desired_bps); + return result; + } + // First preserve a small fair share for every visible device, then perform + // weighted max-min allocation. Satisfied devices return unused capacity. + const int baseline = std::min(100'000, remaining / active); + for (std::size_t i = 0; i < demands.size(); ++i) { + result[i] = std::min(std::max(0, demands[i].desired_bps), baseline); + remaining -= result[i]; + } + while (remaining > 0) { + int weights = 0; + for (std::size_t i = 0; i < demands.size(); ++i) + if (result[i] < demands[i].desired_bps) + weights += demands[i].foreground ? 4 : 1; + if (!weights) break; + const int available = remaining; + for (std::size_t i = 0; i < demands.size() && remaining > 0; ++i) { + if (result[i] >= demands[i].desired_bps) continue; + const int share = int(int64_t(available) * + (demands[i].foreground ? 4 : 1) / weights); + const int extra = std::min({remaining, demands[i].desired_bps - result[i], + std::max(1, share)}); + result[i] += extra; + remaining -= extra; + } + } + return result; +} + +// UI refreshes much more often than video demand changes. Keep the previous +// result while membership, dimensions, cadence, priority and budget are stable. +class PreviewBandwidthAllocator { + public: + const std::vector& Allocate( + const std::vector& demands, int budget_bps) { + if (budget_bps_ != budget_bps || demands_ != demands) { + demands_ = demands; + budget_bps_ = budget_bps; + allocations_ = AllocatePreviewBandwidth(demands, budget_bps); + } + return allocations_; + } + + private: + int budget_bps_ = -1; + std::vector demands_; + std::vector allocations_; +}; +} // namespace crossdesk::gallery + +#endif diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 3eb0fef9..927ffcb2 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -177,8 +177,13 @@ void PeerEventHandler::OnReceiveDataBuffer( subscription->frame_rate, static_cast( subscription->EncoderPreference())) == 0; - // Audio-only updates must not force a keyframe. The pause API requests - // its own keyframes on resume. + if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) != + subscription->max_bitrate_bps.value_or(0))) + accepted = UpdateVideoBitrateLimit( + runtime->peer_, remote_id.data(), remote_id.size(), + subscription->max_bitrate_bps.value_or(0)) == 0; + // Rebalancing bandwidth alone must not force every device to send a + // keyframe. The pause API requests its own keyframes on resume. if (accepted && (!previous || previous->width != subscription->width || previous->height != subscription->height)) accepted = UpdateVideoResolution( @@ -419,6 +424,7 @@ void PeerEventHandler::OnReceiveDataBuffer( props->preview_resolution_supported_ = remote_action.i.supports_deskhub_resolution; props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15; + props->preview_bandwidth_supported_ = remote_action.i.supports_deskhub_bandwidth; props->video_settings_supported_ = remote_action.i.supports_video_settings; if (first_advertisement && props->video_settings_supported_) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 4746444b..5d07224b 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -291,6 +291,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_fps15 = true; + remote_action.i.supports_deskhub_bandwidth = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, remote_action.i.display_list[i]); @@ -335,6 +336,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, props->preview_subscription_supported_ = false; props->preview_resolution_supported_ = false; props->preview_fps15_supported_ = false; + props->preview_bandwidth_supported_ = false; props->preview_needs_sync_ = true; props->preview_ack_ = 0; props->preview_accepted_ = false; @@ -440,6 +442,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_fps15 = true; + remote_action.i.supports_deskhub_bandwidth = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, remote_action.i.display_list[i]); diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 3689d89c..34f71181 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -78,6 +78,7 @@ struct RemoteSession { std::atomic preview_subscription_supported_{false}; std::atomic preview_resolution_supported_{false}; std::atomic preview_fps15_supported_{false}; + std::atomic preview_bandwidth_supported_{false}; std::atomic preview_needs_sync_{true}; std::atomic preview_ack_{0}; std::atomic preview_accepted_{false}; diff --git a/apps/desktop/src/gui/ui/gallery_settings.slint b/apps/desktop/src/gui/ui/gallery_settings.slint index 8eb21d2a..d19f16b5 100644 --- a/apps/desktop/src/gui/ui/gallery_settings.slint +++ b/apps/desktop/src/gui/ui/gallery_settings.slint @@ -4,10 +4,11 @@ import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.sl export component GallerySettings inherits Rectangle { in-out property resolution-index: 0; in-out property frame-rate-index: 0; + in-out property bandwidth-index: 3; in property error; SettingsGroup { y: 0px; width: parent.width; - title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 2; + title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3; SettingsRow { label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение"); label-reserve: self.width - 112px; @@ -21,16 +22,33 @@ export component GallerySettings inherits Rectangle { } SettingsRow { label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров"); - label-reserve: self.width - 112px; divider: false; + label-reserve: self.width - 112px; SettingsSelect { x: 136px; y: 4px; width: parent.width - 150px; current-index <=> root.frame-rate-index; model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"]; } } + SettingsRow { + label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео"); + label-reserve: self.width - 112px; divider: false; + SettingsSelect { + x: 136px; y: 4px; width: parent.width - 150px; + current-index <=> root.bandwidth-index; + model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"]; + } + } + } + Text { + x: 2px; y: 144px; width: parent.width - 4px; height: 70px; + text: GalleryStrings.tr( + "可见设备共享视频预算,优先保障展开画面;不可见设备暂停视频。音频与文件传输不计入,旧版设备不支持限额。瞬时速率可能超过预算。", + "Visible devices share the video budget, with priority for expanded views. Hidden video pauses. Audio, files and older devices are excluded; brief bursts may exceed the budget.", + "Бюджет видео делится между видимыми устройствами с приоритетом крупного вида. Скрытое видео приостановлено. Звук, файлы и старые устройства не учитываются; возможны краткие превышения."); + color: #718096; font-size: 11px; wrap: word-wrap; } if root.error != "": Text { - x: 2px; y: 108px; width: parent.width - 4px; height: 32px; + x: 2px; y: 224px; width: parent.width - 4px; height: 32px; text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap; } } diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index f1289ecf..85200006 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -315,8 +315,10 @@ export component MainWindow inherits Window { in-out property gallery-network-open: false; in property gallery-resolution-index: 0; in property gallery-frame-rate-index: 0; + in property gallery-bandwidth-index: 3; in-out property gallery-resolution-draft: 0; in-out property gallery-frame-rate-draft: 0; + in-out property gallery-bandwidth-draft: 3; in property gallery-settings-error; in property <[GalleryNetworkDevice]> gallery-network-devices; changed gallery-network-open => { @@ -572,6 +574,7 @@ export component MainWindow inherits Window { root.gallery-network-open = false; root.gallery-resolution-draft = root.gallery-resolution-index; root.gallery-frame-rate-draft = root.gallery-frame-rate-index; + root.gallery-bandwidth-draft = root.gallery-bandwidth-index; root.gallery-action("settings-open", ""); focus-sink.focus(); } @@ -2088,6 +2091,7 @@ export component MainWindow inherits Window { x: 18px; y: 108px; width: parent.width - 36px; height: 272px; resolution-index <=> root.gallery-resolution-draft; frame-rate-index <=> root.gallery-frame-rate-draft; + bandwidth-index <=> root.gallery-bandwidth-draft; error: root.gallery-settings-error; } Rectangle { x: 18px; y: 382px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } @@ -2096,7 +2100,7 @@ export component MainWindow inherits Window { if root.settings-tab == 1: SettingsButton { width: 110px; quiet: true; text: GalleryStrings.tr("恢复默认", "Reset defaults", "По умолчанию"); - clicked => { root.gallery-resolution-draft = 0; root.gallery-frame-rate-draft = 0; } + clicked => { root.gallery-resolution-draft = 0; root.gallery-frame-rate-draft = 0; root.gallery-bandwidth-draft = 3; } } Rectangle { horizontal-stretch: 1; } SettingsButton { diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index ac48aa4b..2818e2dd 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit ac48aa4b70770428ed59457595bcae528dd1da4e +Subproject commit 2818e2dd150b1416c2e6ae37ae2d7a899f5b74d4 diff --git a/docs/deskhub.md b/docs/deskhub.md index 27702ae4..72e8926c 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -29,7 +29,7 @@ ## 预览与兼容性 -自动分辨率模式下,桌面墙发起或接管的会话在能力协商后默认请求最长边不超过 320 像素的画面。点卡片展开后,根据视频显示区域的实际物理像素(包含 HiDPI 缩放)请求分辨率;全屏、退出全屏和调整窗口大小后会重新计算,尺寸稳定 200 毫秒后更新订阅。返回网格、隐藏桌面墙或切换到其他设备后恢复缩略图尺寸。同一会话同时在独立串流窗口显示时,按两个可见视频区域中较大的尺寸请求,避免小预览降低大窗口的清晰度。选择固定或原始分辨率后使用对应上限;恢复自动后重新跟随视图。普通连接页发起且未加入桌面墙管理的会话保留原有分辨率行为。 +自动分辨率模式下,桌面墙发起或接管的会话在能力协商后默认请求最长边不超过 320 像素的画面。点卡片展开后,根据视频显示区域的实际物理像素(包含 HiDPI 缩放)请求分辨率;全屏、退出全屏和调整窗口大小后会重新计算,尺寸稳定 200 毫秒后更新订阅。返回网格或切换到其他设备后恢复缩略图尺寸。不可见设备暂停视频;如果同一设备仍在可见的独立串流窗口显示,则继续传输。同一会话同时在独立串流窗口显示时,按两个可见视频区域中较大的尺寸请求,避免小预览降低大窗口的清晰度。选择固定或原始分辨率后使用对应上限;恢复自动后重新跟随视图。普通连接页发起且未加入桌面墙管理的会话保留原有分辨率行为。 被控端按连接分别在编码前缩放,完整保留桌面比例,不放大超过源画面的尺寸,也不修改被控端屏幕的系统分辨率。请求的宽高是上限,画质和网络自适应仍可降低实际发送尺寸。默认跟随每个会话原有的 30/60 帧设置;指定帧率仅覆盖桌面墙订阅,不修改会话的画质或原有帧率偏好。声音仍需明确开启。 @@ -39,15 +39,27 @@ 连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。 +### 视频带宽分配 + +“设置 → 桌面墙 → 视频总带宽”配置所有支持带宽协商的桌面墙会话共享的视频预算,默认 20 Mbps,可选 2、5、10、20、50、100 Mbps。保存立即应用,取消不生效,重启后保留;恢复默认后需要保存。它是视频的持续目标预算,包含发送端为视频纠错、重传及 RTP 开销预留的份额;音频、文件传输、普通连接及不支持带宽协商的旧版设备不计入,关键帧和切换期间的瞬时速率可能超过预算。 + +控制端根据请求的分辨率和帧率估算每台设备的需求上限。先为每台可见设备保留至多 100 Kbps 的基础份额(总预算不足时等分),再按权重分配剩余预算:展开画面或可见的独立串流窗口权重为缩略图的 4 倍。相同规格、相同优先级的设备基本均分;达到需求上限的设备让出剩余额度,不强制用满总预算。默认缩略图 320 × 320 上限、30 FPS 的需求预算为 500 Kbps。分配随连接、分页、分组、窗口可见性以及分辨率/帧率变化更新,独立串流窗口和墙内画面使用同一份设备预算。需求和总预算未变化时复用分配结果,不随 UI 刷新重复计算。 + +翻页、切换分组、隐藏或最小化桌面墙后,没有可见画面的墙内会话发送 `fps=0` 暂停视频,保留连接和主动开启的音频。重新可见后恢复当前需求并请求关键帧。单纯重新分配带宽不会触发所有设备同时发送关键帧,也不会重置 15 FPS 的发送节奏。 + +预算不代表测速结果。每条连接原有的拥塞控制继续工作,发送端使用网络可用带宽与设备预算中的较小值,并在设备的多个活动显示流之间再分配。视频探测速率受同一设备额度约束,以便网络恢复或画面展开后继续升速;额度下降会取消旧探测,暂停视频时关闭探测和填充包。只有声明 `supports_deskhub_bandwidth` 的对端会收到带宽字段;支持预览但不支持带宽协商的旧版仍能暂停不可见视频,更旧版本保持原行为。 + ### 协议与接口 可靠控制通道的 `deskhub_preview` v2 保留 v1 的 `request_id`、`fps`、`quality`、`frame_rate`、`preference`、`audio` 字段,新增必填 `width`、`height` 整数,单位为物理像素。两个值均为 0 表示恢复源尺寸上限,否则两个值均须在 2~16384 之间;非法或缺失的值不应用。`deskhub_preview_status` 回传请求的协议版本、请求 ID 与 `accepted`。请求 ID 跳过 0,支持 32 位回绕,重复或过期请求不会覆盖新设置。断开会话清除订阅和 15 FPS 协商能力,再连接会重新发送当前需求。15 FPS 扩展通过可选能力 `supports_deskhub_fps15` 协商,沿用 v1/v2 的字段结构,使用 `fps=15, frame_rate=30`,在编码前将实际输入限制为 15 FPS。 +带宽扩展沿用 v1/v2,增加可选整数 `max_bitrate_bps`(0~100000000,单位 bps),仅向声明 `supports_deskhub_bandwidth` 的对端发送。0 或缺省表示解除额外的视频预算限制;暂停视频仍使用 `fps=0`。非法字段拒绝整个订阅,断开后清除能力,重连重新协商。MiniRTC 的 `UpdateVideoBitrateLimit(peer, remote_id, remote_id_size, max_bitrate_bps)` 为指定连接的所有视频流设置合计预算,不改变音频与数据发送;返回 0 表示已进入拥塞控制队列,-1 表示参数、连接或传输不支持。 + MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, height)` 接口,返回 0 表示已接受,-1 表示参数、连接或传输类型不支持。它为指定控制端的所有显示流设置独立尺寸上限,切换时丢弃旧设置尚未发出的编码结果,并请求新关键帧;底层自适应也以这个上限作为有效源尺寸。 `SetVideoSendPaused(peer, remote_id, remote_id_size, paused)` 与 `SetAudioSendPaused(peer, remote_id, remote_id_size, paused)` 分别控制指定连接的所有视频/音频发送通道,`true` 暂停、`false` 恢复。重复设置不产生额外操作;返回 0 表示接受,-1 表示参数、连接或传输类型不支持。视频暂停阻止新帧进入编码,废弃旧编码结果,并在发送队列上清除待发视频/重传/纠错包,停止探测和填充;恢复请求新关键帧。音频暂停阻止 PCM 编码并清理待发音频,恢复时仅发送新音频。已交给网络的包无法撤回。API 保留连接、采集源、码率与画质设置以及其他媒体/数据通道,也不改变其他控制端的发送状态。 -桌面墙将 `fps=0` 和 `audio=false` 分别映射到上述 API;应用层仅保留低帧率节流,静音在 MiniRTC 内部执行。接收端仍保留静音门控及播放队列清理,以立即停止播放网络中已在途的音频。旧版 `audio_capture` 命令在支持新 API 的传输中也按连接静音,已有订阅的音频状态优先;不支持 API 的旧/Web 传输沿用采集开关回退。 +桌面墙将 `fps=0` 和 `audio=false` 分别映射到上述 API;应用层仅保留低帧率节流,不再使用 `1 bps` 代替视频暂停或在采集回调中过滤静音音频。接收端仍保留静音门控及播放队列清理,以立即停止播放网络中已在途的音频。旧版 `audio_capture` 命令在支持新 API 的传输中也按连接静音,已有订阅的音频状态优先;不支持 API 的旧/Web 传输沿用采集开关回退。 ## 批量操作 diff --git a/libs/wire/include/preview_subscription.h b/libs/wire/include/preview_subscription.h index e7d424af..8f487d7b 100644 --- a/libs/wire/include/preview_subscription.h +++ b/libs/wire/include/preview_subscription.h @@ -25,6 +25,9 @@ struct PreviewSubscription { int width = 0; int height = 0; int version = 1; + // Optional negotiated extension: total video budget for this peer, in bps. + // 0 restores normal congestion control; absent preserves legacy wire format. + std::optional max_bitrate_bps; static bool ValidResolution(int width, int height) { return (width == 0 && height == 0) || @@ -45,7 +48,8 @@ struct PreviewSubscription { frame_rate == other.frame_rate && EncoderPreference() == other.EncoderPreference() && audio == other.audio && width == other.width && - height == other.height && version == other.version; + height == other.height && version == other.version && + max_bitrate_bps == other.max_bitrate_bps; } int EncoderPreference() const { @@ -63,6 +67,7 @@ struct PreviewSubscription { json["width"] = width; json["height"] = height; } + if (max_bitrate_bps) json["max_bitrate_bps"] = *max_bitrate_bps; return json; } static std::optional Parse(const nlohmann::json &json) { @@ -108,6 +113,12 @@ struct PreviewSubscription { result.height = json.at("height").get(); if (!ValidResolution(result.width, result.height)) return {}; } + if (json.contains("max_bitrate_bps")) { + if (!json.at("max_bitrate_bps").is_number_integer()) return {}; + const auto bitrate = json.at("max_bitrate_bps").get(); + if (bitrate < 0 || bitrate > 100'000'000) return {}; + result.max_bitrate_bps = int(bitrate); + } return result; } catch (...) { return {}; diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index e30fbcde..4693b60a 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -140,6 +140,7 @@ struct HostInfo { bool supports_deskhub_preview; bool supports_deskhub_resolution; bool supports_deskhub_fps15; + bool supports_deskhub_bandwidth; }; struct ServiceStatus { diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 54b1350b..840795f4 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -29,6 +29,7 @@ void ResetHostInfo(HostInfo& info) { info.supports_deskhub_preview = false; info.supports_deskhub_resolution = false; info.supports_deskhub_fps15 = false; + info.supports_deskhub_bandwidth = false; } bool AllocateHostDisplays(HostInfo& info, std::size_t count) { @@ -225,7 +226,8 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { {"supports_video_settings", action.i.supports_video_settings}, {"supports_deskhub_preview", action.i.supports_deskhub_preview}, {"supports_deskhub_resolution", action.i.supports_deskhub_resolution}, - {"supports_deskhub_fps15", action.i.supports_deskhub_fps15}}; + {"supports_deskhub_fps15", action.i.supports_deskhub_fps15}, + {"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth}}; break; } case ControlType::invalid: @@ -424,6 +426,8 @@ bool RemoteAction::FromJson(const std::string& json_string, host_info_object.value("supports_deskhub_resolution", false); output.i.supports_deskhub_fps15 = host_info_object.value("supports_deskhub_fps15", false); + output.i.supports_deskhub_bandwidth = + host_info_object.value("supports_deskhub_bandwidth", false); const std::string host_name = host_info_object.at("host_name").get(); std::strncpy(output.i.host_name, host_name.c_str(),