From d687c0e2ea5ea4ad169fc3fb0b64f2968269a5c7 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 30 Oct 2026 23:32:16 +0800 Subject: [PATCH] [perf] adapt CLI preview rate and improve frame pacing --- apps/cli/docs/terminal-client.md | 10 +- apps/cli/docs/validation.md | 14 ++- apps/cli/main.cpp | 123 +++++++++++++++++-- libs/controller/include/controller_session.h | 2 + 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index 80879634..ad761446 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -78,7 +78,13 @@ crossdesk-cli 123456789 --renderer ansi --fps 8 ## 预览与背压 -`--fps 1..30` 是本地输出目标。未显式指定时,共享内存探测成功默认使用 30 FPS;ANSI、内联 Kitty 和 bridge 默认仍为 8 FPS。自动选择的共享内存路径运行中退回内联时,目标也恢复为 8;显式设置的 `--fps` 始终保留。对端声明 PreviewSubscription 时,目标不超过 15 映射到合法 1/8/15 FPS(15 需要对应能力);超过 15 则请求项目已有的交互模式:协议 `fps=60` 是模式标记,编码设置 `frame_rate=30` 才是帧率,不再把 `--fps 30` 限制为 15 FPS。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求: +`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。 + +未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。 + +本地调度只在有新帧或需要重绘时消耗刷新时隙;短暂迟到保持原刷新节奏,长时间阻塞后跳过旧时隙,不连续补画过时帧。目标帧率不代表终端实际显示帧率。 + +对端声明 PreviewSubscription 时,预览请求不超过 15 映射到合法 1/8/15 FPS(15 需要对应能力);超过 15 则请求项目已有的交互模式:协议 `fps=60` 是模式标记,编码设置 `frame_rate=30` 才是帧率。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求: | 档位 | 分辨率上限 | 质量枚举 | 视频预算 | |---|---|---|---| @@ -95,7 +101,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8 测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。 -统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS,以及实际 `renderer` 和 `output_bytes_per_second` 预算。`kitty_transport=shared_memory/inline/none` 标明生效路径,`target_fps` 是当前目标,`frame_processing_ms` 是平均本地布局/转换/传输准备耗时,`shared_ack_ms` 是平均上传命令写完到解析成功确认的耗时,`shared_fallbacks` 记录运行时回退次数。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧显示命令完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。 +统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS,以及实际 `renderer` 和 `output_bytes_per_second` 预算。`kitty_transport=shared_memory/inline/none` 标明生效路径,`target_fps` 是本地目标,`preview_fps` 是最近请求的远端预览目标(并非已生效或实测帧率),`adaptive_preview` 表示是否开启自动调节,`preview_adjustments` 记录切档次数;每次切档另记录 `preview_fps` 事件。`frame_service_ms` 是最近完整帧处理与输出耗时的平滑估计,`frame_processing_ms` 是平均本地布局/转换/传输准备耗时,`shared_ack_ms` 是平均上传命令写完到解析成功确认的耗时,`shared_fallbacks` 记录运行时回退次数。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧显示命令完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。 ## 输入与退出 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md index e18f58b2..1ab68d35 100644 --- a/apps/cli/docs/validation.md +++ b/apps/cli/docs/validation.md @@ -87,7 +87,7 @@ - ANSI 转义序列模拟器验证最终屏幕一致,覆盖全部四象限字形、8/24/256 色、纯背景及色板复用、首尾单元、局部修改、视口/偏移变化和可独立绘制的 bridge 字符行。未向工程新增测试文件或用例。 - 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 24 次转换:平滑画面约 6.26 → 4.83 ms/帧,随机亮度噪声约 7.75 → 5.72 ms/帧。保留面积滤波、低对比合并及边界连续性约束。 - 240×100 真彩色合成网格、每项 100 次输出生成:整帧约 2.03 → 1.43 ms;120×50 单元的局部修改约 1.02 → 0.36 ms,输出 461,743 → 231,026 字节;2×3 单元的光标修改约 0.072 → 0.013 ms,输出 27,763 → 262 字节。纯背景色块整帧输出 875,352 → 449,826 字节。 -- 这些结果衡量转换、编码及输出量,不包含实际终端绘制或网络传输。整帧高色彩变化图案仍需约 923 KB,仍受 1 MiB/s 输出预算限制;不能将转换耗时的倒数作为远程显示帧率。默认本地目标为 8 FPS,`--fps 15` 可请求更高目标,预览订阅还需要对端支持 15 FPS。 +- 这些结果衡量转换、编码及输出量,不包含实际终端绘制或网络传输。整帧高色彩变化图案仍需约 923 KB,仍受 1 MiB/s 输出预算限制;不能将转换耗时的倒数作为远程显示帧率。当时默认本地目标为 8 FPS,后续默认策略见“默认 30 FPS 与供帧自适应”。 - 额外检查代码、模拟器、基准和产物均位于工程外临时目录;未进行跨平台终端或真实远端帧率验收。 ## Kitty 帧间黑屏与本地吞吐修复(2026-10-30) @@ -104,7 +104,7 @@ ## 本地 Kitty 共享内存与 30 FPS 目标(2026-10-30) - 本地 POSIX Kitty 增加共享内存能力探测、唯一名称、0600 权限、上传确认和 500ms 超时回退。单个待确认共享缓冲区不超过 1920×1080×3 字节;确认前保留旧画面,退出释放未确认对象。Windows 暂保留内联路径,SSH 不发共享内存探测。 -- 共享内存可用且未显式指定 `--fps` 时默认目标 30;自动回退内联恢复 8,显式帧率保持不变。目标大于 15 使用现有预览协议的交互模式 `fps=60, frame_rate=30`;没有新增协议取值或修改被控端。 +- 当时共享内存可用且未显式指定 `--fps` 时默认目标 30;自动回退内联恢复 8,显式帧率保持不变。后续默认策略见“默认 30 FPS 与供帧自适应”。目标大于 15 使用现有预览协议的交互模式 `fps=60, frame_rate=30`;没有新增协议取值或修改被控端。 - 工程外 ASan/UBSan 检查通过:110 组随机帧/视口/宽高比/缩放/细节模式,整数面积滤波相对原浮点参考的每通道最大误差为 1/255;内联 Kitty 和 ANSI 保持原输出。覆盖共享内存权限、读取内容、命名、容量限制、双方 unlink、分片确认及非法/溢出/重复 ID 回应。 - 使用实际 release CLI 对象代码、1920×1080 合成噪声 NV12 连续回调、1568×1088 报告视口的 PTY 测试:内联完整接收约 4.06 FPS,共享内存约 29.04 FPS;平均本地处理约 166.6 → 12.3 ms,终端字节流约 1,703,174 → 188 字节/帧。共享内存仍传输完整 RGB,并非总数据量只有 188 字节;数据复制、终端绘制和实际网络另有成本。 - 显式 8 FPS、探测不支持/静默、第三帧上传报错/丢失确认、80ms 延迟确认、SSH 禁用探测均通过。回退前不显示未确认帧,运行时回退仅触发一次,并正确恢复自动目标和协议请求;所有共享对象均清理。第二帧上传后立即退出时,首帧仍是已提交画面,未确认对象及两个图像 ID 均清理。 @@ -112,6 +112,16 @@ - 用户在本机 Ghostty 运行新版 release 后反馈“明显流畅了”。额外测试源码、数据、构建夹具和产物均位于系统临时目录,未在工程或子模块新增测试。 - 该次实机日志确认 `kitty_transport=shared_memory`、目标 30、预览请求已接受、运行时回退 0 次。会话约 13.61s 完整提交 273 帧,平均提交约 20.06 FPS(包含约 1.49s 首帧启动等待);平均本地处理约 10.87ms、共享内存确认约 0.42ms。该日志记录提交速率,不测量终端 GPU 呈现频率。 +## 默认 30 FPS 与供帧自适应(2026-10-30) + +- 交互 CLI 默认本地目标统一为 30 FPS,Kitty 共享内存回退不再改写该目标;远端请求独立在 8/15/30 FPS 档位间自适应,15 需要对端能力。显式 `--fps` 保留固定请求,bridge 默认仍为 8。 +- macOS arm64 debug/release 构建、release `doctor --json` 和帮助输出检查通过。工程外 ASan/UBSan 验证了升降档阈值、迟滞、恢复、15 FPS 能力缺失、显式帧率、源帧稀疏、请求拒绝以及估计重置。 +- 使用真实 release CLI 对象代码、支持预览确认和动态供帧的假传输、1920×1080 合成 NV12 与 1568×1088 像素视口进行 PTY 验证。固定 30 FPS 源的共享内存路径完整接收约 30 FPS,未触发自动降档;不再在没有新帧时提前消耗刷新时隙。 +- 高熵内联路径实际约 4 FPS 时,自动请求由 30 降为 8,本地目标保持 30;显式 `--fps 30` 在同条件下仍约 4 FPS,远端请求不变。上传报错/超时和探测静默均保持本地 30 目标,随后按完整帧耗时调节供帧。 +- 90ms 上传确认延迟使实际接收约 9 FPS,远端请求自动调整为 15;缺少 15 FPS 能力时不发送该档位。200ms 延迟后恢复正常确认,验证远端请求按 30 → 8 → 30 恢复。源仅提供 2 FPS、对端拒绝预览时均未误触发自动降档。 +- SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。 +- 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。 + ## 实测边界 本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index 2db91182..7f8ca230 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -84,6 +84,63 @@ private: double credit_ = 1048576; Clock::time_point last_; }; +// Measure occupied frame time, not the interval between incoming frames: a +// slow source or a quiet desktop must not trap the next preview at a low rate. +class AdaptivePreview { +public: + AdaptivePreview(int fps, bool enabled) : fps_(fps), enabled_(enabled) {} + void Reset() { + service_ms_ = 0; + samples_ = 0; + candidate_ = fps_; + candidate_since_.reset(); + } + std::optional Observe(double ms, Clock::time_point now, bool fps15, + bool accepted) { + service_ms_ = samples_++ ? service_ms_ + .2 * (ms - service_ms_) : ms; + if (!enabled_ || !accepted) { + candidate_since_.reset(); + return {}; + } + const double capacity = 1000 / std::max(.001, service_ms_); + int next = fps_; + // Request the next tier above sustainable output, leaving headroom for + // fresh frames. Separate up/down thresholds prevent boundary oscillation. + if (fps_ > 8 && capacity <= 6.4) + next = 8; + else if (fps_ == 30 && fps15 && capacity <= 12) + next = 15; + else if (fps_ < 30 && capacity >= 13.5) + next = 30; + else if (fps_ == 8 && capacity >= 7.2) + next = fps15 ? 15 : 30; + if (next == fps_) { + candidate_since_.reset(); + return {}; + } + if (next != candidate_ || !candidate_since_) { + candidate_ = next; + candidate_since_ = now; + } + const auto hold = std::chrono::seconds(next < fps_ ? 2 : 4); + if (samples_ < 8 || now - *candidate_since_ < hold) + return {}; + fps_ = next; + ++adjustments_; + candidate_since_.reset(); + return fps_; + } + int Fps() const { return fps_; } + double ServiceMs() const { return service_ms_; } + uint64_t Adjustments() const { return adjustments_; } + +private: + int fps_, candidate_ = 0; + bool enabled_; + double service_ms_ = 0; + uint64_t samples_ = 0, adjustments_ = 0; + std::optional candidate_since_; +}; struct Tty { int out; RawTerminal terminal; @@ -326,9 +383,11 @@ int View(SessionOptions options, const std::string &device, } shared_image.Reset(); } - bool automatic_fast_fps = shared_enabled && !fps_explicit; - if (automatic_fast_fps) + if (!fps_explicit) options.fps = 30; + AdaptivePreview preview(options.fps, !fps_explicit); + const auto frame_interval = std::chrono::duration_cast( + std::chrono::duration(1.0 / options.fps)); ControllerSession s; Input input; input.control = control; @@ -339,6 +398,7 @@ int View(SessionOptions options, const std::string &device, s.SetControl(control); std::optional displayed, pending; bool pending_frame = false; + std::optional frame_started; bool shared_upload = false; std::optional shared_deadline; std::string pending_clear; @@ -362,11 +422,26 @@ int View(SessionOptions options, const std::string &device, shared_deadline.reset(); shared_image.Reset(); ++shared_fallbacks; - if (automatic_fast_fps) { - automatic_fast_fps = false; - options.fps = 8; - s.SetPreview(options.fps, options.preview_width, options.preview_height, + frame_started.reset(); + preview.Reset(); + }; + const auto complete_sample = [&] { + if (!frame_started) + return; + const auto now = Clock::now(); + const double ms = + std::chrono::duration(now - *frame_started).count(); + frame_started.reset(); + if (auto fps = preview.Observe(ms, now, s.SupportsPreviewFps15(), + s.Connected() && s.PreviewAccepted())) { + s.SetPreview(*fps, options.preview_width, options.preview_height, options.quality, options.max_bitrate_bps); + std::cerr << json{{"event", "preview_fps"}, + {"target_fps", options.fps}, + {"requested_fps", *fps}, + {"frame_service_ms", preview.ServiceMs()}} + .dump() + << "\n"; } }; const auto pump_output = [&] { @@ -379,6 +454,7 @@ int View(SessionOptions options, const std::string &device, pending.reset(); pending_frame = false; ++submitted; + complete_sample(); if (!first_output_at) { first_output_at = Clock::now(); status_tick = *first_output_at; @@ -447,6 +523,7 @@ int View(SessionOptions options, const std::string &device, if (input.view_revision != view_revision) { view_revision = input.view_revision; redraw = true; + preview.Reset(); } const bool grid_resized = v.columns != last_view.columns || v.rows != last_view.rows; @@ -460,6 +537,7 @@ int View(SessionOptions options, const std::string &device, last_view = v; last_status_rows = status_rows; redraw = true; + preview.Reset(); status_tick = now; if (grid_resized) size_query_tick = now; @@ -535,10 +613,11 @@ int View(SessionOptions options, const std::string &device, status_tick = now + std::chrono::seconds(1); } if (output.Idle() && !shared_upload && now >= next) { - next = now + std::chrono::milliseconds(1000 / options.fps); bool fresh = false; if (auto frame = s.TakeFrame(last_frame ? &*last_frame : nullptr); frame && frame->generation == s.Generation()) { + if (!last_frame || last_frame->generation != frame->generation) + preview.Reset(); last_frame = std::move(frame); fresh = true; if (!first_frame_at) @@ -548,10 +627,17 @@ int View(SessionOptions options, const std::string &device, last_frame.reset(); if (last_frame && (fresh || redraw)) { const auto render_started = Clock::now(); - if (fresh) + // Keep cadence when only slightly late, skip missed slots after a + // stall, and never consume a slot while waiting for a source frame. + next = render_started > next + frame_interval + ? render_started + frame_interval + : next + frame_interval; + if (fresh) { + frame_started = render_started; queue_ms = std::chrono::duration( now - last_frame->queued) .count(); + } pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v); // Clear waiting text and Kitty margins without erasing the status bars. std::string clear; @@ -571,6 +657,8 @@ int View(SessionOptions options, const std::string &device, } } if (!shared_upload) { + if (fresh && !frame_started) + frame_started = Clock::now(); output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0) : Difference(*pending, displayed))); waiting_visible = false; @@ -585,10 +673,16 @@ int View(SessionOptions options, const std::string &device, displayed = std::move(pending); pending.reset(); pending_frame = false; + complete_sample(); } } } - output.Wait(0); + const auto wait_ms = std::chrono::ceil( + next - Clock::now()) + .count(); + output.Wait(0, output.Idle() && !shared_upload && wait_ms > 0 + ? int(std::min(5, wait_ms)) + : 5); } auto final = s.Status(); s.Disconnect(); @@ -627,6 +721,10 @@ int View(SessionOptions options, const std::string &device, : shared_enabled ? "shared_memory" : "inline"}, {"target_fps", options.fps}, + {"adaptive_preview", !fps_explicit}, + {"preview_fps", preview.Fps()}, + {"preview_adjustments", preview.Adjustments()}, + {"frame_service_ms", preview.ServiceMs()}, {"frame_processing_ms", rendered ? render_ms / rendered : 0}, {"shared_ack_ms", shared_acks ? shared_ack_ms / shared_acks : 0}, {"shared_fallbacks", shared_fallbacks}, @@ -917,9 +1015,10 @@ Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160. Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate. Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale. ---fps controls local output. Local Kitty shared memory defaults to 30; other paths default to 8. -Targets above 15 use the negotiated interactive 30 FPS stream; lower targets use 1/8/15 FPS previews. -bridge: private NDJSON v1, credentials on a separate inherited descriptor. +--fps controls local output (default 30). Without it, remote preview adapts to measured output speed. +Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged. +Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS. +bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor. No IP direct connection, audio playback, clipboard or file transfer support. )"; } // namespace diff --git a/libs/controller/include/controller_session.h b/libs/controller/include/controller_session.h index 8f7fb57d..a6a868e6 100644 --- a/libs/controller/include/controller_session.h +++ b/libs/controller/include/controller_session.h @@ -68,6 +68,8 @@ public: std::optional TakeFrame(DecodedFrame *recycle = nullptr); nlohmann::json Status() const; bool Connected() const { return state_ == "connected"; } + bool PreviewAccepted() const { return preview_accepted_; } + bool SupportsPreviewFps15() const { return fps15_; } bool Finished() const { return state_ == "failed" || state_ == "closed"; } uint64_t Generation() const { return generation_; }