dsh-watch-together
陪一个只会读文字的模型一起看视频:跟着你的真实播放进度一段段往下看,在你快看完这一段时把画面和台词递过来。
名字全小写,带横杠 —— 和
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 引擎(声音自己接,见 §接声音)
- 依赖:
node+ffmpeg+ 一个浏览器扩展(可选但强烈建议)
30 秒开始
npm i dsh-watch-together # 会连带装 dsh-video-storyboard
node bin/watch.mjs doctor # 自检:ffmpeg / 扩展 / 字幕 / 声音,逐项 OK 才算就绪
node bin/watch.mjs install-extension # 装一次浏览器扩展(以后正常开浏览器就行)
然后在会话里:
watch_together({ wait: true }) // 跟着你浏览器里正在放的那一支走
配置在哪、缺什么怎么补:全部集中在 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 里。
那张表才是真正的契约,版本号只是它的代理 —— 少了导出,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 的版本。
安装
npm i dsh-watch-together # 会一并装 dsh-video-storyboard
从"陪看还在 storyboard 里"的旧版本升级(重要)
旧版 dsh-video-storyboard@0.4.x 自己就注册了 watch_together。如果宿主 profile 里还留着那份旧副本,
而这个包装上之后同一个工具会被注册两遍。升级顺序:
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。一句话版本:
| 要什么 | 最低配 | 配好之后 |
|---|---|---|
| 出图 | ffmpeg 在 PATH 上 |
能看、能写台词 |
| 准位置 | 装一次浏览器扩展 | 跟着你平时的浏览器走,不用专用窗口 |
| 有字幕 | yt-dlp + B 站 cookies |
台词锚点有真实秒数 |
| 没字幕的片子 | ~/.dsh/asr.json |
用 ASR 给句子时间码 |
| 出声 | ~/.dsh/voice.json + ~/.dsh/tts.json |
到点用你选的音色念 |
陪看自己没有额外依赖:位置从浏览器读,不装 Python、不装播放器插件。 唯一要准备的是一个能上报位置的浏览器 —— 见下文「进度从哪来」。
怎么用
一个回合就是一段,不要按定时器轮询:
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 分钟。刚点开视频时最需要的是"它已经跟上了", 所以开头几批短一些,代价只是那几分钟多跑几次分镜。
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。
装了之后:你正常打开 Edge 播 B 站,插件就能看到你在第几秒。不用开专用窗口,不用配端口。
扩展上报什么:只有当前可见标签页的
currentTime / duration / title / url / paused, POST 到127.0.0.1:9377。不读页面内容、不发外网。源码一共 170 行:extension/content.js(读)+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 就是干这个的,双击即可:
scripts/start-edge-debug.cmd # 端口 9333
scripts/start-edge-debug.cmd 9444 # 换一个端口
端口写进配置(桌面版读不到 setx 的环境变量,所以推荐写文件):
// ~/.dsh/video.json
{ "browserPort": 9333 }
DSH_BROWSER_DEBUG_PORT=9333 # 或者环境变量
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。它只做两件事:
- 在正确的播放时刻把台词交出去;
- 每次结果里带上
speech事件,at是这句话在时间轴上的位置。
念不念、用什么念、念多快,全由你(或宿主)决定。
手把手的声音配置教程(挂载适配器、选晓萱等音色、验证、排错)见
docs/VOICE.zh.md。
事件长什么样
watch_together 交付一段时,返回值里有:
"speech": [
{ "at": 191, "text": "钓上来了,池子里那条比他想的配合。", "state": "scheduled", "segment": 180 }
]
后台循环里某句到点时,会把同一条推给你,state 变成 "due"。
同进程:订阅
import { onSpeech } from 'dsh-watch-together/voice';
const off = onSpeech(async ({ state, text }) => {
if (state !== 'due') return; // scheduled 只是清单,不是"现在念"
await mySpeak(text); // ← 你自己的引擎
});
有人订阅时:到点的台词直接交给订阅者,循环不会再叫醒模型(省 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] 钓上来了,池子里那条比他想的配合。
调试时最想知道的就是"它到底是第几秒吐出来这句话",这行比音频本身还有用。
启用只要一个文件:
// ~/.dsh/voice.json
{ "adapter": "<你的路径>/dsh-watch-together/examples/voice-edge-tts/index.mjs" }
或者环境变量 DSH_VOICE_ADAPTER=<同样的路径>。插件启动时 import() 它,挂不上只写一行 warn,
不会影响插件启动。
最小适配器(一分钟)
// 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)。
聊天框 / 悬浮窗:一条统一的文本流
插话那句和剧本台词走同一条流,一起显示在聊天框里,也一起被念出来:
| role | 是什么 | 会念 |
|---|---|---|
line |
剧本台词(到点由循环写进来) | 是 |
assistant |
模型插话时说的那句 | 是 |
user |
你自己打的字 | 否 |
它们都落在 chat.jsonl(%LOCALAPPDATA%\dsh-video-storyboard\cache\ —— 缓存目录由
dsh-video-storyboard 管,两个插件共用一份)。
模型自己说的话怎么进到框里? 靠 say:
watch_together({ say: "你看这条,鱼比人还紧张。" })
插件读不到模型的正常回复,所以插话时想让用户看见并听见,就得把这句话交给 say。
插话之后:先说话,再干活
用户问一句话,模型常常先去取图/zoom,回来才开口 —— 而"回来"要十几秒,用户那边就是
"叫了它一声,它没理我"。所以 watch_together 有三条硬规则(src/interject.js):
- 回插话的那次调用必须带
say,字先写进流,图和清单后到:watch_together({ say: "我看一眼", speakNow: true }) // 一次调用,先说再看 - 没回就追问:下一次工具结果末尾会挂
[未回应] 用户 N 秒前说的「…」你还没回, 直到真的带了say为止(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,不装任何第三方包。
pythonw examples/floating-chat/window.py # 无控制台
它长这样:剧本台词是琥珀色气泡(左边一道竖线,表示"这句会被念出来")、 模型插话是浅灰气泡、你说的话是蓝色气泡靠右,每条上面一行小字写着谁、什么时候。
几个有意为之的细节:
| 行为 | 为什么 |
|---|---|
按真实 DPI 建窗(SetProcessDpiAwareness) |
不声明的话 Windows 会把窗口当 96dpi 位图拉伸,高分屏上整窗发虚 |
| 只做内部焦点,不抢系统焦点 | 窗口一开就把焦点从浏览器抢走的话,敲空格想暂停会打进聊天框 |
| 往上翻历史时新消息不把你拽回底部 | 只有本来就贴着底才自动滚 |
关窗时记住位置和大小(~/.dsh/watch-window.json 的 geometry) |
下次不用重新摆 |
增量读 chat.jsonl(开场只回放最后 60 条) |
以前每 200ms 全量解析,两小时一场几千条会越来越慢 |
圆角外的像素用 -transparentcolor 抠掉 |
tkinter 没有圆角窗口;取不到这个能力就老实退成直角 |
一定要用带 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。 但台词真正需要的只有两件事 —— 这段在说什么、那句话在第几秒,字幕里都有,而且是纯文本。
# 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 就是循环依赖,构建会直接红)。
开发
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 才生效(插件树在进程启动时组合)。
许可
Apache License 2.0 —— 见 LICENSE(全文)与 NOTICE(这个项目不做什么:不托管视频、不上传位置、不绕过 DRM、不内置 TTS 引擎)。
No comments yet. Be the first to write one.