[feat] allocate DeskHub video bandwidth automatically

This commit is contained in:
dijunkun
2026-10-13 15:39:50 +08:00
parent de16eb3b92
commit 4dfd8c3005
13 changed files with 270 additions and 43 deletions
@@ -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<std::string, Preview> gallery_previews;
struct PendingPreview {
std::shared_ptr<RemoteSession> props;
PreviewSubscription demand;
bool needs_sync = false;
};
std::vector<PendingPreview> gallery_pending_previews;
std::vector<gallery::PreviewBandwidthDemand> 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);
});
@@ -23,22 +23,31 @@ struct StreamSettings {
{}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080},
{2560, 1440}, {3840, 2160}, {}}};
static constexpr std::array<int, 4> kFrameRates{0, 15, 30, 60};
static constexpr std::array<int, 6> 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<StreamSettings> FromIndices(int resolution, int fps) {
static std::optional<StreamSettings> 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<int64_t>();
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 {
@@ -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 <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
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<int64_t>((estimate + 49'999) / 50'000 * 50'000,
150'000, 20'000'000));
}
};
inline std::vector<int> AllocatePreviewBandwidth(
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
std::vector<int> 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<int>& Allocate(
const std::vector<PreviewBandwidthDemand>& 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<PreviewBandwidthDemand> demands_;
std::vector<int> allocations_;
};
} // namespace crossdesk::gallery
#endif
@@ -177,8 +177,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
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_) {
@@ -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]);
@@ -78,6 +78,7 @@ struct RemoteSession {
std::atomic<bool> preview_subscription_supported_{false};
std::atomic<bool> preview_resolution_supported_{false};
std::atomic<bool> preview_fps15_supported_{false};
std::atomic<bool> preview_bandwidth_supported_{false};
std::atomic<bool> preview_needs_sync_{true};
std::atomic<uint32_t> preview_ack_{0};
std::atomic<bool> preview_accepted_{false};
+21 -3
View File
@@ -4,10 +4,11 @@ import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.sl
export component GallerySettings inherits Rectangle {
in-out property <int> resolution-index: 0;
in-out property <int> frame-rate-index: 0;
in-out property <int> bandwidth-index: 3;
in property <string> 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;
}
}
+5 -1
View File
@@ -315,8 +315,10 @@ export component MainWindow inherits Window {
in-out property <bool> gallery-network-open: false;
in property <int> gallery-resolution-index: 0;
in property <int> gallery-frame-rate-index: 0;
in property <int> gallery-bandwidth-index: 3;
in-out property <int> gallery-resolution-draft: 0;
in-out property <int> gallery-frame-rate-draft: 0;
in-out property <int> gallery-bandwidth-draft: 3;
in property <string> 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 {