# dsh-watch-together 中文文档

规范地址：https://npmcn.com/package/dsh-watch-together/

版本：0\.2\.11
npm 元数据更新时间：2026-11-03T11:51:23.895Z
中文译文更新时间：2026-11-03T13:56:25.756Z
软件包许可：Apache-2\.0

本文由上游 README 机器翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/dsh-watch-together
原文对照：https://npmcn.com/package/dsh-watch-together/?lang=original
项目主页：https://github.com/SAOS-3624/dsh-watch-together#readme
源码仓库：https://github.com/SAOS-3624/dsh-watch-together

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：1.1 MB，97 个文件
- 模块格式：ES Module
- TypeScript 类型：未声明
- Node.js 要求：\>=20
- 运行依赖：1 个
- peer 依赖：@deepseek-ai/dsh-tools \>=0\.1\.0-rc\.7
- 首次发布：2026-10-31T13:25:15.303Z
- 最新版本发布：2026-11-03T11:51:23.748Z
- 已发布版本数：16（近 12 个月 16 个）

## 中文简介

与纯文本代理一起观看视频：跟踪用户的真实播放位置，在视频结束前一次交出一段视频，并保留脚本台词、感叹词和用户回复的单一文本流。

## 安装

```sh
npm install dsh-watch-together
```

也可以使用：`pnpm add dsh-watch-together`、`yarn add dsh-watch-together`、`bun add dsh-watch-together`

## README

# dsh-watch-together

