给 DSH 加一段开机动画:打开一个新对话、或打开你指定的那个会话时,视频铺满整个窗口播放。
English: README.en.md
- 一个会话只播一次(新对话默认行为)
- 指定会话每次打开都播 —— 在侧边栏页脚点一下图钉即可
- 铺满窗口、可跳过、放完自动关闭
- 可以换自己的片子(三种方式,见下)
dsh plugin --profile web add github:NativeDog1/dsh-boot-animation仓库里已经提交了构建产物
lib/,也没有prepare生命周期脚本, 所以这条命令不编译任何东西,不会触发 pnpm 的allowBuilds构建授权。 (等包发布到 npm 之后,也可以写成dsh plugin --profile web add dsh-boot-animation。)
| DSH 版本线 | 状态 |
|---|---|
0.1.5-rc.x / 0.1.7-rc.x |
✅ 支持(桌面版旧内核,以及 npm i -g @deepseek-ai/dsh 那条线) |
0.2.0-rc.x |
✅ 支持(桌面版新内核,以及 @next 那条线) |
本插件刻意不声明任何 @deepseek-ai/dsh-* 的 peerDependencies。 host 半边只用
ctx.webServer,客户端半边只通过动态注入取 slots / uiSession,两处都不静态
依赖某个宿主包。这样做的直接好处是:它永远不会被 dsh 的版本兼容闸门跳过 ——
0.2.0 起那道闸门会把 peerDependencies 对不上的插件整包跳过,现象是"装上了、
但重启后什么也没发生",而且不报错(市场自己的兼容守卫也会拦)。其它插件要靠
^0.1.5-rc.3 || ^0.2.0-rc.1 这种 || 列表来适配,本插件不需要,也不会因为漏写
某条版本线而被跳过。
代价是宿主形状变了得自己容忍,这部分由测试守着:verify-blank(13 种宿主形状)、
verify-session-id、verify-client-boot(11 项 boot 安全)。例如"这是不是全新对话"
在 0.2.0 从 binding 上的 blankBit 挪到了 session.getSnapshot().blank —— 两种都读,
并且订阅那个 face 本身而不是采样一次。
装完必须重启一次 DSH 服务才生效(bundle 层是在启动时装配的):
# 停掉当前的 dsh web,然后
dsh webDSH 的客户端 bundle 响应带 cache-control: max-age=31536000, immutable,
而 URL 上的 rev 是进程 nonce、不会随内容变化。所以浏览器会一直用第一次抓到的副本。
装好或升级后请在新窗口里按 Ctrl+Shift+R(硬刷新)。普通 F5 不够。
打开一个还没说过话的新对话时会自动播一次。
- 打开那个会话
- 点侧边栏最下面的 🎞 图标(就在「设置」旁边)
- 图标变绿 🎬 = 已钉住
之后每次进入这个会话都会播一遍 —— 切走再切回来、刷新页面,都会重播。
再点一下图标取消。
注意:如果刚启动时你的活动主面板不是「对话」(比如停在某个插件的面板上), 当前会话还不存在,图钉是禁用状态。先打开一个对话即可。
上面那个 🎞 图钉管的是「什么时候播」;如果你要管「这个会话播哪一段」, 用片库里的 「仅本会话」:
- 打开那个会话
- 点页脚 🎛 打开片头片库
- 在你想播的那一行点 「仅本会话」
那一行会变成蓝底并挂上「本会话」徽章,面板顶部也会写明「本会话固定播放:xxx」, 旁边有 「取消(回到全局)」。之后只有这个会话播这一段 —— 其它会话、以及你在别处选的那一段,都不受影响。 「选它」改的是全局,「仅本会话」改的是这一个会话,两者互不覆盖。
优先级:会话覆盖 → 随机 → 全局选择 → 环境变量 → intro.mp4 → videos/ → 内嵌内置片。
会话覆盖连「随机播放」也压得住 —— 把一个会话钉住之后又被随手塞一段随机片,
那不是"钉住"的意思;但显式请求随机的调用(mode=random)仍然不被覆盖。
指的那一段被删掉或移走时,这个会话回落到全局而不是黑屏,并在 diagnostics 里 记一条
conversation-override-stale;文件回来了这条钉住自动生效 (不会因为一次失联就被清掉)。
插件现在是一个片库,不是单个槽位:它会把所有能找到的视频都列出来,你选一个,选择会被记住。
装完不用加任何东西,片库里就已经有四段可选:
| 片库里的名字 | 来源 | 大小 |
|---|---|---|
DeepSeek 品牌片头 |
内嵌 lib/clips.data.js |
1.2 MB |
DeepSeek 赛博朋克片头 |
内嵌 lib/clips.data.js |
1.8 MB |
DeepSeek 数字角色苏醒 |
内嵌 lib/clips.data.js |
2.5 MB |
DeepSeek 启动问题 |
内嵌 lib/clips.data.js |
3.2 MB |
这些片段没有落盘的 mp4 文件 —— 它们以 base64 存在 lib/clips.data.js 里,host 在
第一次被请求时才 import(约 11.5 MB 的模块,如果在启动时解析,每次开 DSH 都要白付这个代价)。
这样做的意义是:不会再有 files 字段漏写、安装副本过期、或者随包发出一个没做 faststart
的容器这些事。media/*.mp4 只是 npm run embed-clips 的输入,不随包发布。
四段都是 faststart 过的(moov 在文件头),可以边下边播;生成脚本会拒绝任何
moov 不在前面的输入。这很重要:索引表在文件末尾的 mp4 要整段下载完才出画面,
叠加客户端 25 秒看门狗,表现就是「片头全黑」。
体积:npm 包 9.1 MB(tarball)/ 解包 12.2 MB。四段都做过 CRF 20 重编码 + faststart
重排,相比各自的原片省下 36%–71%(合计 20.4 MB → 8.7 MB),画质指标 SSIM 0.988–0.996、
PSNR 44–48 dB —— 这是「肉眼看不出差别」的区间。原片另存于仓库外 ~/dsh-dev/_clip-masters/。
想换成自己的片子:把 mp4 放进 media/,改 scripts/embed-clips.mjs 里的清单,
跑 npm run embed-clips。(临时试片不用这么麻烦 —— 见下面的「最省事的方式」。)
- 把 mp4 丢进
~/.dsh/boot-animation/videos/ - 在侧边栏页脚点 🎛(在 🎞 图钉旁边)打开「片头片库」
- 点一下你想播的那一条
选中的那段会在下一次播放片头时登场:新对话、以及你钉住的会话。
Windows 上就是
C:\Users\<你>\.dsh\boot-animation\videos\具体路径以片库面板底部显示的那一行为准。你不需要为了"干净"删任何东西:同一个视频存在多份副本时,片库只列一条 (按 大小+mtime 判定同一份内容),并标注「合并 N 份重复」。你的文件一直留在 原处,只是不重复显示。这解决的是历史上一个真实的困惑:用户自己也放了一份
intro.mp4,于是同一段片子在面板上出现两次、挂两个不同徽章,看起来像 "插件里没有这一段"。
| 元素 | 作用 |
|---|---|
| ✓ 标记 | 当前生效的那一条(全局选择) |
| 来源徽章 | 你自己加的 / 插件自带 / 内置原始 / 环境变量 |
| 文件大小 | 帮你确认换对了没有 |
| ▶ 预览 | 立刻播放这一行,不改你的任何选择 |
| 选它 | 把它设为全局片头(以后开片头都播它) |
| 仅本会话 | 只让当前这个会话播它,不动全局选择(见上) |
本会话 徽章 |
这一行是这个会话固定播放的那段 |
| 面板顶部的本会话条 | 本会话当前固定播放哪一段,带「取消(回到全局)」 |
| 刷新 | 刚往文件夹里丢完文件,点它重新扫描 |
原片源 徽章 |
历史上那个 intro.mp4 落点,仍然优先 |
| ⚠ 未优化 徽章 | 该文件的索引表 moov 在末尾,建议重排(见下) |
铺满屏幕 / 完整显示 |
播放时怎么贴合窗口,见下 |
片头动画的触发故意很窄:
- 新对话:只播一次(记住播过的会话,不会重复打扰)
- 钉住的会话:每次打开都播
所以在同一个新对话里刷新页面,片子本来就不会重播 —— 这很容易被误认为 "我换了片子但另一个视频不出现"。点片库里的 ▶ 预览当前 可以立刻播放选中的那段, 确认换对了没有。
覆盖层铺满整个窗口,但窗口的长宽比几乎不会是视频的长宽比——浏览器有标题栏和 工具栏,可视区通常比 16:9 更宽。这时:
| 模式 | CSS | 效果 |
|---|---|---|
| 铺满屏幕(默认) | object-fit: cover |
填满窗口,没有黑边,超出部分被裁掉 |
| 完整显示 | object-fit: contain |
整帧都在,长宽比不匹配时留黑边 |
在片库面板里切换,下次播放生效。选「完整显示」的情况:片子里有贴着边缘的字幕、 logo 或水印,不想被裁掉。
如果黑边来自视频本身烧进去的边框(导出时带上的),改 CSS 没用,要用 ffmpeg 裁掉:
ffmpeg -i in.mp4 -vf "crop=W:H:X:Y" -c:a copy out.mp4。 判断方法:ffmpeg -v info -i in.mp4 -vf cropdetect=24:16:0 -f null -, 若crop=值全程稳定,就是烧进去的;若随画面变化,那只是深色背景,别裁。
.mp4 .m4v .webm .mov .mkv —— 但能不能播取决于浏览器解码。
H.264 + AAC 的 mp4 最稳;HEVC(H.265)、ProRes、部分 mkv 大概率只有声或黑屏。
host 半侧按这个顺序解析,每次请求都重新解析(换片子不用重启):
| 顺序 | 位置 |
|---|---|
| 0 | 当前会话的覆盖(selection.json 的 conversationOverrides;只有带 ?session= 的请求会看这一层) |
| 1 | ~/.dsh/boot-animation/selection.json 里选中的那个 id(片库面板写的) |
| 2 | 环境变量 DSH_BOOT_ANIMATION 指向的文件 |
| 3 | ~/.dsh/boot-animation/intro.mp4(历史落点,仍优先于片库里的其他文件) |
| 4 | ~/.dsh/boot-animation/videos/ 里最新修改的那个 |
| 5 | 内嵌的四段(按 scripts/embed-clips.mjs 里的顺序,品牌片头优先)—— 永远兜得住,因为它在代码里 |
所以最保险的手动换法依然是:
mkdir -p ~/.dsh/boot-animation
cp 我的片子.mp4 ~/.dsh/boot-animation/intro.mp4想确认当前用的是哪一个,直接访问状态端点:
curl http://127.0.0.1:3080/dsh-boot-animation/status.json
curl http://127.0.0.1:3080/dsh-boot-animation/videos.json看某个会话会播哪一段,以及把某个会话钉到某一段(id: null 取消):
curl "http://127.0.0.1:3080/dsh-boot-animation/resolve.json?mode=active&session=<会话id>"
curl -X POST http://127.0.0.1:3080/dsh-boot-animation/select \
-H 'content-type: application/json' \
-d '{"scope":"conversation","sessionId":"<会话id>","id":"builtin:cyberpunk"}'不带 ?session= 时,每一个回答都和 0.3.0 逐字节一致。
多半是容器没做 faststart。 如果 mp4 的索引表 moov 在文件末尾,浏览器必须
整段下完才能解码,中间一直黑屏;而客户端有 25 秒看门狗(STALL_TIMEOUT_MS),
超时就自己把覆盖层关掉 —— 症状就是「点开什么都没有」。
用 ffmpeg 重排一下容器(无损,不重新编码):
ffmpeg -i 原片.mp4 -c copy -movflags +faststart 修好的.mp4验证 moov 是否前置:
ffprobe -v trace 修好的.mp4 2>&1 | grep -m1 moov # 偏移应该很小自动播放带声音、以及 Fullscreen API,都要求用户手势,任何网页都绕不过。所以:
- 动画以静音在铺满窗口的覆盖层里自动开始(视觉上已经是全屏)
- 点一下画面:同时开启声音并进入真全屏
- 万一连静音自动播放也被拒,会显示「点击播放」而不是黑屏
| 现象 | 原因 / 处理 |
|---|---|
| 完全没出现 | 十有八九是缓存:Ctrl+Shift+R。或重启一次 DSH 服务 |
| 新对话不播 | 这个会话已经播过了(每个会话只播一次)。钉住它可变成每次都播 |
| 钉住了也不播 | 确认图钉是绿色;确认打开的就是被钉的那个会话 |
| 黑屏无画面 | 先看 moov 是否前置(见上「排错:视频是黑的」);再访问 /dsh-boot-animation/status.json 看片源;最后看浏览器控制台有没有解码错误 |
| 换了片没生效 | 片库里点完要有 ✓ 才生效;确认文件在 videos/ 里并点了「刷新」 |
| 只有一个会话播的不是你选的 | 那个会话可能有「仅本会话」覆盖:打开片库看顶部那条,点「取消(回到全局)」 |
| 某个会话的固定片头突然没了 | 它指的片段被移走或删除了 —— 该会话已回落到全局;status.json 里能看到 conversation-override-stale |
| 播到一半自己没了 | 25 秒看门狗(STALL_TIMEOUT_MS)超时 —— 通常还是 faststart 或解码太慢 |
| 想看到插件在干什么 | 把 src/client/index.ts 顶部的 DEBUG 改成 true 重新构建,控制台会打印每次决策 |
-
挂载点:
shell.overlay(帧级浮动层,kind: list,新增一格不顶替官方 UI)+sidebar.footer.action(页脚那个图钉和 🎛 片库入口) -
这个插件绝对不能用静态
inject—— 这是踩过的坑,别改回去。 客户端加载器把任何不是active的条目都当成致命错误:if (u !== "active") if (u === "pending") { … } if (o.length > 0) throw new Error(`web boot: ${o.length} entry did not activate …`)
静态
inject一旦有宿主给不出的服务,本插件 fiber 就永远 pending, 于是整个 GUI 打不开。实测事故原文:web boot: 1 entry did not activate dsh-boot-animation: pending (waiting for service: uisession)所以两个服务都用 cordis 的动态注入
ctx.inject(['slots','uiSession'], cb)—— 等待发生在子 fiber,我们自己的条目照常active。宿主给不出时插件静默闲置, 这对一个装饰性插件才是正确的失败方式。scripts/verify-client-boot.mjs专门断言"产物里没有静态inject",并覆盖四种服务情形。 -
当前会话来自
ctx.uiSession.adapter.current这个 React 友好的 store。 它的快照不是会话记录,而是解析后的描述符产物{ key, hooks, keyedHooks, props }—— 会话 id 在props.sessionId,会话快照在hooks.session -
「这是个全新对话」的字段,DSH 0.2.0 起改了位置:
hooks.session现在是SessionFace(ISession & ObservableSnapshot<SessionSnapshot>),空白标志在getSnapshot().blank;0.2.0 之前它是直接挂在 binding 上的blankBit,而 0.2.0 把它变成了private。 这两个字段都读(新优先),并且订阅那个 face 本身而不是采样一次 —— 旧写法的问题不是报错而是沉默:读一个不存在的字段得到undefined, 判断恒为假,于是「新对话自动播放」悄悄失效。同理,触发也不要求 "必须是切换会话的那一次渲染",否则会取决于两个 store 谁先落定。 -
「每次点开都播」实现为监听进入会话这个动作,而不是记"播过没有", 所以被钉的会话不受"已看过"记录限制
-
视频路由支持 Range(浏览器对媒体会发 Range;该给 206 却给 200 时有些播放器会拒绝播放)
-
媒体响应是
no-cache+ ETag,不是no-store:no-store让浏览器一个字节都不能留, 于是每次开片头都要重下整段,加载期间就是黑屏。no-cache表示"留着但要先问", 配合 ETag:没换片子 → 304 直接用本地副本(秒开);换了片子 → ETag 不同 → 重新下发。 这条的正确性由verify-routes.mjs断言(含"带旧 ETag 请求新片子必须 200"的反例) -
hook 只能在组件里调:
apply()是插件加载器调的,不是 React 调的,所以状态 全部住在AppRoot组件内。片库入口在图钉那个 slot、对话框在 overlay 那个 slot, 是两个独立的 React 根,用模块级libraryOpeners订阅集合桥接 -
片库的路由:
videos.json(列)、media/<id>(按 id 流)、select(POST 写选择或钉会话)、resolve.json("现在播谁")、boot.mp4(老路由,302 到具体片段,向后兼容) -
按会话覆盖是主机的一层,不是客户端的一层:
resolveActive(sessionId)把conversationOverrides[sessionId]排在最前面,客户端只把 session 带上(?session=)—— 优先级链因此仍然只有一份实现。mode=random是唯一绕过它的入口,因为那是调用方 指名要随机;mode=active/mode=selected都认这一层 -
conversationOverrides放进selection.json(v3)而不是另开一个文件:它就是用户选择, 而这个 store 是插件里唯一知道怎么原子写、怎么自愈损坏文件的地方,另开一个文件 等于把那两件事再抄一遍。键值两侧的校验和selectedClipId完全一样(路径一律被拒), 所以手改文件也无法从这张表里塞进媒体路径;表有上限(200),淘汰最久没被设置的那个 -
videos.json/status.json只公布发问那个会话的conversationClipId, 从不把整张表发给浏览器 —— 一个把用户钉过的每个会话 id 都吐出去的端点, 比一个只回答被问到的问题的端点差得多 -
内嵌片段的 id 是
builtin:<name>,与路径派生的 id 不会撞;它们的 ETag 用自身的 内容哈希("embedded-<sha256前16位>"),所以重校验是精确的、不依赖 stat -
同一个视频在多个位置时按内容去重(sha256;文件侧按 size+mtime 缓存哈希结果), 内嵌那份优先胜出 —— 它不可能被删掉,所以指向它的选择永远解析得到
-
prefix 路由不能带尾部斜杠:webserver 用
pathname !== prefix && !pathname.startsWith(prefix + '/')匹配,注册.../media/会被当成.../media//,永远匹配不上(曾导致 /media/ 全 404)
| 命令 | 作用 |
|---|---|
npm run verify:routes |
用服务器自己的匹配规则驱动真实 handler,断言每条路由 |
npm run verify:conversation |
按会话覆盖的完整行为:只对自己生效、不碰全局选择、压过随机但不压过 mode=random、片段失效时回落并记录、只公布发问者的钉住、校验与上限 |
npm run verify:letterbox |
用 CDP 驱动本机 Edge,量出所选贴合方式实际留多少黑边 |
npm run check |
上面全部 15 组(build / routes / selection / conversation / cache / blank / boot / preview / playback / random / fallback / install / teardown / version-refresh / session-id) |
npm run build:client |
先跑 CSS 检查再构建(防带病构建) |
两个脚本都是被真实 bug 逼出来的,各自都有过一次"用自己的规则测自己"的教训: 它们的断言刻意复刻被测方的规则,并且在提交前会做反向验证(故意改坏 → 必须报错)。
客户端构建有一个坑:整个 CSS 是一段模板字符串,注释里写一个反引号就会提前把它 结束掉,而报错是 TypeScript 的 parse error 指向某行 CSS,同时
lib/client.js保持不变 —— 看起来像改成功了其实没生效。scripts/check-css-template.mjs专门 拦这个,已接进build:client。
BSD-3-Clause,见 LICENSE。包内 lib/clips.data.js 里的内嵌片源以相同条款分发。