mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 04:52:39 +08:00
[feat] add Sixel rendering to CLI
This commit is contained in:
@@ -32,7 +32,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
|
||||
|
||||
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
||||
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
|
||||
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
|
||||
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty / Sixel 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
|
||||
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
||||
@@ -150,7 +150,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
|
||||
|
||||
## 终端客户端 CLI
|
||||
|
||||
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
|
||||
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 或 Sixel 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
|
||||
|
||||
### 画面预览
|
||||
|
||||
@@ -202,7 +202,7 @@ xmake run crossdesk-cli 123456789
|
||||
|
||||
### 选择画面与连接参数
|
||||
|
||||
默认 `--renderer auto` 自动探测 Kitty 图形协议,未获支持时回退为 ANSI。macOS「终端」等普通终端可显式选择 `ansi`;Ghostty 等支持该协议的终端可选择 `kitty`。显式指定 `kitty` 但探测失败时会报错,此时改用 `ansi`。
|
||||
默认 `--renderer auto` 按 Kitty → Sixel → ANSI 的顺序选择。macOS「终端」等普通终端可显式选择 `ansi`;支持对应图形协议的终端可选择 `kitty` 或 `sixel`。显式像素模式探测失败时会报错,此时改用 `ansi`。Xshell 是否能显示像素图片取决于终端自身的协议支持,CLI 无法单方面补齐。
|
||||
|
||||
以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。
|
||||
|
||||
@@ -219,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
|
||||
|
||||
| 参数 | 用途 |
|
||||
| --- | --- |
|
||||
| `--renderer auto\|ansi\|kitty` | 自动选择、字符画面或像素画面;默认 `auto` |
|
||||
| `--renderer auto\|ansi\|kitty\|sixel` | 自动选择、字符画面或像素画面;默认 `auto` |
|
||||
| `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 |
|
||||
| `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 |
|
||||
| `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 |
|
||||
@@ -256,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
|
||||
|
||||
其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。
|
||||
|
||||
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
|
||||
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty / Sixel 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
|
||||
|
||||
[CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md)
|
||||
|
||||
|
||||
+5
-5
@@ -32,7 +32,7 @@ CrossDesk connects your computers from desktop, browser, mobile, and terminal cl
|
||||
|
||||
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
|
||||
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
|
||||
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
|
||||
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty / Sixel rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
|
||||
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
|
||||
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
|
||||
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
|
||||
@@ -150,7 +150,7 @@ Remote sessions support monitor switching, video quality and frame-rate controls
|
||||
|
||||
## Terminal client CLI
|
||||
|
||||
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
|
||||
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty or Sixel graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
|
||||
|
||||
### Display preview
|
||||
|
||||
@@ -202,7 +202,7 @@ For an existing checkout, run the dependency preparation and subsequent commands
|
||||
|
||||
### Rendering and connection options
|
||||
|
||||
The default `--renderer auto` probes for Kitty graphics support and falls back to ANSI. Use `ansi` explicitly in standard terminals such as macOS Terminal, or `kitty` in compatible terminals such as Ghostty. Explicit `kitty` mode reports an error if the probe fails; use `ansi` in that case.
|
||||
The default `--renderer auto` selects Kitty, then Sixel, then ANSI according to terminal capability replies. Use `ansi` in standard terminals such as macOS Terminal, or `kitty` / `sixel` in terminals supporting the corresponding protocol. Explicit pixel modes report an error if the probe fails; use `ansi` in that case. Sixel output alone does not guarantee Xshell compatibility: the terminal itself must support the protocol.
|
||||
|
||||
These examples use the command installed globally by npm. For a source build, replace `crossdesk-cli` with `xmake run crossdesk-cli` and run it from the repository root.
|
||||
|
||||
@@ -219,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--renderer auto\|ansi\|kitty` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
|
||||
| `--renderer auto\|ansi\|kitty\|sixel` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
|
||||
| `--zoom native` | Start in native detail mode; `--zoom 2` zooms to twice the full-desktop scale |
|
||||
| `--quality low\|balanced\|high\|ultra` | Request up to 360p / 720p / 1080p / 2160p; default: `high`. The host determines actual quality |
|
||||
| `--fps 8` | Fix the local output target and corresponding remote preview request; accepts 1–30. The default local target is 30, with adaptive remote requests when omitted |
|
||||
@@ -256,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
|
||||
|
||||
On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope.
|
||||
|
||||
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
|
||||
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty / Sixel terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
|
||||
|
||||
[CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese)
|
||||
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# CrossDesk CLI
|
||||
|
||||
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
|
||||
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty / Sixel 图形后端,以及 Pi 自定义 TUI 扩展。
|
||||
|
||||
## 画面预览
|
||||
|
||||
@@ -8,7 +8,7 @@ CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通
|
||||
| --- | --- |
|
||||
| <img src="../../docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 全景输出,包含只读状态栏与快捷键提示" /> | <img src="../../docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 像素全景输出,显示同一远程桌面" /> |
|
||||
|
||||
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 探测 Kitty 支持,失败时使用 ANSI;普通终端可指定 `--renderer ansi`,Ghostty 等支持 Kitty 图形协议的终端可指定 `--renderer kitty`。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
|
||||
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 依次选择 Kitty、Sixel、ANSI;普通终端可指定 `--renderer ansi`,支持对应协议的终端可指定 `--renderer kitty` 或 `--renderer sixel`。显式像素模式需要通过能力探测;增加 Sixel 输出不代表所有 Xshell 版本均可显示图片,终端本身也必须支持该协议。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
|
||||
|
||||
## 安装与连接
|
||||
|
||||
@@ -53,6 +53,7 @@ xmake run crossdesk-cli 123456789 --quality high --zoom native
|
||||
|
||||
```sh
|
||||
crossdesk-cli 123456789 --renderer kitty
|
||||
crossdesk-cli 123456789 --renderer sixel --fps 8
|
||||
crossdesk-cli 123456789 --renderer ansi --zoom native
|
||||
crossdesk-cli 123456789 --quality balanced --fps 8
|
||||
crossdesk-cli 123456789 --display 1
|
||||
@@ -82,4 +83,4 @@ crossdesk-cli 123456789 --display 1
|
||||
|
||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
||||
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
|
||||
|
||||
@@ -39,7 +39,7 @@ npx @chunkdash/crossdesk-cli@latest 123456789
|
||||
|
||||
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
|
||||
|
||||
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
|
||||
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty / Sixel 图形协议是可选增强,需终端自身支持并通过能力探测。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
|
||||
|
||||
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ crossdesk-cli 123456789 --server your-server.example --port 9099
|
||||
|
||||
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
|
||||
|
||||
## ANSI、Kitty 与 SSH
|
||||
## ANSI、Kitty、Sixel 与 SSH
|
||||
|
||||
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
|
||||
|
||||
@@ -63,7 +63,7 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina
|
||||
|
||||
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
|
||||
|
||||
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。
|
||||
auto 模式发送 Kitty 查询与主设备属性查询(DA1),等待最多 250ms;优先选择 Kitty,否则在 DA1 的能力参数中找到 `4` 时选择 Sixel,再回退为 ANSI。显式 `kitty` 或 `sixel` 模式探测失败时报错,不强制向未知终端输出图像。tmux/screen 中 auto 使用 ANSI,显式像素模式报不支持;不修改用户终端配置。
|
||||
|
||||
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
|
||||
|
||||
@@ -71,16 +71,24 @@ auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没
|
||||
|
||||
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
|
||||
|
||||
Sixel 使用终端字节流传图,无需额外图像库或临时图片文件:每六行像素按颜色平面编码,使用重复压缩。每帧最多 248 色(RGB 色立方体加灰阶),复杂画面超过约 1.9MB 时逐级降低到 43 色、16 色,必要时降到 4 级灰阶,以保留原有 2MiB 单消息上限。Sixel 有调色板量化损失;它没有 Kitty 的图像缩放 placement,因此依据字符像素尺寸限制画面行列,使图像保持在 1920×1080 内,缩放、细节模式和鼠标映射使用相同视口。每帧覆盖整个图像区域(包括黑边),最后不足六行的部分不会绘制到状态栏;通过保存/恢复 DECSDM 模式使图像从视频区光标位置开始,退出时恢复。未收到像素尺寸时按 10×20 字符估算,实际对齐仍取决于终端尺寸报告。高熵画面或慢速 SSH 建议先用 `--fps 8`。
|
||||
|
||||
```sh
|
||||
crossdesk-cli 123456789 --renderer sixel --fps 8
|
||||
```
|
||||
|
||||
CLI 支持 Sixel 不等于 Xshell 必然支持。查阅的 [Xshell 官方完整功能列表](https://www.netsarang.com/en/xshell-all-features/) 与 [更新记录](https://www.netsarang.com/en/xshell-update-history/) 尚未提供 Sixel 支持说明;以终端实际能力回应及显示测试为准,不能仅修改 `TERM` 就获得像素图像支持。无能力回应时可继续使用 `--renderer ansi --zoom native`。Sixel 协议与 DA1 能力位依据 [XTerm 控制序列文档](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Sixel-Graphics)。
|
||||
|
||||
```sh
|
||||
ssh -t your-server
|
||||
crossdesk-cli 123456789 --renderer ansi --fps 8
|
||||
```
|
||||
|
||||
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
|
||||
用户本地终端负责显示 SSH 字节流。ANSI、Kitty / Sixel over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
|
||||
|
||||
## 预览与背压
|
||||
|
||||
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
|
||||
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、Sixel、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
|
||||
|
||||
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
|
||||
|
||||
@@ -99,7 +107,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8
|
||||
|
||||
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
|
||||
|
||||
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI 和 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,两者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
|
||||
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI、Kitty 和 Sixel 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,三者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
|
||||
|
||||
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
|
||||
|
||||
@@ -169,7 +177,7 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
|
||||
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。
|
||||
- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。
|
||||
- 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。
|
||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
||||
|
||||
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
|
||||
|
||||
@@ -132,8 +132,16 @@
|
||||
- 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。
|
||||
- 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。
|
||||
|
||||
## Sixel 图形输出(2026-11-02)
|
||||
|
||||
- 新增 `--renderer sixel`;auto 按 Kitty → Sixel → ANSI 选择,Sixel 通过 DA1 能力参数 `4` 探测。显式像素模式没有有效回应或处于 tmux/screen 时拒绝启动;`doctor --json` 增加 `sixel=probe_on_view`,不主动探测。
|
||||
- macOS arm64 CLI debug 构建、帮助信息和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖能力回复解析、20,000 组随机尺寸的视口限制及重复计算一致性、7 组合成 NV12 图像(纯黑、纯白、渐变、彩色、部分六行、裁剪缩放和大视口)。
|
||||
- 工程外独立 Sixel 解码器验证完整像素覆盖、颜色量化误差、图像边界和光标保存/恢复;1920×1080 高熵样本触发 4 级灰阶回退,输出约 1.30MB,保持原尺寸且低于单消息上限。
|
||||
- 生产 CLI/终端 I/O 配合工程外假会话的 11 组 POSIX PTY 检查通过:Kitty 优先、Sixel 自动及显式选择、分片 DA1 回应、错误/无回应回退、显式模式失败、tmux/screen 限制、窗口变化和细节缩放;SSH 字节预算保持 1MiB/s,退出后 termios 与备用屏幕恢复,Sixel 路径发送 DECSDM 恢复命令。
|
||||
- 未连接真实远端,未进行 Xshell、其他 Sixel 终端或真实 SSH 显示验收,不保证任何 Xshell 版本支持该协议。额外测试源码、脚本、数据及产物均保存在工程外系统临时目录,未新增或修改工程内测试文件/用例。
|
||||
|
||||
## 实测边界
|
||||
|
||||
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
|
||||
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/Sixel/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
|
||||
|
||||
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
|
||||
|
||||
+30
-11
@@ -143,6 +143,7 @@ private:
|
||||
};
|
||||
struct Tty {
|
||||
int out;
|
||||
bool sixel = false;
|
||||
RawTerminal terminal;
|
||||
Tty(int input, int output) : out(output), terminal(input, output) {
|
||||
// Fall back to button/drag reporting if all-motion tracking is unsupported.
|
||||
@@ -153,6 +154,7 @@ struct Tty {
|
||||
~Tty() {
|
||||
// Cancel any incomplete string/control sequence before restoration.
|
||||
std::string end = "\030\033\\" + std::string(kKittyDelete) +
|
||||
(sixel ? "\033[?80r" : "") +
|
||||
"\033[0m\033[?1003l\033[?1002l\033[?1000l"
|
||||
"\033[?1006l\033[?1004l\033[?2004l\033["
|
||||
"?7h\033[?25h\033[?1049l";
|
||||
@@ -332,9 +334,9 @@ int View(SessionOptions options, const std::string &device,
|
||||
return 130;
|
||||
Tty tty(0, fd);
|
||||
Output output(fd);
|
||||
bool kitty = false;
|
||||
bool kitty = false, sixel = false;
|
||||
if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) {
|
||||
output.Put(std::string(kKittyQuery) + "\033[c");
|
||||
output.Put((renderer == "sixel" ? std::string{} : kKittyQuery) + "\033[c");
|
||||
std::string reply;
|
||||
auto until = Clock::now() + std::chrono::milliseconds(250);
|
||||
while (Clock::now() < until && !interrupted) {
|
||||
@@ -345,11 +347,21 @@ int View(SessionOptions options, const std::string &device,
|
||||
if (n > 0 && reply.size() < 8192)
|
||||
reply.append(b, n);
|
||||
}
|
||||
kitty = KittyReply(reply);
|
||||
kitty = renderer != "sixel" && KittyReply(reply);
|
||||
sixel = renderer != "kitty" && !kitty && SixelReply(reply);
|
||||
}
|
||||
if (renderer == "kitty" && !kitty)
|
||||
throw std::runtime_error(
|
||||
"Kitty probe failed or multiplexer active; use --renderer ansi");
|
||||
if (renderer == "sixel" && !sixel)
|
||||
throw std::runtime_error(
|
||||
"Sixel probe failed or multiplexer active; use --renderer ansi");
|
||||
if (sixel) {
|
||||
// DECSDM reset places graphics at the cursor. Save the user's setting
|
||||
// and restore it on exit, including interrupted image writes.
|
||||
output.Put("\033[?80s\033[?80l");
|
||||
tty.sixel = true;
|
||||
}
|
||||
// Both pixel frames and large true-colour character grids need local
|
||||
// throughput. Keep SSH conservative and retain the one-message/burst limits
|
||||
// and nonblocking writes so slow terminals still apply backpressure.
|
||||
@@ -520,10 +532,12 @@ int View(SessionOptions options, const std::string &device,
|
||||
auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect);
|
||||
v.detail = detail;
|
||||
v.native = input.native;
|
||||
v.pixel_mode = kitty;
|
||||
v.pixel_mode = kitty || sixel;
|
||||
v.zoom = input.zoom;
|
||||
v.center_x = input.center_x;
|
||||
v.center_y = input.center_y;
|
||||
if (sixel)
|
||||
v = SixelViewport(v);
|
||||
if (input.view_revision != view_revision) {
|
||||
view_revision = input.view_revision;
|
||||
redraw = true;
|
||||
@@ -591,6 +605,7 @@ int View(SessionOptions options, const std::string &device,
|
||||
bar.command_mode = input.command_mode;
|
||||
bar.fps = "FPS " + std::to_string(output_fps);
|
||||
bar.view = std::string(kitty ? "KITTY"
|
||||
: sixel ? "SIXEL"
|
||||
: detail == AnsiDetail::Quadrant ? "QUAD"
|
||||
: "HALF") +
|
||||
(input.native ? " | Detail x" : " | Fit x") +
|
||||
@@ -651,15 +666,15 @@ int View(SessionOptions options, const std::string &device,
|
||||
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.
|
||||
pending = v.pixel_mode ? Layout(*last_frame, v) : Convert(*last_frame, v);
|
||||
// Clear waiting text and image margins without erasing the status bars.
|
||||
std::string clear;
|
||||
if (waiting_visible)
|
||||
for (int row = 0; row < v.rows; ++row)
|
||||
clear += "\033[" + std::to_string(v.row_offset + row + 1) +
|
||||
";1H\033[0m\033[2K";
|
||||
if (shared_enabled) {
|
||||
const auto pixels = KittyPixels(*last_frame, v);
|
||||
const auto pixels = ImagePixels(*last_frame, v);
|
||||
if (shared_image.Assign(pixels.rgb.data(), pixels.rgb.size())) {
|
||||
output.Put(KittySharedUpload(shared_image.Name(), pixels.width,
|
||||
pixels.height, submitted % 2 != 0));
|
||||
@@ -673,7 +688,8 @@ int View(SessionOptions options, const std::string &device,
|
||||
if (fresh && !frame_started)
|
||||
frame_started = Clock::now();
|
||||
output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0)
|
||||
: Difference(*pending, displayed)));
|
||||
: sixel ? Sixel(*last_frame, v)
|
||||
: Difference(*pending, displayed)));
|
||||
waiting_visible = false;
|
||||
pending_frame = true;
|
||||
}
|
||||
@@ -729,7 +745,7 @@ int View(SessionOptions options, const std::string &device,
|
||||
{"reported", input.terminal_metrics.Known()},
|
||||
{"manual_aspect", cell_aspect > 0}}},
|
||||
{"submitted", submitted},
|
||||
{"renderer", kitty ? "kitty" : "ansi"},
|
||||
{"renderer", kitty ? "kitty" : sixel ? "sixel" : "ansi"},
|
||||
{"kitty_transport", !kitty ? "none"
|
||||
: shared_enabled ? "shared_memory"
|
||||
: "inline"},
|
||||
@@ -1011,7 +1027,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) {
|
||||
s.Disconnect();
|
||||
return interrupted ? 130 : 0;
|
||||
}
|
||||
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30]
|
||||
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty|sixel] [--fps 1..30]
|
||||
[--display 0..63] [--control] [--server host] [--port 9099]
|
||||
[--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native]
|
||||
[--cell-aspect auto|0.2..2]
|
||||
@@ -1028,6 +1044,7 @@ 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.
|
||||
Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers require a successful probe.
|
||||
--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.
|
||||
@@ -1064,6 +1081,7 @@ int main(int argc, char **argv) {
|
||||
{"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()},
|
||||
{"software_decode", true},
|
||||
{"kitty", "probe_on_view"},
|
||||
{"sixel", "probe_on_view"},
|
||||
{"audio_playback", false},
|
||||
{"bridge_version", 1}};
|
||||
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
||||
@@ -1142,7 +1160,8 @@ int main(int argc, char **argv) {
|
||||
throw std::invalid_argument("unsupported option");
|
||||
}
|
||||
}
|
||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty")
|
||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
||||
renderer != "sixel")
|
||||
throw std::invalid_argument("unknown renderer");
|
||||
if (command == "bridge" && !stdio)
|
||||
throw std::invalid_argument("bridge requires --stdio");
|
||||
|
||||
@@ -67,7 +67,12 @@ struct PixelFrame {
|
||||
int width = 0, height = 0;
|
||||
std::vector<uint8_t> rgb;
|
||||
};
|
||||
PixelFrame KittyPixels(const DecodedFrame &, const Viewport &);
|
||||
PixelFrame ImagePixels(const DecodedFrame &, const Viewport &);
|
||||
// Sixel has no placement scaling. Limit the cell viewport, not the bitmap,
|
||||
// so the displayed pixels and pointer mapping keep the same geometry.
|
||||
Viewport SixelViewport(Viewport);
|
||||
std::string Sixel(const DecodedFrame &, const Viewport &);
|
||||
bool SixelReply(const std::string &response);
|
||||
std::string KittySharedUpload(const std::string &name, int width, int height,
|
||||
bool alternate_buffer, bool query = false);
|
||||
std::string KittyPresent(const Viewport &, bool alternate_buffer);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace crossdesk::terminal {
|
||||
Viewport SixelViewport(Viewport v) {
|
||||
if (v.columns < 1 || v.columns > 240 || v.rows < 1 || v.rows > 100)
|
||||
throw std::invalid_argument("invalid Sixel viewport");
|
||||
const auto limit = [](int &cells, int &pixels, int fallback, int maximum) {
|
||||
const int original_cells = cells;
|
||||
if (pixels <= 0)
|
||||
pixels = cells * fallback;
|
||||
cells =
|
||||
std::max(1, std::min(cells, int(int64_t(maximum) * cells / pixels)));
|
||||
pixels = std::clamp(
|
||||
int((int64_t(pixels) * cells + original_cells / 2) / original_cells), 2,
|
||||
maximum);
|
||||
};
|
||||
limit(v.columns, v.pixel_width, 10, 1920);
|
||||
limit(v.rows, v.pixel_height, 20, 1080);
|
||||
v.pixel_mode = true;
|
||||
return v;
|
||||
}
|
||||
|
||||
bool SixelReply(const std::string &response) {
|
||||
for (size_t pos = 0;
|
||||
(pos = response.find("\033[?", pos)) != std::string::npos; ++pos) {
|
||||
const auto end = response.find('c', pos + 3);
|
||||
if (end == std::string::npos || end - pos > 1024)
|
||||
continue;
|
||||
const char *cur = response.data() + pos + 3;
|
||||
const char *last = response.data() + end;
|
||||
bool first = true, supported = false;
|
||||
while (cur < last) {
|
||||
unsigned value = 0;
|
||||
const auto parsed = std::from_chars(cur, last, value);
|
||||
if (parsed.ec != std::errc{})
|
||||
break;
|
||||
// The first parameter identifies the terminal, not a capability.
|
||||
supported = supported || (!first && value == 4);
|
||||
first = false;
|
||||
cur = parsed.ptr;
|
||||
if (cur == last) {
|
||||
if (supported)
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
if (*cur++ != ';')
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct Palette {
|
||||
int levels, grays;
|
||||
std::vector<std::array<int, 3>> colors;
|
||||
Palette(int cube_levels, int gray_levels)
|
||||
: levels(cube_levels), grays(gray_levels) {
|
||||
for (int r = 0; r < levels; ++r)
|
||||
for (int g = 0; g < levels; ++g)
|
||||
for (int b = 0; b < levels; ++b)
|
||||
colors.push_back({Level(r), Level(g), Level(b)});
|
||||
for (int i = 0; i < grays; ++i) {
|
||||
const int value = Gray(i);
|
||||
colors.push_back({value, value, value});
|
||||
}
|
||||
}
|
||||
int Level(int i) const { return (i * 255 + (levels - 1) / 2) / (levels - 1); }
|
||||
int Gray(int i) const { return (i * 255 + (grays - 1) / 2) / (grays - 1); }
|
||||
uint8_t Index(const uint8_t *rgb) const {
|
||||
const int gray =
|
||||
((int(rgb[0]) + rgb[1] + rgb[2]) * (grays - 1) + 382) / 765;
|
||||
const int gray_index = levels * levels * levels + gray;
|
||||
if (!levels)
|
||||
return uint8_t(gray_index);
|
||||
int index = 0, cube_error = 0, gray_error = 0;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int step = (rgb[c] * (levels - 1) + 127) / 255;
|
||||
index = index * levels + step;
|
||||
const int dc = rgb[c] - Level(step), dg = rgb[c] - Gray(gray);
|
||||
cube_error += dc * dc;
|
||||
gray_error += dg * dg;
|
||||
}
|
||||
return uint8_t(gray_error < cube_error ? gray_index : index);
|
||||
}
|
||||
};
|
||||
|
||||
void Run(std::string &out, uint8_t bits, int count) {
|
||||
if (count > 3) {
|
||||
out += '!';
|
||||
out += std::to_string(count);
|
||||
out += char('?' + bits);
|
||||
} else {
|
||||
out.append(size_t(count), char('?' + bits));
|
||||
}
|
||||
}
|
||||
|
||||
std::string Encode(const PixelFrame &pixels, const Viewport &v,
|
||||
const Palette &palette) {
|
||||
constexpr size_t budget = 1900000; // Leave room for terminal/UI commands.
|
||||
const int width = pixels.width, height = pixels.height;
|
||||
std::string out = "\0337\033[" + std::to_string(v.row_offset + 1) +
|
||||
";1H\033P0;1;0q\"1;1;" + std::to_string(width) + ";" +
|
||||
std::to_string(height);
|
||||
// Define a complete palette for every frame, including black margins.
|
||||
// Pb=1 preserves pixels outside the image in its final partial six-row band.
|
||||
for (size_t i = 0; i < palette.colors.size(); ++i) {
|
||||
out += '#' + std::to_string(i) + ";2";
|
||||
for (int c : palette.colors[i])
|
||||
out += ';' + std::to_string((c * 100 + 127) / 255);
|
||||
}
|
||||
std::vector<uint8_t> planes(palette.colors.size() * width);
|
||||
std::vector<int> ends(palette.colors.size());
|
||||
for (int y = 0; y < height; y += 6) {
|
||||
std::fill(planes.begin(), planes.end(), 0);
|
||||
std::fill(ends.begin(), ends.end(), 0);
|
||||
for (int dy = 0; dy < std::min(6, height - y); ++dy)
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const auto color =
|
||||
palette.Index(&pixels.rgb[(size_t(y + dy) * width + x) * 3]);
|
||||
planes[size_t(color) * width + x] |= uint8_t(1 << dy);
|
||||
ends[color] = std::max(ends[color], x + 1);
|
||||
}
|
||||
bool first = true;
|
||||
for (size_t color = 0; color < ends.size(); ++color) {
|
||||
if (!ends[color])
|
||||
continue;
|
||||
if (!first)
|
||||
out += '$'; // Return to the left edge without advancing the band.
|
||||
first = false;
|
||||
out += '#' + std::to_string(color);
|
||||
const auto *plane = &planes[color * width];
|
||||
for (int x = 0; x < ends[color];) {
|
||||
int end = x + 1;
|
||||
while (end < ends[color] && plane[end] == plane[x])
|
||||
++end;
|
||||
Run(out, plane[x], end - x);
|
||||
x = end;
|
||||
}
|
||||
if (out.size() > budget)
|
||||
return {};
|
||||
}
|
||||
if (y + 6 < height)
|
||||
out += '-';
|
||||
}
|
||||
out += "\033\\\0338";
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string Sixel(const DecodedFrame &frame, const Viewport &viewport) {
|
||||
const auto v = SixelViewport(viewport);
|
||||
const auto pixels = ImagePixels(frame, v);
|
||||
// Sixel cannot scale a smaller bitmap to a cell placement. Reduce color
|
||||
// detail on high-entropy frames instead of shrinking or misplacing them.
|
||||
for (const auto &sizes :
|
||||
{std::pair{6, 32}, std::pair{3, 16}, std::pair{2, 8}, std::pair{0, 4}}) {
|
||||
auto out = Encode(pixels, v, Palette(sizes.first, sizes.second));
|
||||
if (!out.empty())
|
||||
return out;
|
||||
}
|
||||
// Four planes at 1920 x 1080 always fit the budget above.
|
||||
throw std::runtime_error("Sixel output budget exceeded");
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -543,7 +543,7 @@ static std::string Base64(const std::vector<uint8_t> &data) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) {
|
||||
PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) {
|
||||
auto v = requested;
|
||||
v.pixel_mode = true;
|
||||
Validate(f, v);
|
||||
|
||||
Reference in New Issue
Block a user