[![test](https://github.com/SAOS-3624/dsh-watch-together/actions/workflows/test.yml/badge.svg)](https://github.com/SAOS-3624/dsh-watch-together/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/dsh-watch-together.svg)](https://www.npmjs.com/package/dsh-watch-together) [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://npmcn.com/package/dsh-watch-together/LICENSE)

> 陪一个只会读文字的模型**一起看视频**：跟着你的真实播放进度一段段往下看，在你快看完这一段时把画面和台词递过来。
> 
> 名字全小写，带横杠 —— 和 `dsh-video-storyboard` 同一个风格。

**English TL;DR** — a DSH plugin that watches a video _together_ with a text-only agent: it follows the real playback position reported by a browser extension, hands the agent one storyboard + transcript per segment just before that segment ends, and speaks the scripted lines at the exact second. Code, identifiers and the tool schema are English; the docs below are Chinese.

-   **它是什么**：一个 DSH 插件，只注册一个工具 `watch_together`
-   **它不是什么**：不是全片概览，不是播放器，也不带 TTS 引擎（声音自己接，见 [§接声音](https://npmcn.com/package/dsh-watch-together/#readme-%E6%8E%A5%E5%A3%B0%E9%9F%B3%E6%A0%B8%E5%BF%83%E4%B8%8D%E5%8F%91%E5%A3%B0%E5%A3%B0%E9%9F%B3%E4%BD%A0%E8%87%AA%E5%B7%B1%E6%8E%A5)）
-   **依赖**：`node` + `ffmpeg` + 一个浏览器扩展（可选但强烈建议）

## 30 秒开始

```bash
npm i dsh-watch-together          # 会连带装 dsh-video-storyboard
node bin/watch.mjs doctor         # 自检：ffmpeg / 扩展 / 字幕 / 声音，逐项 OK 才算就绪
node bin/watch.mjs install-extension   # 装一次浏览器扩展（以后正常开浏览器就行）
```

然后在会话里：

```js
watch_together({ wait: true })    // 跟着你浏览器里正在放的那一支走
```

**配置在哪、缺什么怎么补**：全部集中在 [`docs/CONFIG.zh.md`](https://npmcn.com/package/dsh-watch-together/docs/CONFIG.zh.md) —— ffmpeg / yt-dlp / B 站 cookies / ASR / 浏览器扩展 / 调试端口 / 声音 / 悬浮窗，一节一个服务。

* * *

## 它和 `dsh-video-storyboard` 是什么关系

**两个插件，一个依赖。** 这不是一个包里的两个开关。

|  | `dsh-video-storyboard` | **`dsh-watch-together`（本包）** |
| --- | --- | --- |
| 版本 | **0.5.x** | **0.0.7** |
| 工具 | `video_storyboard` / `video_zoom` / `video_probe` / `video_locate` | `watch_together` |
| 干什么 | 把一段视频变成**一张可引用的宫格**（一次性、全片或指定窗口） | 跟着播放进度**一段一段看下去** |
| 依赖方向 | 不依赖陪看（`storyboard → watch` = 0 处） | **依赖视频识别**，单向 |

陪看天生建立在视频识别之上：它每次交付的那一格画面和清单，都是调用 `dsh-video-storyboard` 的 `runStoryboard()` 算出来的。所以本包把 `dsh-video-storyboard` 写成**普通依赖**（`^0.5.0`）， `npm i dsh-watch-together` 会连它一起装上 —— 装的是**库代码**，不会因此多注册四个 `video_*` 工具 （工具由各自的插件入口注册）。

想只要视频识别、不要陪看：装 `dsh-video-storyboard` 就行，本包不会被拖进来。

## 发布顺序 —— 但版本号不需要对齐

本包依赖 `dsh-video-storyboard@^0.5.0`。**这是范围，不是等号**：semver 对 0.x 的规定是 `^0.5.0` = `>=0.5.0 <0.6.0`，所以 storyboard 发 0.5.5、0.5.9 都不必动本包。 两个包的版本号本来就不同（本包 `0.0.7`，storyboard `0.5.4`），**没有"对齐"这回事**。

范围定在哪，取决于本包到底从它身上拿了什么：**7 个子路径、15 个导出**， 全部列在 [`test/dep-contract.test.mjs`](https://npmcn.com/package/dsh-watch-together/test/dep-contract.test.mjs) 里。 那张表才是真正的契约，版本号只是它的代理 —— 少了导出，`ERR_PACKAGE_PATH_NOT_EXPORTED` 还算好查；签名悄悄变了才难查，本包不会报错，只是行为不对。

顺序上仍然有先后（npm 上必须先有满足这个范围的 storyboard，本包才装得上）： `npm publish dsh-video-storyboard@0.5.x` → `npm publish dsh-watch-together@0.1.x`。 反过来会装不上：`npm i dsh-watch-together` 找不到满足 `^0.5.0` 的版本。

## 安装

```bash
npm i dsh-watch-together        # 会一并装 dsh-video-storyboard
```

### 从"陪看还在 storyboard 里"的旧版本升级（重要）

旧版 `dsh-video-storyboard@0.4.x` 自己就注册了 `watch_together`。如果宿主 profile 里还留着那份旧副本， 而这个包装上之后**同一个工具会被注册两遍**。升级顺序：

```bash
node ../dsh-video-storyboard/scripts/install-dev.mjs   # 先把 storyboard 更新到 0.5.0（只剩 4 个 video_*）
node scripts/install-dev.mjs                            # 再装陪看（它会连带把 storyboard 再同步一次）
```

本包的 `scripts/install-dev.mjs` 会把**自己**和同级目录里的 `dsh-video-storyboard` 一起复制进 profile —— 因为陪看是裸包名 import 后者的，profile 里缺了它就会 `ERR_MODULE_NOT_FOUND`。`--check` 只读，`--remove` 只卸自己、不动 storyboard。

前置条件（ffmpeg / yt-dlp / cookies / ASR / 声音）逐个服务的配法见 [`docs/CONFIG.zh.md`](https://npmcn.com/package/dsh-watch-together/docs/CONFIG.zh.md)。一句话版本：

| 要什么 | 最低配 | 配好之后 |
| --- | --- | --- |
| 出图 | `ffmpeg` 在 PATH 上 | 能看、能写台词 |
| 准位置 | 装一次浏览器扩展 | 跟着你**平时的浏览器**走，不用专用窗口 |
| 有字幕 | `yt-dlp` + B 站 cookies | 台词锚点有真实秒数 |
| 没字幕的片子 | `~/.dsh/asr.json` | 用 ASR 给句子时间码 |
| 出声 | `~/.dsh/voice.json` + `~/.dsh/tts.json` | 到点用你选的音色念 |

陪看自己**没有额外依赖**：位置从浏览器读，不装 Python、不装播放器插件。 唯一要准备的是一个能上报位置的浏览器 —— 见下文「进度从哪来」。

## 怎么用

一个回合就是一段，**不要按定时器轮询**：

```js
watch_together({ waitForVideo: true })   // 接上用户浏览器里正在放的那一支（不用问链接）
watch_together({ speakNow: true })       // 先看用户已经看到的那一段
watch_together({ lines: [                // 把这一段的台词写进去（at 给字幕原文最准）
  { from: 180, at: "1/4螺口", text: "注意这个接口，它决定了你以后能换什么镜头。" },
] })
watch_together({ wait: true })           // 阻塞到这一段快放完；用户插话会提前返回
```

`input` 可以省略 —— 省略时就从浏览器读。传了也只是"这一支"，不是每次都要贴。

### 它怎么工作

```
用户看到 05:12  →  插件算下一段（05:12–07:12）   ← 这 8 秒用户还在看，等得起
用户看到 07:00  →  这一段早算好了，直接把图递出来  ← 感知延迟 0
```

**AI 先看完，卡着用户的进度说话。** 计算发生在用户在看的当口，所以感觉像一起看。

| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `ramp` | `[60,120,180,240]` | **变速启动**：开头短、逐段加长，第一次说话不用等 5 分钟 |
| `segment` | 300 秒 | 稳定后的批次长度（阶梯走完后固定在这一档） |
| `lookahead` | 300 秒 | **余量按时间算**：保证当前位置之后已备好的时长不少于它（不是"留两段"） |
| `depth` | 自动 | 只给 `depth` 时按**段数**算余量（旧口径，一般别用） |
| `lead` | 15 秒 | **段末前多少秒交付**。不是段末——两格交界处插话最突兀 |
| `cells` | 5 | 每段一张图 |
| `cursor` | 自动 | 用户看到第几秒；不给就自己读，读不到才问你 |

### 变速启动：先短后长

```
1 分钟 → 2 分钟 → 3 分钟 → 4 分钟 → 5 分钟 → 5 分钟 → …
 └─ 45 秒就说第一句           └─ 之后稳定，不再加长
```

固定 5 分钟一档意味着**第一次开口要等将近 5 分钟**。刚点开视频时最需要的是"它已经跟上了"， 所以开头几批短一些，代价只是那几分钟多跑几次分镜。

```js
watch_together({ ramp: [30, 60, 120] })   // 更激进的起步
watch_together({ ramp: [] })              // 全程等长，不要阶梯
```

### 双缓冲：永远留一个批次在后面

```
批次 1 正在看  ┃  批次 2 已建好  ┃  （空闲时开始建批次 3）
批次 1 用完    →  批次 2 顶上，同时开始建批次 3
```

建一个批次要 2–10 秒，而看一个批次要 `segment` 秒，所以**总有时间在空闲里把它建完**。 实测 4 次交付全部 **0.0 秒**（命中预取），批次之间无缝。

## 进度从哪来：两条通道，装个扩展就不用管

位置只有一个来源：你正在看的那个页面里 `<video>` 的 `currentTime`。它准到毫秒，不用猜。 把这个值取出来有两条路：

|  | **A. 浏览器扩展（推荐）** | B. 调试端口（备用） |
| --- | --- | --- |
| 怎么开始 | 装一次扩展，**一分钟** | 每次开一个专用窗口 |
| 用哪个浏览器 | **你平时那个**，书签登录都在 | 另一个窗口，要重新登一次 |
| 位置精度 | 页面真值，每秒上报 | 页面真值，每 15 秒读一次 |
| 手工步骤 | 装扩展（一次性） | 双击 `scripts/start-edge-debug.cmd` |
| 适合 | 所有人 | 不想装扩展 / 扩展读不到的站点 |

> **装扩展**：双击 `scripts\install-extension.cmd` —— 它复制好扩展、把路径放进剪贴板、打开扩展管理页， 然后**一直在那儿等**：你点完三下它自己会说「✓ 扩展装上了」，播个视频它再说「✅ 全部完成」。 一个文件、一次双击，装和验证都在里面。步骤与常见错误：[docs/BRIDGE.zh.md](https://npmcn.com/package/dsh-watch-together/docs/BRIDGE.zh.md)。

装了之后：你正常打开 Edge 播 B 站，插件就能看到你在第几秒。**不用开专用窗口，不用配端口。**

> **扩展上报什么**：只有当前可见标签页的 `currentTime / duration / title / url / paused`， POST 到 `127.0.0.1:9377`。不读页面内容、不发外网。源码一共 170 行： [`extension/content.js`](https://npmcn.com/package/dsh-watch-together/extension/content.js)（读）+ [`extension/background.js`](https://npmcn.com/package/dsh-watch-together/extension/background.js)（发）。

> 插件**不会自己开浏览器**（这条老路 2026-10-31 已删）：多开一个窗口并不能让用户正在看的 那一页报出位置。读不到就问一句「你看到几分几秒了」。

两条通道任意通一条就行，插件优先用扩展那条（更新更勤）。

### B. 备用通道：专用窗口（为什么不能直接用你现在的浏览器）

**Chromium 136 之后（这台机器的 Edge 是 152）不允许在默认 profile 上开调试端口**， 这是微软/谷歌的安全修复，绕不过去。所以那个窗口有它自己的 `--user-data-dir=%USERPROFILE%\.dsh\edge-watch`：

-   **第一次要在里面登录一次 B 站**，之后它一直记着（Cookie 存在那个 profile 里）
-   你的日常 Edge 完全不受影响，可以同时开着
-   书签/扩展不在里面 —— 它只是个"看片的窗口"

`scripts/start-edge-debug.cmd` 就是干这个的，双击即可：

```bash
scripts/start-edge-debug.cmd          # 端口 9333
scripts/start-edge-debug.cmd 9444     # 换一个端口
```

端口写进配置（**桌面版读不到 `setx` 的环境变量，所以推荐写文件**）：

```jsonc
// ~/.dsh/video.json
{ "browserPort": 9333 }
```

```bash
DSH_BROWSER_DEBUG_PORT=9333      # 或者环境变量
```

```js
watch_together({ browserPort: 9333 })   // 或者单次传参
```

> 端口别用 9222：那是 Chrome DevTools 的惯例端口，**WorkBuddy 自己就占着**（实测读到的会是它的界面）。

### 为什么进度会"读不到"（重要）

插件每 15 秒真读一次位置，中间按墙钟外推。三种情况要分清：

| 状态 | 判断依据 | 行为 |
| --- | --- | --- |
| 读数新鲜（≤5 秒） | 刚读到 | 直接信 |
| 读数 6–16 秒旧 | 正常的外推窗口 | 按墙钟往前推，误差下一次读取就纠回来 |
| 超过 20 秒没读到 | **错过了一次读取** | 推出来的位置要**多越过 5 秒**才敢开口（宁可晚，不能早） |
| 超过 60 秒没读到 | 连错过三次 | 不再推，明确要你报进度 |

### 你半路做的动作，它怎么应对

| 你做什么 | 它怎么做 |
| --- | --- |
| **暂停** | 位置不再变，它安静等着。恢复播放后接着说，不会打扰你 |
| **拖进度条跳过去** | 位置立刻跟上；**跳过的部分不补说**。跳超过 1 分钟会提醒模型一次 |
| **拖回去重看** | 位置对了，但**看过的段不会重说一遍**（避免复读）。也会提醒模型一次 |
| **视频播完** | 停下来，叫模型收个尾、问下一支看什么 |
| **换一支片子** | 时长/地址变了就认出来，停下来并叫模型重新开始跟这一支 |
| **切到别的标签页** | 只有**当前可见的标签页**上报，所以不会读错标签页 |
| **关掉浏览器** | 约 20 秒没消息就问你现在看到哪儿；一直没回应就停表，不空转 |

> 暂停、跳转、回看这些都不需要你通知它 —— 它每秒都在读真实进度， 位置永远是对的，只是"说不说话"的策略不同。

读不到最常见的原因：扩展被停用了 / 陪看服务没在跑 / 那个窗口被关了 / 页签切走了。 `watch_together({ statusOnly: true })` 可以随时看钟的状态：读到第几秒、可不可信、为什么没说话。 `node bin/watch.mjs doctor` 会分别列出两条通道各自通不通。

它顺带报三块诊断信息 —— 用户说"后面越来越慢 / 它突然不说话了"时，靠这三块定位卡在哪一环：

| 字段 | 含什么 |
| --- | --- |
| `segment { index, from, to, delivered, total }` | 在第几段、起止、这一场已交付几段、整片共几段（片长未知时 `total` 是 `null`） |
| `speech { queued, lastLateSec, busyUntil }` | 还欠几句台词、上一句比画面晚了几秒、串行声音引擎忙到什么时候 |
| `build { inflight, cached, lastMs }` | 正在建哪一段、缓存里已有几段、上一段建了多久 |

`null` 一律表示"没测到"，不是 0。`queued` 要读 `watch.json`，所以只在 `statusOnly` 里算 （循环每 2 秒付这个 IO 不值得）。

## 接声音（核心不发声，声音你自己接）

这个包**不带任何 TTS 引擎**，也不读 `~/.dsh/tts.json`。它只做两件事：

1.  在**正确的播放时刻**把台词交出去；
2.  每次结果里带上 `speech` 事件，`at` 是这句话在时间轴上的位置。

念不念、用什么念、念多快，全由你（或宿主）决定。

> 手把手的声音配置教程（挂载适配器、选晓萱等音色、验证、排错）见 [`docs/VOICE.zh.md`](https://npmcn.com/package/dsh-watch-together/docs/VOICE.zh.md)。

### 事件长什么样

`watch_together` 交付一段时，返回值里有：

```json
"speech": [
  { "at": 191, "text": "钓上来了，池子里那条比他想的配合。", "state": "scheduled", "segment": 180 }
]
```

后台循环里某句到点时，会把同一条推给你，`state` 变成 `"due"`。 另外，循环每一拍还会把**未来 1 分钟内、已经写好且带 `at` 的台词**提前推成 `scheduled`（每句一次）—— 这不是让你现在念，而是给适配器时间**预合成**；真正播放仍然只认 `due`。

> `scheduled` **一定带有限的 `at`**（没写 `at` 的台词进不了这个窗口）。写适配器时别拿 `Number(at)` 判空——`Number(null)` 是 `0`，会被当成"最早的一句"占满预取队列，之后既踢不掉旧的也进不来新的。

### 同进程：订阅

```js
import { onSpeech } from 'dsh-watch-together/voice';

const pending = new Map();          // key -> 预合成 Promise
const keyOf = ({ input, segment, text }) => `${input ?? ''}|${segment ?? ''}|${text ?? ''}`;

const off = onSpeech(async (event) => {
  const { state, text } = event;
  const key = keyOf(event);
  if (state === 'scheduled') {       // 还没到点：先合成，不播放
    if (!pending.has(key)) pending.set(key, mySynth(text));
    return;
  }
  if (state !== 'due') return;      // spoken / skipped 不是"该念了"
  const audio = pending.get(key) ? await pending.get(key) : await mySynth(text);
  pending.delete(key);
  await myPlay(audio);              // ← 你自己的播放器
});
```

**有人订阅时**：到点的台词直接交给订阅者，循环**不会**再叫醒模型（省 token，也避免同一句被说两遍）。 **没人订阅时**：台词照旧走"叫醒模型、模型自己说"那条路 —— 功能完整，只是安静。

> 只做展示的订阅者要传 `{ voice: false }`（聊天框就是这么干的）：否则循环会以为"有人会念"， 从此不再叫醒模型 —— 陪看会**默默地**变成哑巴。

> **别忘了上报"真的出声了"**：`due` 只是"该念了"，合成还要 0.6~2 秒。悬浮窗里的字等的是 `state === 'spoken'`（开始播放那一瞬调 `api.emitSpoken(e)`）。不上报也能用，但字会先于声音 出现（2.5 秒兜底），也就是这次修掉的那个不同步。

### 节奏：宁可少说一句，不要越说越晚

引擎是**串行**的（一句念完才轮到下一句），所以台词密度直接决定声音会不会越拖越远：

-   钟会记"引擎到什么时候才空出来"（`字数/5` 秒），上一句还在说时**不会**再塞下一句；
-   拖过迟到宽限（3 秒）的那句直接作废 —— 少说一句，而不是晚 20 秒说；
-   适配器还能先放弃：排队超过 3.5 秒就跳过这一句，并发 `state: 'skipped'`；
-   展示层收到 `skipped` 会撤掉兜底定时器，**没出声的字不会出现在悬浮窗里**。

所以"台词准时"和"每句都念"是互斥的，这个包选前者。想少丢词就让模型每段少写两句 —— 交付说明里会把每句的落点列出来，平均间隔小于 6 秒时会明确警告"太密"。

### 现成适配器：`examples/voice-edge-tts`

带了一个可以直接用的适配器：edge-tts 合成 → `ffplay` 播放 → 失败退回系统声音。 每一句都会在 **stderr 打一行时间码**：

```
[voice 03:11.4] 钓上来了，池子里那条比他想的配合。
```

调试时最想知道的就是"它到底是第几秒吐出来这句话"，这行比音频本身还有用。

启用只要一个文件：

```json
// ~/.dsh/voice.json
{ "adapter": "<你的路径>/dsh-watch-together/examples/voice-edge-tts/index.mjs" }
```

或者环境变量 `DSH_VOICE_ADAPTER=<同样的路径>`。插件启动时 `import()` 它，挂不上只写一行 warn， **不会**影响插件启动。

### 最小适配器（一分钟）

```js
// my-voice.mjs
import { execFile } from 'node:child_process';

export function attach({ onSpeech }) {
  return onSpeech(({ state, text }) => {
    if (state === 'due') execFile('say', [text], () => {});   // macOS；换成你自己的引擎
  });
}
```

`attach(api)` 收到 `{ onSpeech, speechSubscribers, emitSpeech, emitSpoken, lastSpeech }`，返回一个退订函数即可。 想放弃某一句就发 `state: 'skipped'`（见 [docs/VOICE.zh.md](https://npmcn.com/package/dsh-watch-together/docs/VOICE.zh.md)）。

## 聊天框 / 悬浮窗：一条统一的文本流

插话那句和剧本台词**走同一条流**，一起显示在聊天框里，也一起被念出来：

| role | 是什么 | 会念 |
| --- | --- | --- |
| `line` | 剧本台词（到点由循环写进来） | 是 |
| `assistant` | 模型插话时说的那句 | 是 |
| `user` | 你自己打的字 | 否 |

它们都落在 `chat.jsonl`（`%LOCALAPPDATA%\dsh-video-storyboard\cache\` —— 缓存目录由 `dsh-video-storyboard` 管，两个插件共用一份）。

**模型自己说的话怎么进到框里？** 靠 `say`：

```js
watch_together({ say: "你看这条，鱼比人还紧张。" })
```

插件读不到模型的正常回复，所以插话时想让用户**看见并听见**，就得把这句话交给 `say`。

### 插话之后：先说话，再干活

用户问一句话，模型常常先去取图/zoom，回来才开口 —— 而"回来"要十几秒，用户那边就是 "叫了它一声，它没理我"。所以 `watch_together` 有三条硬规则（`src/interject.js`）：

1.  **回插话的那次调用必须带 `say`**，字先写进流，图和清单后到：
    
    ```js
    watch_together({ say: "我看一眼", speakNow: true })   // 一次调用，先说再看
    ```
    
2.  **没回就追问**：下一次工具结果末尾会挂 `[未回应] 用户 N 秒前说的「…」你还没回`， 直到真的带了 `say` 为止（3 分钟后自动作废）。
3.  **回话和脚本台词不许挤在一起**：你说话之后 12 秒内的脚本台词会被**取走** （`[lines·太近]`）交回给模型按刚说的那句重写；那段时间里循环也不会再发词 （`src/reply-guard.js`）。两句挨着念，声音引擎串行，后一句排太久会被适配器直接丢掉。

### 三种"停下来"的信号

用户不需要说"别看了"——这三件事本身就是指令，插件会停表，并且**像插话一样叫醒模型**， 让它在同一轮里知道"别再按旧的跑了"：

| 信号 | 怎么发现 | 之后 |
| --- | --- | --- |
| 关掉悬浮窗 | 钟每 2 秒看一次窗口 pid | 停表 + `[hangup]`：**不要再调 watch\_together、不要再念台词**；下一次调用直接返回，不建图、不起钟、不重开窗（除非 `open: true`） |
| 关掉浏览器 / 切走页签 | 位置读数断了 | 20 秒后**一句台词都不再念**（位置只能外推，外推过期就不许出声）；5 分钟仍读不到就停表 |
| 换片 / 播完 | 位置桥的 `dur` 或视频页地址变了（每 2 秒判一次） | 停表 + 叫醒："换片了/播完了"；阻塞等待里会**当场**返回 `[video-changed]` |

> 关窗为什么必须真的叫醒：`wake: false`（默认）关的是"这一段该说话了"那种打扰。 关窗/换片/播完不是打扰，是"事情变了"——它们走的是不受那个开关影响的通道。

### 悬浮窗（不用浏览器）

`examples/floating-chat/window.py` 是个**真·独立悬浮窗**：无边框圆角、置顶、 按住顶部拖动、右下角可拉伸、回车发送、Esc 关闭。用标准库 tkinter，不装任何第三方包。

```bash
pythonw examples/floating-chat/window.py     # 无控制台
```

它长这样：三块分开的液态玻璃（标题 / 主体 / 输入栏），气泡圆角、半透明， 你说的话靠右；标题栏可拖动、双击标题改名、▾ 折叠、✕ 关闭，底部有麦克风图标和发送。 玻璃分成四层，各自不知道对方存在：

| 文件 | 职责 |
| --- | --- |
| `glass_geometry.py` | 一套尺寸的全部贴图（遮罩 / 折射坐标 / 内框透镜）。不可变值对象，气泡变宽就换一个新对象，尺寸相关的图直接复用 |
| `glass_capture.py` | 抓屏、静态跳帧与运动强度判定、麦克风贴图、圆角收尾 —— **CPU/GPU 共用一份** |
| `glass_cpu.py` | CPU 合成：缩小 → 高斯 → 边缘折射 → 内框透镜 → 染色 → 色散 |
| `glass_gpu.py` | 同一件事在 moderngl 上做（`DSH_GLASS_GPU=1` 或 `DSH_GLASS_NATIVE=1`） |
| `window.py` | Tk 窗口、聊天流、拖动/缩放/折叠/改名 |

CPU/GPU 输出是逐像素对齐的（同一背景均差 0.07，99% 像素差 ≤1），所以换后端看不出区别。

几个有意为之的细节：

| 行为 | 为什么 |
| --- | --- |
| 按真实 DPI 建窗（`SetProcessDpiAwareness`） | 不声明的话 Windows 会把窗口当 96dpi 位图拉伸，高分屏上整窗发虚 |
| 只做内部焦点，不抢系统焦点 | 窗口一开就把焦点从浏览器抢走的话，敲空格想暂停会打进聊天框 |
| 往上翻历史时新消息不把你拽回底部 | 只有本来就贴着底才自动滚 |
| 关窗时记住位置和大小（`~/.dsh/watch-window.json` 的 `geometry`） | 下次不用重新摆 |
| 增量读 `chat.jsonl`（开场只回放最后 60 条） | 以前每 200ms 全量解析，两小时一场几千条会越来越慢 |
| 圆角外的像素用 `-transparentcolor` 抠掉 | tkinter 没有圆角窗口；取不到这个能力就老实退成直角 |
| 窗口只读 `window-events.jsonl`，写 `window-outbox.jsonl` | 和插件解耦；旧版插件没有协议文件时仍增量读 `chat.jsonl` |
| CPU/GPU 共用同一份 UI 和同一套抓屏/运动判定 | GPU 只换每帧合成，不另写窗口；历史上两边各写一份，同一个 bug 修两遍（#39、#48） |

> 窗口默认要 Pillow + numpy + mss；GPU 额外要 `pip install moderngl` 和 OpenGL 3.3。 CPU 在这台机器上更省（约 43% 单核 / 93 MB，GPU 32% / 195 MB —— 显存纹理和每帧 读回要花内存），默认走 CPU；窗口很大或 CPU 较弱时再切 GPU。接口和 线协议见 [`docs/INTERFACES.zh.md`](https://npmcn.com/package/dsh-watch-together/docs/INTERFACES.zh.md)。

> **一定要用带 tkinter 的 Python**：Windows 商店版自带；python.org 官方安装包勾上 tcl/tk 也有。 WorkBuddy 托管的那份**没有** tkinter。

它和聊天记录页（`http://127.0.0.1:9377/`，只读兜底）看的是同一个文件，两个一起开着也不打架。 浏览器置顶小窗（`/widget` + 画中画）那条老路已经删了 —— 要先开一个标签页、关掉就没了。

## 命令行（没有 DSH 宿主时）

`bin/watch.mjs` 是给**没有 `watch_together` 工具的会话**（比如普通的终端 AI）用的壳， import 的是同一份 `src/`。完整用法就是 `node bin/watch.mjs help`：

| 命令 | 干什么 |
| --- | --- |
| `doctor` | 自检：插件 / ffmpeg / 端口 / 扩展心跳 / 登录，逐项 OK/FAIL |
| `start [url]` | 起一个**带调试端口**的专用浏览器窗口（备选通道） |
| `login` | 从浏览器直读 cookie 落盘 + 验 B 站登录 |
| `wait [秒]` | 等用户真的放起了视频，返回地址 / 标题 / 时长 / 位置 |
| `status` | 当前位置 + 当前段 + **余量 buffer** + 下一步该跑什么 |
| `fill` | 一次把 5 分钟余量内的段全建好（别一段一段挤） |
| `seg [auto|from to]` | 出这一段的宫格 + 清单 + 句子时间轴 |
| `subs [from to]` | 只拉字幕时间轴（不出图，1 秒） |
| `cue '[[at,"词"]]'` | 到点念台词；`check` 只核对不念 |
| `say "文本"` / `voices` | 立刻念一句 / 列出可用音色 |
| `chat` / `reply` | 起聊天服务 / 替用户回一句 |
| `bridge` | 前台盯着扩展上报（排查用） |
| `install-extension` | 装浏览器扩展（三分钟，装完自己验证） |

## 素材：图和字幕是两半，缺一不可

台词来自"看到什么"＋"听到什么"，所以一段的素材有两半，工具会记账：

| 一半 | 从哪来 | 记在过去哪 |
| --- | --- | --- |
| `image` | storyboard 九宫格（一次交付给一张） | `watch.json` 的 `material[段起点].image` |
| `transcript` | 句子 + 每句**真实起始秒**（字幕轨或 ASR） | `material[段起点].transcript` |

两半都交出去过，交付结果里就会出现：

```
[素材] 图 ✔ 字幕 ✔ —— 两半都到了，现在写台词（`lines`）。
```

缺一半时它会直接写出下一步该调什么：

```
[素材] 图 ✔ 字幕 ✘ —— 句子的真实秒数还没有，先取字幕：
  watch_together({ transcriptOnly: true, input: <同一个 input>, from: <段起点>, size: <段长> })
```

`transcriptOnly: true` 就是"**图与字幕分开返回**"那个开关：只给句子和秒数、不出图、不读播放位置。 命令行等价物是 `node bin/lines.mjs scan`（读的是同一份字幕缓存）。

记住口径：它只记"我交出去过什么"，不假装能验证"模型有没有看"—— 但没交出去过的东西，不会被当成素材用。

## 写台词的两条路（省 token）

写一段台词的代价，全在"看图"上：一张 storyboard 400~700 vision token。 但台词真正需要的只有两件事 —— **这段在说什么**、**那句话在第几秒**，字幕里都有，而且是纯文本。

```bash
# 1) 纯文本候选：8~10 句 + 每句真实起始秒，一张图都不解码
node bin/lines.mjs scan --input <url|路径> --from 180 --size 180

# 2) 直接写进去（秒数可以写 195，也可以写 03:15）
node bin/lines.mjs set --input <url> --from 180      --at 200 --text "…" --at 250 --text "…"

# 3) 完全不经过模型：按打分选 3 句写进稿子（只是复述字幕，不会"评论"）
node bin/lines.mjs auto --input <url> --from 180 --size 180 --count 3

node bin/lines.mjs list --input <url>     # 哪些段已经写过、各几句
node bin/lines.mjs cues --input <url> --from 180 --size 60   # 原始字幕，排查用
```

字幕会缓存在 `<cache>/subs-<sessionKey>.json`，同一支片子只抓一次； `--refresh` 强制重抓，`--quiet` 关掉头部那行说明。

**代价对比（每段）**：看图写 ≈ 1,200~1,900 token，`scan`（只看候选句）+ `set` ≈ 300 token， `auto` ≈ 0（脚本自己挑，**应急用**）。`auto` 只会把字幕复述一遍，它的定位是「这段我懒得管」，不是默认路径 —— 正常还是看图 + 字幕写人话。

## 它不是全片概览

-   一次只看一段，看完再下一段
-   想细看某一格 → `video_zoom({ cellId: 3 })`（那是 `dsh-video-storyboard` 的工具）
-   每段 5 格 ≈ 1,000–1,300 token；90 分钟片约 4.5–6 万 token（是一次性概览的 4–6 倍，换的是"有时间顺序"）

## 长视频：一段短前情（默认关）

段数多了以后，模型可能丢掉上一段发生了什么。需要时显式打开，交付时会附一小段 `[前情]`：

-   `recap: true` 或环境变量 `DSH_WATCH_RECAP=1` 才开启；默认不产生任何前情；
-   只取当前段之前最近 1~2 段（`recapSegments`，默认 2，最多 3）；
-   只读**本地字幕缓存**的原句，不读模型写过的台词槽；
-   绝不读当前段之后的内容，不会剧透；
-   整段上限 420 字符（`recapMaxChars`），超长截断；第一段没有前情，也不额外联网/跑 ASR。

默认关是因为没有实测数据前，每段 200+ token 是纯成本；开启后请自己对照后段是否真的少复述。这段不是全片总结；想看全片概览请另外用 `video_storyboard` 的概览能力。

## 代码结构

```
src/
  index.js          插件入口：只注册 watch_together，并挂上声音与聊天框
  register.js       watch_together 的注册入口
  tool-spec.js      watch_together 的 schema ＋ 兜底校验
  handler.js        watch_together 的 execute

  watch-loop.js     后台循环：每段到点自己叫醒模型
  watch-run.js      一次陪看调用：读位置、建这一段、到点交付
  watch-modes.js    各种等待模式（waitUntil / nextLine / refill）
  watch-actions.js  单发命令（status / voices / speak / cookies / wake / stop）

  watch.js          门面：转发下面两个
  watch-state.js    看到哪儿了：watch.json 读写 ＋ 段长/交付点算术
  watch-lines.js    该说什么了：位置轴上的台词队列
  lines-util.js     不读图也能写词：字幕 → 候选句 + 真实秒数
  watch-material.js 素材账本：图 / 字幕 各交出去过没有

  watch-source.js   这次看的是哪个源（就是当前页面的地址）
  watch-browser.js  "浏览器在不在"＋从两条通道读一次位置（不负责开浏览器）
  position.js       位置可信度与推算
  position-bridge.js 扩展上报的位置：落盘 + 5 秒新鲜度
  latency.js        交付延迟记录与补偿
  batches.js        变速启动与批次窗口
  timer.js          到点提醒 ＋ 叫醒会话
  interrupt.js      边等边听插话（共享 watcher + 等待者优先级）
  interject.js      插话交给模型的那段话 ＋ "到底回了没有"（待回执）
  reply-guard.js    回话占住的那 12 秒：取走脚本台词、让循环别抢话

  voice.js          门面 ─┬ speech-bus.js    订阅总线（谁在听、谁真会出声）
  voice-adapter.js  ──────┘ 按路径挂外部引擎
  speech.js         语速估算："何时可以插话" vs "引擎还要占用多久"

  chat.js           门面 ─┬ chat-store.js    chat.jsonl 读写（增量 tail）
  chat-view.js      ──────┤ 聊天记录页模板
  chat-server.js    ──────┘ localhost 五条路由
  input-surface.js  把服务 + 悬浮窗支起来（幂等）
  floating-window.js 拉起/判活 Python 悬浮窗

  time.js           mmss（秒 → "mm:ss"），只有一份实现
```

依赖是**单向**的：这里的模块可以 `import 'dsh-video-storyboard/...'`，反过来永远不会发生 （那个包里出现一个指向本包的 import 就是循环依赖，构建会直接红）。

## 开发

```bash
node --test                    # 全量测试（不要传目录，node 自己会找 test/*.test.mjs）
node scripts/check-syntax.mjs  # 只做语法/导入检查，比跑测试快
node scripts/install-dev.mjs --check   # 看 dev 副本和 profile 里装的是不是同一份
node scripts/install-dev.mjs           # 把本包同步进 DSH profile（副本，不是软链）
```

改完代码要重启 DSH Desktop 才生效（插件树在进程启动时组合）。

## 致谢

社区提的改动按 PR 记在这里（storyboard 那边的完整名单在 `dsh-video-storyboard/CREDITS.md`）：

-   [@ggfgfgf-on](https://github.com/ggfgfgf-on) —— 插件设置页（[PR #11](https://github.com/SAOS-3624/dsh-watch-together/pull/11)，设置直接渲染在插件详情页）； 核心改走 runtime-ports、悬浮窗气泡与折叠重做（[PR #22](https://github.com/SAOS-3624/dsh-watch-together/pull/22)）； 小框边缘模糊过渡、CPU 补色散与运动混合、麦克风回到玻璃帧合成（[PR #28](https://github.com/SAOS-3624/dsh-watch-together/pull/28)）
-   [@DreamDLYY](https://github.com/DreamDLYY) —— 悬浮窗拖出屏幕后抓屏越界、日志每帧刷错、背景永久花屏的复现与修复（[PR #23](https://github.com/SAOS-3624/dsh-watch-together/pull/23)）

## 许可

Apache License 2.0 —— 见 [LICENSE](https://npmcn.com/package/dsh-watch-together/LICENSE)（全文）与 [NOTICE](https://npmcn.com/package/dsh-watch-together/NOTICE)（这个项目**不做什么**：不托管视频、不上传位置、不绕过 DRM、不内置 TTS 引擎）。

## 赞赏

免费、不锁功能、不需要授权 —— 就是 Apache-2.0，拿去用就行。

如果它替你省了时间，欢迎扫码：

![赞赏码（微信 / 支付宝）](https://npmcn.com/package/dsh-watch-together/docs/images/reward-qr.png)

打赏不解锁任何东西，也不影响这个项目以后的授权方式。

## 常见问题

### dsh-watch-together 是什么？

与纯文本代理一起观看视频：跟踪用户的真实播放位置，在视频结束前一次交出一段视频，并保留脚本台词、感叹词和用户回复的单一文本流。

### 如何安装 dsh-watch-together？

在项目目录运行 npm install dsh-watch-together，或使用 pnpm add / yarn add / bun add。它还提供命令行工具 dsh-watch、dsh-watch-lines，可以用 npx dsh-watch 直接运行。

### dsh-watch-together 支持 TypeScript 吗？

该包的 package\.json 没有声明类型入口。需要类型提示时，可以在 npm 上查找社区维护的 @types/dsh-watch-together 是否存在。

### dsh-watch-together 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### dsh-watch-together 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=20。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### dsh-watch-together 可以商用吗？

dsh-watch-together 使用 Apache-2\.0 许可。宽松许可：允许商业使用、修改和再分发，并附带明确的专利授权；分发时需保留许可与 NOTICE 文件，修改过的文件要注明改动。以上为概括，不构成法律意见，请以许可原文为准。

### dsh-watch-together 还在维护吗？

最新版本 0\.2\.11 发布于 2026/11/03（今天）；至今共发布 16 个版本，其中过去 12 个月发布了 16 个；首次发布于 2026/10/31。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

