glasscast
by Edint386
README.md
# glasscast
把电脑屏幕投到千问 AI 眼镜 S1,用语音开始和停止。适合瞄一眼终端、构建进度或文档,不适合看视频、玩游戏。
> 非官方个人项目,与阿里巴巴、千问团队和千问 AI 硬件开放平台没有任何关联。
## 它怎么工作
眼镜没有给第三方开放视频流,所以 glasscast 用连续换图来代替视频。一次投屏分三段:
1. **电脑截屏。** `glasscast serve` 不停截屏,按推荐配置缩到 480×218,压成约 2.6 KB 的灰度 JPEG。它只保留最新一帧,放在你自己的公网 HTTPS 域名下。
2. **眼镜取图。** 眼镜上的页面不停换图片地址,比如 `…/screen.jpg/0101120000_1.jpg`、`…/0101120000_2.jpg`。地址一变,手机 App 就下载一次,拿到的总是最新一帧,再经蓝牙传给眼镜解码显示。
3. **语音控制。** 你对眼镜说「开始投屏」,千问平台的大模型调用 glasscast 的 MCP 工具,再打开投屏页面。MCP 只管开始、停止和查询,不参与逐帧刷新。
一张图从手机下载到眼镜显示,固定要约 0.65 秒,和图片是 2.5 KB 还是 4 KB 关系不大。这决定了这条链路的速度上限。
## 实际效果
下面是在 S1 真机上测到的结果:
| | 普通模式(默认) | 流畅模式 |
|---|---|---|
| 速度 | 约每秒 1 帧 | 最高约每秒 1.5 帧 |
| 眼镜页面 | `cast-image` | `cast-emit` |
| 特点 | 最稳 | 稍快,档位太高时会卡住 |
- 普通模式延迟约 1~2 秒,流畅模式没有单独测过。
- 单色绿屏,没有声音,也不能在眼镜上操作电脑。
- 画面在视野左上角,页面里改不了。整屏缩小后小字看不清,看代码时建议只投一块区域(`GLASSCAST_REGION`)。
- 只在 macOS 上测过。
## 流畅模式的实现思路
流畅模式要同时做到两件事:换图比每秒一次快,并且每张图都来得及显示。
### 普通模式为什么快不起来
普通模式用 `<tick>` 换图。页面里所有 tick 共用一个 1 秒定时器,所以最快每秒 1 帧。我们也试过用 `<frame-set>` 帧合集当定时器,模拟器里能跑,但平台保存时的校验(URI-001)不放行,放不到真机上。
### 用事件倒数当定时器
页面可以给自己发 `emit://` 事件。emit 是异步的,事件要等下一轮消息循环才送达,真机上每一步约 13 毫秒。收到事件后再发一个,页面就有了一个比 tick 快得多的节拍。
难点在于节拍不能写进 `data`。每写一次 `data` 就刷新一次绑定,而绑定一刷新,还没加载完的 `<image>` 就要从头加载。早期版本每一圈都连写两三次 `data`,真机上每张图被请求 3~6 次,最后只显示出一张图。
所以计数放在事件参数里,只有换图时才写 `data`。页面的核心就是下面这些(完整代码见 [`pages/cast-emit/template.xml`](pages/cast-emit/template.xml)):
```xml
<data s="0" g="0" m="75"/>
<!-- 打开页面:写入每轮的步数 m,开始倒数 -->
<lifecycle-event a:if="{{hops != 0}}" on-load="data://update?m={{hops * 3}}" on-new-intent="data://update?m={{hops * 3}}"/>
<lifecycle-event on-load="emit://event/d10?n=10"/>
<!-- 倒数:d1 到 d9 共九条规则,动作相同,都是把 n 减一再发出去 -->
<event match="event://d1" action="emit://event/d{{e:n - 1}}?n={{e:n - 1}}"/>
<event match="event://d9" action="emit://event/d{{e:n - 1}}?n={{e:n - 1}}"/>
<!-- 倒到 0:写一次换图编号 g,再从 m 开始下一轮 -->
<event match="event://d0?" action="data://update?g={{d:g + 1}}"/>
<event match="event://d0?" action="emit://event/d{{d:m}}?n={{d:m}}"/>
<image a:if="{{d:s != 0}}" src="{{url}}{{structuredContent.url}}/{{d:s}}_{{d:g}}.jpg" width="640" height="290" image-fit="stretch"/>
```
以档位 4 为例,`m` 是 36,一张图的过程是:
1. 倒数到 0,`g` 加一。这是一轮里唯一一次写 `data`,图片地址随之变成 `…/<会话>_<g>.jpg`。
2. 手机下载最新一帧,经蓝牙传给眼镜,眼镜解码显示。
3. 这段时间里页面继续倒数 36、35……1,全程不写 `data`,图片不会被打断。
4. 又倒到 0,回到第 1 步。
还有两个写法上的细节:
- **九条规则就能倒任意步数。** 每页不同的 `match` 数量有限(最多 32 个),而 `match` 是前缀匹配:`event://d3` 能命中 `d3`、`d30`、`d36`。所以 `d1`~`d9` 按 `n` 的首位数字分流,只有 `n` 到 0 时才命中 `d0?`。
- **地址每次都不一样。** `data` 里的数超过 2147483647 会被卡住,时间戳存不进去。所以 `s` 只取打开页面时的月日时分秒,`g` 是从 0 开始的小计数器,两者拼成的地址不会溢出,每次打开页面也不会命中缓存。地址还必须以 `.jpg` 结尾,用 `?ts=` 防缓存的地址能下载,但眼镜不显示。
### 档位和真实帧数
`start_cast` 的 `fps` 参数是速度档位,不是眼镜上的帧数。它决定两件事:
- **电脑每秒截几张图。** 档位 4 时电脑每秒截 4 张,眼镜只来得及显示约 1.5 帧,手机每次取到的都是最新一张。
- **页面每轮倒数多少步。** `hops = round(1000 / 档位 / 20)`(Python 的 round,12.5 取 12;20 是 `GLASSCAST_EMIT_HOP_MS` 的默认值),限制在 1~25,`m = hops × 3`。`hops` 由 `start_cast` 算好,模型打开页面时传进去,所以换档不用改页面。
| 档位 | 每轮步数 | 换图间隔 | 真实帧数 | 表现 |
|---|---|---|---|---|
| 1~2 | 75 | 约 1 秒(估算) | 约每秒 1 帧(估算) | 没有直接测过 |
| 3~4 | 36~51 | 正常时 630~720 毫秒 | 约每秒 1.5 帧 | 图小时稳定,见下 |
| 4.5 及以上 | 33 及以下 | 不固定,4.5 档卡住时约 405 毫秒 | 时好时坏 | 容易卡住(只实测过 4.5 档) |
3~4.5 档是实测的,1~2 档是按步数估算的。3、3.3 档用约 4.1 KB 的图,4 档的 630~720 毫秒和 4.5 档用约 2.5 KB 的图。图越小越不容易卡:约 4.1 KB 的图到 3.5 档就开始不稳,3.8 档约 500 毫秒、时好时坏,4 档不行;约 2.5 KB 的图到 4 档还算稳。
### 上限和卡住是怎么来的
每张图有约 0.65 秒的固定开销,所以倒数再短,真实帧数也基本停在每秒 1.5 帧左右。
档位 4 的 36 步按每步 13 毫秒算只要约 470 毫秒,比 0.65 秒还短,正常时实测却是 630~720 毫秒。这是因为眼镜处理图片时,事件循环也被拖慢了。
问题在于倒数并不知道图片显示完了没有。只要有一张图没显示出来,眼镜就不再被拖慢,循环回到全速(每步约 12.5 毫秒),下一张来得更快,也就更显示不出来。真机日志里这种状态能持续几十秒,换图间隔只有 405~450 毫秒,画面却不动。档位越高越容易掉进去,4 是实测能稳住的最高档,但也不是绝对不会卡。
想再快,只能一次下载里装多帧,在眼镜本地播放,这条路还在实验中。现在传的始终是一张张普通 JPEG,不是视频流,也不是 GIF:`<gif>` 的 `src` 在模拟器里只在创建时取一次,换不了内容(真机没测过),GIF 输出只是默认关闭的实验开关,现有页面都没用它。
## 需要准备
- 千问 AI 眼镜 S1 和手机 App「千问AI穿戴」
- [千问 AI 硬件开放平台](https://device.qianwen.com)账号
- 一台装好 [uv](https://docs.astral.sh/uv/) 的 Mac,投屏时不能锁屏或睡眠
- 一个指向这台电脑的公网 HTTPS 域名,必须是真证书
## 快速开始
先在本机试一下,不需要眼镜:
```bash
git clone https://github.com/Edint386/glasscast.git
cd glasscast
uv sync
GLASSCAST_TOKEN=local-test-token-123456 GLASSCAST_CAST_KEY=localtest123 \
uv run glasscast serve --source synthetic --public-url http://127.0.0.1:8766 --autostart image
```
用浏览器打开 <http://127.0.0.1:8766/s/localtest123/screen.jpg>,合成画面约每秒更新一帧,隔一秒以上刷新就能看到新画面。
接到眼镜上:
1. 运行 `cp .env.example .env`,填好域名和 token(用 `openssl rand -hex 24` 生成)。
2. 给启动 glasscast 的终端打开「屏幕录制」权限。
3. 把服务暴露到公网 HTTPS,见 [公网部署](docs/deploy.md)。
4. 启动服务:`. ./.env && uv run glasscast serve`。
5. 在平台上建 MCP 插件、技能和两个页面,见 [平台配置](docs/platform-setup.md)。
6. 对眼镜说「开始投屏」。
## 语音命令
| 你说 | 效果 |
|---|---|
| 开始投屏 | 用普通模式投屏,约每秒 1 帧 |
| 用流畅模式投屏 | 用流畅模式投屏,最高约每秒 1.5 帧 |
| 最流畅 | 调到 4 档,约每秒 1.5 帧 |
| 最省流量 | 调到 1 档,约每秒 1 帧 |
| 投屏调到 N 档 | N 取 1~10。档位不等于帧数,见上面的表,建议不超过 4 档 |
| 投屏状态 | 查询是否在投、电脑多久前截过图。看不出眼镜上的画面是否卡住 |
| 停止投屏 | 电脑停止截屏,再用眼镜的返回手势离开页面 |
## 安全
- 图片地址里的 KEY 等于看屏口令,拿到完整地址的人就能看到你的屏幕。
- 投屏会投出屏幕上的一切,包括通知和密码框。
- Token 只填在平台的插件配置和本机的 `.env` 里,不要写进技能或页面。
详见 [使用手册的安全一节](docs/usage.md#8-安全)。
## 文档
- [使用手册](docs/usage.md):完整部署步骤、命令行和环境变量、HTTP 和 MCP 接口、排错、已知问题
- [公网部署](docs/deploy.md):反向 SSH 隧道、nginx、1Panel、cloudflared
- [平台配置](docs/platform-setup.md):在千问平台上建插件、技能和页面
- [眼镜页面](pages/README.md):每个页面是什么、改页面前要知道的限制
- [本地模拟器](tools/simulator/README.md):不用眼镜预览页面
- [实验记录](docs/research.md):带日期的调研和真机测试笔记
## 许可
代码以 [MIT 许可证](LICENSE) 发布。「千问」「Qwen」等名称和商标归其各自所有者。模拟器用到的官方渲染内核不包含在本仓库里,由脚本从官方 CDN 下载。使用时请遵守平台的开发者协议,投屏内容和公网暴露的风险由使用者自行承担。
**English:** glasscast mirrors a Mac screen to Qwen AI glasses S1 by serving small grayscale JPEGs over your own HTTPS domain, at about 1 to 1.5 images per second. Setup docs are in Simplified Chinese. This is an unofficial personal project.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues