← Back
NativeDog1

NativeDog1/dsh-boot-animation

打开新对话、或打开你钉住的会话时,视频铺满整个窗口播放一段片头开机动画。支持换成自己的视频,每个会话只播一次或每次打开都播

View on GitHub ↗
Stars
192
Forks
15
Watchers
192
Open issues
5
Contributors
2
Language
JavaScript
License
Other
Default branch
main
Created Sep 25, 2026Updated Sep 30, 2026

Star growth

Today—
This week—
This month—

Star history will appear here once this repo has been tracked for a couple of days.

README

dsh-boot-animation

给 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 版本

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 web

装完看不到效果?先做这件事

DSH 的客户端 bundle 响应带 cache-control: max-age=31536000, immutable, 而 URL 上的 rev 是进程 nonce、不会随内容变化。所以浏览器会一直用第一次抓到的副本。

装好或升级后请在新窗口里按 Ctrl+Shift+R(硬刷新)。普通 F5 不够。

用法

新对话自动播

打开一个还没说过话的新对话时会自动播一次。

让某个会话每次打开都播(推荐)

  1. 打开那个会话
  2. 点侧边栏最下面的 🎞 图标(就在「设置」旁边)
  3. 图标变绿 🎬 = 已钉住

之后每次进入这个会话都会播一遍 —— 切走再切回来、刷新页面,都会重播。

再点一下图标取消。

注意:如果刚启动时你的活动主面板不是「对话」(比如停在某个插件的面板上), 当前会话还不存在,图钉是禁用状态。先打开一个对话即可。

只给某一个会话换片头(0.4.0 新增)

上面那个 🎞 图钉管的是「什么时候播」;如果你要管「这个会话播哪一段」, 用片库里的 「仅本会话」:

  1. 打开那个会话
  2. 点页脚 🎛 打开片头片库
  3. 在你想播的那一行点 「仅本会话」

那一行会变成蓝底并挂上「本会话」徽章,面板顶部也会写明「本会话固定播放: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。(临时试片不用这么麻烦 —— 见下面的「最省事的方式」。)

最省事的方式(推荐)

  1. 把 mp4 丢进 ~/.dsh/boot-animation/videos/
  2. 在侧边栏页脚点 🎛(在 🎞 图钉旁边)打开「片头片库」
  3. 点一下你想播的那一条

选中的那段会在下一次播放片头时登场:新对话、以及你钉住的会话。

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,都要求用户手势,任何网页都绕不过。所以:

  1. 动画以静音在铺满窗口的覆盖层里自动开始(视觉上已经是全屏)
  2. 点一下画面:同时开启声音并进入真全屏
  3. 万一连静音自动播放也被拒,会显示「点击播放」而不是黑屏

排错

现象 原因 / 处理
完全没出现 十有八九是缓存: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 里的内嵌片源以相同条款分发。