DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

ZhaoAndy821 /

ZhaoAndy821/dsh-motion-background

Verified

DSH WebUI 的动态背景插件:着色器型(GLSL 实时演算)与媒体型(MP4/WebM/GIF/图片)两类效果,每个效果是 mods/ 下的一个文件夹。Dynamic backdrops for the DeepSeek Harness WebUI.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@011d5017

dsh-motion-background

给 DSH WebUI 用的动态背景插件:把对话界面的底色换成实时演算的画面,或一段素材。

两类背景,在设置页里切换:

类型 怎么出画 需要什么
着色器型 WebGL2 + GLSL,逐帧实时演算 mod.json + fragment.glsl(纯程序化,不用素材)
媒体型 浏览器原生 <video> / <img> 铺底 mod.json 的 media 字段 + 素材文件(mp4 / webm / gif / webp / png / jpg)

两类可以混放,效果下拉框、回落与错误上报一视同仁。媒体型完全不碰 WebGL, 所以在没有 WebGL2 的环境里也能用。

效果不是写死在内核里的:每个效果是 mods/ 下的一个文件夹。 内核自身不含任何第三方着色器代码,全部 GLSL 由各效果自己的文件提供。 怎么加效果见下面的「加一个效果(扩展点)」。

这句"不含第三方着色器"的证据边界要说清:它建立在 "对若干公开着色器库抽出的 86 个函数名 + 179 个 uniform 名 + 2186 条特征行做全词匹配, 内核 0 命中;同款探测器在已知含第三方着色器代码的旧版皮肤上命中 14 个(阳性对照)"之上 —— 换来源、改名、或改写到无法按名字识别的代码,不在这个结论范围内。

检测方法本身写在 docs/KERNEL-REVIEW.md §A,本文档与那份都不列出 具体来源的名字。

开发环境:在 0.1.5-rc.2 的 DSH 上开发与验证;没有声明更宽的版本兼容范围。


三份文档,按你的角色读

你是谁 读哪份
要实现/重写内核 docs/KERNEL-TASK.md —— 自包含的任务书(契约 + 需求 + 验收 + 已踩的坑)
要独立审查本实现 docs/KERNEL-REVIEW.md —— 复现步骤 + 必查清单 + 证伪方法
要写一个效果 docs/MOD-FORMAT.md —— mod 契约

结构

lib/index.js        宿主半:扫 mods/ 目录,提供 GET /motion-background/mods 与 /motion-background/media/<id>/<file>
lib/client.js       客户端半:内核(渲染 / mod 加载 / 设置面板 / 配置)
mods/<id>/          一个效果 = 一个文件夹
  ├── mod.json      元数据 + 默认参数 + 面板映射(可选 range 量程 / order 排序 / media 媒体声明)
  ├── fragment.glsl 片元着色器(GLSL ES 3.00)——**着色器型**必需,媒体型不需要
  └── vertex.glsl   可选
docs/               上面三份 + 装配后的验收清单(POST-RESTART-CHECKLIST.md)与配置生命周期笔记
LICENSE             MIT 正文(mods/ 的许可另见各自 mod.json,见文末「许可」)
verify.mjs          自检(五组,可在无 DSH 的环境复跑)
mutations.mjs       反证驱动器(注入坏实现,要求断言变红)
FALSIFICATION.md    反证执行记录(由 mutations.mjs 生成,每次重跑覆盖)

两类 mod:

类型 出画方式 必需 例子
着色器型 WebGL2 + GLSL,程序化实时演算 mod.json + fragment.glsl mods/meteor
媒体型 浏览器原生 <video> / <img> 铺背景 mod.json 里的 media 字段 + 素材文件 mods/aurora-video

媒体型的意义之一是在没有 WebGL2 的环境里照样能跑(完全不碰 WebGL)。 两类可混装,扫描/下拉框/回落一视同仁。细节见 docs/MOD-FORMAT.md §2.2。

两类效果在面板里怎么区分

两者共用同一套控制栏(不是两套),所以效果下拉的名字后面会带一个类型标注: 程序化效果就叫「流星雨」,媒体型叫「极光(MP4 视频)」。标注由宿主半按素材扩展名派生 (media.label),不是写在 mod.json 的名字里 —— 换素材(mp4 → webm)标注自动跟着变, 也不会出现"标着 MP4、实际是 WebM"的谎报。

视频的播放模式(循环 / 往返)

选中视频型效果时,面板的「效果」下面会多出一个「播放」下拉框:

模式 行为 什么时候用
循环 播到结尾立刻跳回开头(原生 <video loop>) 素材头尾本来就衔接得上
往返 正着放一遍,再倒着放回来 —— 两端在同一帧折返 头尾不衔接时,避免循环接缝处"跳一下"

mods/*/mod.json 的 media.playMode 可以声明建议值(aurora-video 建议 pingpong), 但用户的选择永远优先,且会存进 localStorage 跨效果生效。

⚠️ 「倒放」没有原生实现,这不是取舍,是被规范挡死的:el.playbackRate = -1 在 Chromium 上直接抛 NotSupportedError(HTML 规范里 playbackRate 只接受非负值)。 所以往返的倒放段是脚本逐帧驱动:暂停元素,每个动画帧把 currentTime 往回挪 「距上一帧的墙上时间」那么多。(640×360 / 6s):速度约 0.99×、落点精确(误差 0)、 画面真的逐帧更新。代价是倒放段比原生播放略耗 CPU(verify.mjs 的 E19b 用像素指纹 钉住"画面真的在变",而不是只看 currentTime 这个标量)。

⚠️⚠️ 还有一个不能想当然的地方:倒放不许"每帧都发 seek"。 每个新的 currentTime 赋值都会掐掉在途的那次 seek,解码器于是永远从头开始 —— 越"努力"越卡。三种驱动写法的对照(各 1.2s 媒体时间):

驱动写法 发出 seek 真的落位 落位率 可见画面
每帧无条件发(最初版本) 156 6~14 ~5% 57/12(≈23 fps 幻灯片)
在途不发新 seek(现在) 23 23 100% 12/12(满帧)

⚠️ 上表各 1.2s 媒体时间;采样窗口拉长到固定 2.0s 后,同一个"拆掉闸门"的坏实现 落位率回升到 0.64~0.72 —— 因此判据下限取 0.9(正常实现 恒为 1.000,零浪费)。下限是校准出来的,不是取个看起来宽松的数: 早先取 0.6 时与洪水版只差 0.04~0.12,这条反证于是 flaky(同一条变异一次被抓、一次放行)。

两种写法的速度都是 0.97~1.0×(不是"快了慢了",是"流畅还是幻灯片")。 现在的驱动有一个 seek 闸门:上一拍没落位时不发新的,但把墙上时间攒着留到下一拍补回 (否则倒放整体变慢);另有 250ms 兜底超时,避免元素卡在 seeking 上导致永久停摆。 verify.mjs 的 E19b 直接量落位率(landed / assigns ≥ 0.9)来钉住这条契约 —— 只数"不同像素指纹"是统计量,这种洪水写法也能蒙混过关(40/82)。

📌 一个顺带的旁证:修复前 E19c 测到的往返闭环(正放 6s → 倒回 0)耗时约 14 秒, 修复后是 6.5 秒 —— 倒放段原本慢了一倍多,现在与正放段相当。 这也正是"中段生硬"的量化来源。

为什么往返模式下 loop 必须为 false:ended 事件只在非循环时触发,而它是"正放到头"的 唯一可靠信号。loop 若为 true,浏览器会自己跳回开头 ⇒ ended 永不触发 ⇒ 倒放支路根本不会启动。

加一个效果(扩展点)

一个效果 = mods/ 下的一个文件夹。加就是把这个文件夹放进去,删就是删掉它 —— 改完刷新页面即可,不需要重启 DSH(宿主半每次请求都重扫目录)。

原则

  1. 内核不认识"效果" —— 它只认契约:加载、编译、驱动绘制、渲染面板。 画面本身(GLSL、素材、参数、面板映射)全部待在你的文件夹里。
  2. 两类共用一个契约面 —— 着色器型与媒体型用同一份 mod.json,差别只在有没有 media 字段。 扫描、下拉框、回落、错误上报对两者一视同仁。
  3. 一个效果坏掉不影响别人 —— 编译失败或契约体检不过的会被摘出下拉框、记进 errors,整页不会挂。
  4. 不猜 —— 你没声明的东西内核不替你兜底:colors 非法直接判该效果不可用; panel 映射到不存在的 uniform 就把那个旋钮置灰(而不是给一个拖了没反应的滑块)。

要满足的条件

着色器型

  • mod.json + fragment.glsl(+ 可选的 vertex.glsl;缺省用内核自带的极简全屏 quad)
  • fragment.glsl 首行必须是 #version 300 es,必须声明并写入 out vec4 fragColor;
  • 用 in vec2 v_uv; 取坐标(0..1,左下原点)
  • 不得声明采样器(内核不绑定任何纹理;要随机就自己写 hash)
  • 保留 uniform(u_time / u_resolution / u_pixelRatio / u_colorBack / u_colorFront / u_colors) 的类型必须与契约一致,否则该效果判不可用
  • colors 必须是 "front" 或 "array"(非法值直接判该效果不可用,不静默兜底)

媒体型

  • mod.json 里含 media 字段(给了它就是媒体型,也不需要 colors)
  • 素材扩展名限 mp4 / webm / gif / webp / png / jpg / jpeg —— 不在表里判不可用
  • media.src 只能是本效果目录下的文件名:不许含路径分隔符、不许含 ..,否则整个效果不加载

两类共同

  • 文件夹名与 id 只允许 [a-z0-9-];id 按 mod.json 里的值去重(重复时按文件夹名升序取第一个)
  • 含第三方代码或素材的,必须在 mod.json 的 author / license 里声明来源与许可

扩展完之后能调什么

下面这些都由你在 mod.json 里声明:

你想控制 写在哪
面板上那四个旋钮分别接哪个 uniform panel(映射到不存在的 uniform ⇒ 该旋钮置灰)
每个旋钮的量程 / 步长 range,如 [0, 2] 或 [0, 2, 0.1](写坏了会兜底成合法区间)
旋钮初值 spec(内核不提供自己的默认值,免得盖掉你的设计)
哪个效果默认选中 order(升序,id 字典序兜底)
颜色从哪来 colors:"front" 单色系 / "array" 多色团 —— 都取 DSH 主题令牌,深浅主题自动跟随
效果在面板里的名字与副标题 name / description(name 里别写类型词,标注由宿主半按扩展名派生)
(媒体型)铺满方式 media.fit:cover(铺满、可能裁切)/ contain(完整可见、可能留白)
(媒体型)整体不透明度 media.opacity(超出 0..1 会被夹回)
(媒体型)叠在界面上的混合方式 media.blend: true ⇒ CSS mix-blend-mode: screen
(视频型)播放模式的建议值 media.playMode:loop / pingpong(用户在面板里选过之后以用户的选择为准)

不在上面这张表里的,目前改不了 —— 如实说清:

  • 绘制帧率与渲染分辨率是内核级常量,不是面板项,也不是某个效果能声明的。 要改就改 lib/client.js 里的三个常量并重启 DSH,见「资源占用」一节。
  • 媒体型不能通过 mod.json 设定视频帧率或清晰度 —— 那是素材文件本身的属性。 内核只按 media.fit 决定 CSS 的铺法(object-fit),不重编码、不改素材。
  • 用户在面板上能动的只有那几个控件(四个旋钮 + 面板浓度 + 播放模式 + 启用开关), 以及各效果自己声明的量程与初值。

完整字段表与 GLSL 契约见 docs/MOD-FORMAT.md。


资源占用

背景是全屏绘制,开销大致与「帧率 × 像素量」成正比 —— 所以内核给两者都设了上限, 并按窗口状态分档。下面是一个效果被选中之后,各状态下实际发生的事。

各状态下的档位

你的状态 着色器型 媒体型
选中它(dsh 窗口在前台) 绘制上限 30 fps;画布像素 = CSS 尺寸 × min(devicePixelRatio, 1) 浏览器原生播放,不占 WebGL
失焦(你在用别的窗口 / 两栏并排) 上限降到 15 fps 继续播(见下方「管不到的情况」)
标签页切走 / 窗口最小化 完全不画(0 帧) 暂停 <video>,切回来自动续播
prefers-reduced-motion 打开 完全不画(优先于上面所有档位) 不动它(交给浏览器自身策略)
关掉「启用」 画布被摘掉、WebGL 上下文回收 媒体元素被摘掉(真正释放解码内存)

三个数字对应 lib/client.js 里的三个具名常量:

# 常量 取值 管什么
1 FPS_ACTIVE 30 fps 窗口正常时的绘制上限
2 FPS_BLURRED 15 fps 窗口失焦(用别的窗口 / 两栏并排)时的上限
3 DPR_MAX 1 渲染分辨率:画布像素 = CSS 尺寸 × min(devicePixelRatio, DPR_MAX)
+ 媒体型可见性门控 document.hidden ⇒ pause() 标签页切走 / 窗口最小化时停掉 <video>,可见时自动续播

为什么要设这些上限

症状(最初是怎么发现的):开着 dsh 的 WebUI,切到别的窗口后移动鼠标、点击都特别卡; 把两个窗口并排时也一样。

根因(不是 CPU —— 全程 CPU ≤0.6%)。两层,外加一个盲区:

  1. 帧率不受控:requestAnimationFrame 会跟着显示器刷新率跑 —— 在 240 Hz 屏上是 240 次/秒;且窗口失焦时浏览器不会停 rAF(自动节流只管"标签页不可见"与 "窗口被完全遮挡","失焦但仍可见"这一档不管)⇒ 你用别的窗口时它还在后台全屏重绘。
  2. 像素量太大:2560×1600 屏 + Windows 缩放 150% ⇒ devicePixelRatio = 1.5 ⇒ 全屏画布 3840×2400 = 920 万像素/帧。只限帧率不够 —— 30 fps 时 GPU 仍占 86.9%。
  3. 盲区:媒体型(视频)不被帧率闸管到:它的 frame() 是空实现(媒体由浏览器驱动), 而这里的视频是 muted ⇒ Chromium 对静音视频在后台标签页不会自动暂停 ⇒ 切走后它继续解码。这一档只能靠可见性门控单独管。

分辨率与帧率对照(数 gl.drawArrays 与系统 GPU 计数器)

场景 修复前 修复后
前台绘制帧率 240 fps 30 fps
失焦绘制帧率 240 fps(照跑不误) 15 fps
Chrome GPU 进程占用(前台) — 86.9% → 17.4%
本机 GPU 总负载 接近饱和(≈99%) ≈56%

分辨率那一项比线性预期还好(44% 像素 ⇒ 预期 38%,17.4%)—— 低分辨率下 shader 更省,不是线性的。

观感(必读)

  • 两栏并排时,你会看到流星雨"慢下来" —— 那不是卡,是 FPS_BLURRED 在生效(15 fps)。 它换来的正是"你在别的窗口里不卡"。
  • 降分辨率看不出来:这是慢速模糊的氛围背景,DPR_MAX = 1 在 2560×1600 屏上 "背景没有糊"。想更锐可以调到 1.25 / 1.5,GPU 会同比例上升(1.25 ⇒ 像素 ×1.56 ⇒ 约 27%)。
  • 切回来立刻恢复 30 fps —— 闸门是"跳过绘制但保留续帧",不需要等下次交互。

使用限制(这些情况它管不到,别当成 bug)

  • "失焦但仍可见"这一档不适用媒体型:并排看视频时它继续播。取舍理由:并排时视频是 看得见的,暂停比继续播更突兀;而硬解全屏 MP4 的开销远小于全屏 shader 重绘。 (标签页切走/最小化仍然会停 —— 那一档是明确该停的。)
  • 判据是 document.hasFocus():多显示器、虚拟机、远控(如 GameViewer)等环境下, 焦点语义与常规不同 ⇒ 分档可能"看起来没生效"。此时它退化为"始终按 30 fps 跑",不会更差。
  • 用户手动把窗口调小 ⇒ 像素量自然下降,上限档位不变。
  • prefers-reduced-motion 打开时,着色器型完全不画(这一条优先于上面所有档位)。

要调的话改哪儿

三个常量都在 lib/client.js 的 createSurface 之前,改完必须重启 dsh(见下方"坑"):

const FPS_ACTIVE  = 30;   // 前台绘制上限
const FPS_BLURRED = 15;   // 失焦上限("不和前台抢"的核心;调低更让路、调高更顺滑)
const DPR_MAX     = 1;    // 渲染分辨率上限(GPU 占用与它近似成正比于 DPR²)

⚠️ 不要去掉任何一道上限。去掉帧率上限意味着它跟着显示器刷新率跑;去掉分辨率上限 意味着 4 倍像素量 —— 两者都会把用户的桌面一起拖慢。

⚠️⚠️ 改了 lib/client.js 之后,必须重启 dsh,然后再刷新页面(踩过)。 dsh 给客户端 bundle 的响应头是 cache-control: public, max-age=31536000, immutable, 而 URL 里的 rev(内容哈希)只在 dsh 启动时算一次。 ⇒ 只改文件、不重启:服务端内容已是新版(curl 能拿到新版),但 rev 没变 ⇒ 浏览器按 rev 命中 immutable 缓存 ⇒ 永远用旧代码,连普通刷新(F5)都绕不过。 判据:curl 模块表看 rev 有没有变;没变 = 没生效。 (症状表现:自己用无缓存的新实例测是好的,用户那边"依旧卡 / 依旧没变"。)

两条方法论(后人别重复踩)

⚠️ 测这个量必须数 drawArrays,不能数页面 rAF 频率:节流的设计是"跳过绘制但保留 续帧"(这样重新获得焦点能立刻恢复,不需要额外的 focus 监听),所以 rAF 频率本来就不变 —— 拿它当判据会得到一条永远通过的断言。

⚠️ 必须做"前后台对照"才能定位:dsh 页面不在前台时它的 GPU 占用是 0(浏览器把 rAF 停了)—— 只测一个状态推不出结论。并排比"在前台 / 不在前台"两个数,才知道是谁在吃。

⚠️ 自动化断言未覆盖(如实记录):verify.mjs 里没有这些上限的断言。原因是那个夹具 的动画时间轴不跑 —— 加了全套诊断后:hook 自检为真、live=true、mod 已挂上、 probeMods() 能真画一帧、hidden=false / hasFocus=true、prefersReduce() 为假, 但 2 秒窗口内 drawArrays 0 次(E 组其它用例也都靠 freeze 单帧或 paint() 显式重绘, 从不依赖"动画自己跑")。与其留一条在 0 帧时也通过的恒真断言(本项目最反对的假绿), 这里如实不写。要把它变成断言,得先找到一个能驱动动画时间轴的夹具(未做)。

✅ 但媒体型的可见性门控有天然保护:现有的 E19 系列(~20 条,覆盖倒放 / 播放模式 / reduce-motion)会在门控写错时直接变红 —— 加这一层前后它们都是 233/0。

为什么需要宿主半

客户端插件是单文件 bundle、跑在浏览器里,没有文件系统 —— 而"读 mods/ 目录" 是可插拔的前提。所以由宿主读目录、经一个 HTTP 端点交给客户端。 (纯样式类插件可以不要宿主半;这个不行。)

媒体型还需要第二个端点把素材字节交给浏览器。它只服务白名单扩展名、 路径必须是固定两段的 /media/<id>/<文件名> 且文件名不许含 .. 或分隔符 (不合格一律 404 且不泄漏目录内容),并必须支持 HTTP Range —— 浏览器播 mp4 时先发 Range: bytes=0- 探测,不支持 Range 会导致视频不播或不能循环。

跑自检

前置:自检需要一个 headless 浏览器,本仓库不把它列为依赖(运行时零依赖)。 先任选一种方式备好:

npm i -D playwright-core                 # 方式一:装在仓库里
# 方式二:用环境变量指到你已有的那一份
PLAYWRIGHT_CORE=/path/to/playwright-core node verify.mjs

找不到时 verify.mjs 会直接失败并打印上面这两条出路,不会悄悄跳过 —— 静默降级成"用假数据测通过"正是它要防的事。

node verify.mjs        # 五组断言(宿主半 / 健壮性 / 客户端真跑 / 坏 mod 不崩 / 行为级)
node mutations.mjs     # 反证:1 次基线 + 注入 20 种已知坏实现,要求断言**变红**,并写出 FALSIFICATION.md

verify.mjs 会:真 import 宿主半并用假 req/res 喂它、在临时副本里塞各类坏 mod 验证隔离性、 在 headless Chromium 里真跑客户端并数像素、注入非法 GLSL 确认内核不崩;第五组(E)再真调用 设置卡片组件树逐项检查四个旋钮(初值 / 量程 / 置灰)、真驱动 onChange 看运行时 spec 与 drawArrays 计数、读浅色与深色两种主题下的像素、并数 loseContext 确认卸载回收了 WebGL 上下文。 媒体功能另有 E18 组:真挂载 <video> 并核验 readyState / 尺寸 / muted / loop / currentTime (真在播)、加载失败要判不可用、媒体坏了要回落着色器、切走后 DOM 里不留 <video>。 播放模式另有 E19 组:往返模式 loop 必须为 false、正放播完后方向真的翻转、倒放段 currentTime 单调递减、像素指纹真的在变("倒放"与"只改了时间标量"的区分手段)、 seek 落位率 ≥ 0.9("流畅倒放"与"每帧洪水式发 seek 退化成幻灯片"的区分手段 —— 这个量直接拦 currentTime setter 数发出次数、数 seeked 事件数落位次数)、 倒放速度 0.8~1.2× 与 可见帧率 ≥ 10 fps(后两条见下方"三个判据缺一不可")、 往返能闭环回到正放、「播放」下拉只在视频型出现且选择会落盘、类型标注出现在下拉的显示文案里。

三个判据缺一不可

只给 E19b 加「落位率 ≥ 0.6」是不够的 —— 下面这个坏实现能全绿: 把闸门换成纯时间限流(if ((now - lastIssue) < 400),不看 seeking)—— 速度掉到 0.62×、可见帧率只有 2.5 fps(比修复前还差),却没有任何断言在拦它。

两条坏实现各命中不同判据:

判据 正常实现 拆闸门(每帧发 seek) 纯限流 400ms
落位率 ≥ 0.9 1.000 0.648 → 红 1.000(5 发 5 中)✅ 过
速度 0.8~1.2× 0.996× 1.000× ✅ 过 0.596× → 红
可见帧率 ≥ 10 fps 49.9 fps 43.1 fps ✅ 过 2.4 fps → 红

⇒ 三者互相独立、各抓一种坏法:落位率管"别洪水",速度管"别限流", 帧率管"用户看到了什么"。没有任何一条能单独顶替另外两条。 (上表也说明为什么不能用"落位率"去抓限流版 —— 它发得少、反而全部落位;反过来 "速度"也抓不住洪水版。这就是"三件套缺一不可"的实证依据 —— 判据数不等于覆盖度,判据之间是否互相独立才是。)

⚠️ 关于落位率 >1 的已知残余:"开窗前先 await waitSeeked() 再清零" 这一步没有修干净 —— waitSeeked 自带 200ms 超时,超时后仍会带着在途 seek 开窗, CPU 降速下可复现 rate=1.045。判据只要求 ≥ 0.9(不要求 ≤1),所以不影响结论; 此处如实记录,不再假称已修掉。

报告器是 fail-closed 的:任何阶段抛错都会打印「验证未完成」并非零退出,不会打印成功标志。

⚠️ "现在是绿的"不构成验证。mutations.mjs 是验收的另一半:基线必须绿,而每条变异必须让对应的 断言变红。两者都过,才有资格说这套断言在观察事实 —— 本仓库早期就吃过这个亏: 注入 5 处坏实现,93 条断言仍然全绿。

写反证(变异)时必须注意的坑(都已踩过)

坑 1:变异"太弱" —— 删掉一处实现 ≠ 删掉这个能力。

el.play() 在 lib/client.js 里有两处(createMediaSurface().boot() 与 applyConfig())。 只干掉 boot 那处,mountSurface() 紧接着调的 applyConfig() 照样把视频播起来 ⇒ currentTime > 0 依旧成立 ⇒ 断言全绿、还打印成功标志。 记录里当时的原话是:media-noplay | exit=0 | 187/0 | ❌ 反证失败(exit=0 red=0 hit=0 且打印了成功标志)。

教训:做变异前先确认这个能力在源码里一共几处。现在 media-noplay 用循环替换 一次干掉全部,并在命中数 < 2 时抛错。

坑 2:变异"根本没施加" —— 表现与坑 1 完全一样。

改上面那处时我误删了 media-keep-el 与 media-loud 两行 patch() 调用, 于是这两条变成"变异没施加",输出同样是 exit=0 / 全绿 / 打印成功标志。 "没施加"和"太弱"在结果上无法区分,都是假 PASS。

教训:现在有一道总闸 —— 若某条客户端变异跑完却一处都没改到,直接抛错; 另有"锚点出现次数 > 1 时打印警告"的守卫。两道都验证过真的会触发。

坑 3:总闸自己也会假绿 —— "打了标记"不等于"能力被拆掉"。

第一版总闸只检查 from.includes('(变异:'),而 patch() 无论替换成什么都会追加这个标记。 于是把 el.muted = true 换成等价写法 el.muted = !!1,总闸照样放行、断言 187/0 全绿 —— 它声称"能力被拆掉",实际只断言了"我来过" —— 这正是最难发现的一类假绿。

教训:总闸改为按能力核对削减数 —— 每条变异要声明它该让哪个源码特征减少几处 (removeAll / minRemoved / mustIntroduce),闸门拿变异前/后的计数差来判。 只数削减还不够:=> !!1 同样能让计数掉 1,所以凡"削弱点是一段字面量"的还必须 引入预期的弱实现。这套现在能抓住等价替换(改造前会放行)。 宿主半(hostSource())走同一套纪律。

坑 4:连跑整套会被"删除配额"打断 —— 症状像环境抖动,其实是机制性的。

现象:套件跑到中途起,之后每条都记成 ERROR(本轮没跑完),而单跑任一条却完全正常。

根因(查了垫片源码):调用方(Agent 运行时)经 NODE_OPTIONS 给每个 node 进程注入 safe-delete 垫片,它把 fs.rmSync / fs.rm / fs.unlink / fs.rmdir 换成"移入回收站",并按对话回合累计删除项数(scope:'turn';totalCount = 已用 + 本次项数; 达阈值即要求确认)。

⚠️ 这是调用方 Agent 施加的限制,不是这个插件本身的、也不是操作系统的通用行为 —— 换个不带该垫片的运行时跑,裸 rmSync 完全正常。换言之,运行本插件的那个 DeepSeek Harness 本身不一定有删除配额(配额取决于"谁在调它",不取决于本插件);这段是"遇到才需处理", 不是"必然发生"。

关键在越线之后每一笔删除都会被拒并抛错(不是只拦超标那一笔)⇒ 之后无论删多小的目录都炸。 多次 verify.mjs 共享同一回合的额度,于是前半程正常、后半程连续报"跑不完"。

⚠️ 阈值可配置(有一个默认值,本机被上调过若干次)—— 所以文档里不写任何具体阈值: 数字会随环境变。可断定的是机制本身。

对照(同一回合内):裸 rmSync 删 1 个目录 ⇒ 被拒并抛 SAFE_DELETE_BULK_CONFIRM_REQUIRED; 走 rmrf()(PowerShell Remove-Item)⇒ 删成功,且回合计数原值不变。

修法:verify.mjs 里清自建夹具/副本一律走 rmrf()。裸 rmSync 只允许出现在 rmrf() 内部 作为 PowerShell 不可用时的兜底。

⚠️ 这个坑还有一个认知陷阱:旧注释把原因写成"钩子会拒绝超过某个项数的单次递归删除"。 方向错了 —— 按那个理解会去"缩小单次删除量"(无效),因为真正超限的是回合累计。 排查时不要停在"看起来合理的因果"上:报错里的 count 远小于一次 buildCopy() 的项数, 这个数字本身就是"它数的是累计量"的线索。

写反证时的第三个陷阱:让无关组替被测代码背锅

反证跑不完(ERROR)与反证不成立(❌ 反证失败)都算失败,但成因完全不同 —— ERROR 说明脚本自己崩了,那时没有任何断言被观察过,容易把"没测"误当成"测过了"。

踩到的一次:为了让路径类变异真的打在副本宿主半上,把 buildCopy() 提到 A 组之前; 于是 --mutate=no-isolation(见坏 mod 就中断整轮扫描)让 A 组的载荷塌成空, 而 C 组开头有一句 payloadC.mods.length === 0 ⇒ throw ⇒ 整轮在 C 阶段抛错, 被记成 ERROR 而不是"隔离性断言变红"。

根因是耦合:宿主半的故障归 A/B 组负责,却把只测客户端的 C/D/E 一起拖停了。 解法:C/D/E 的载荷改由 payloadForClient() 结算 —— 若 A 组载荷里没有 meteor, 就改用真仓库宿主半另取一份,并显式打印回落原因(回落必须留痕,否则就成了"悄悄换掉被测对象")。

写媒体安全断言时必须注意的坑:多道闸互相重叠

/motion-background/media 的路径校验是多道互相重叠的闸:① 段数必须为 2、 ② 文件名不许含 ../分隔符、③ 扩展名必须在白名单里。 随便一条非法路径通常被其中好几道同时拦住 —— 于是"拆掉某一道闸"时,那些断言照样全绿, 看起来像"断言很稳",其实是假绿:它们根本没在观察那道闸。

踩过两次:

  1. 原来 18 条穿越向量全都够不着闸②(各被 ①/③ 兜住)⇒ 拆掉闸② 零红。
  2. 修上面那条时,给"闸①"打的标签也是错的 —— 那些向量在闸③/id 就死了, 拆掉段数校验后没有一条变成 200。

⇒ 现在的做法是:每道闸都配「只有它拦得住」的向量,且每条向量的落点文件必须真实存在 (否则拆掉那道闸后 statSync 会兜底 404,反证又静默失效)。落点存在性不能靠推理, 要在真目录里算一遍(有 2 条"看着合理"的向量落点其实不存在)。 verify.mjs 里有一条结构性断言按闸分别计数,谁删掉某道闸的证据向量就会立刻变红。

装进 DSH

从仓库直装(github: 走 pnpm 原生协议):

dsh plugin --profile web add github:ZhaoAndy821/dsh-motion-background

用本地克隆装(想改代码时):

  1. 在 DSH profile 的 package.json 里加 link 依赖与 bundle 项
  2. 在 profiles/web/node_modules/ 下建指向本目录的 junction
  3. 重启宿主(因为改了 dsh.client.inject,只刷新页面不够)

装好后按 docs/POST-RESTART-CHECKLIST.md 逐条验收 —— 六条 curl 自查 + 面板人眼确认 + 回滚步骤。

📌 两层,别混为一谈:profile 的 cordis.patch.yml 是热生效的 —— HMR 用 chokidar 监听 该文件,一变就重新组合(composeLive() 每次从磁盘重读),不必等重启。 而新增 / 卸载插件动的是 bundles 层,那是启动快照,必须重启。 详见 docs/CONFIG-LIFECYCLE.md。

📌 不要靠手改 dsh.profile.bundles 来停用某个插件 —— reconcilePlugins() 会在每次 dsh plugin 命令之后,把所有声明了 dsh.bundle 的 dependency 重新加回 bundles。 要停用就在 patch 层给它一条 disabled: true;那一条同时挡住宿主半与客户端半 (客户端模块扫描器的判据里含 !entry.disabled,停用的条目根本不进模块表)。

许可

根目录 LICENSE 是 MIT 正文,适用于内核与仓库自有文件 (lib/、docs/、verify.mjs、mutations.mjs、README.md)。

  • mods/ 明确排除在上面那条总括之外:每个 mod 的许可看它自己的 mod.json (mods/meteor 是 MIT;mods/aurora-video 的素材是 ffmpeg 自产的测试视频,声明为 CC0-1.0)。 引入第三方代码/素材的 mod 必须在 mod.json 里声明来源与许可,且不得把第三方代码带进内核。
  • ⚠️ "内核里没搜到某来源的着色器代码"不等于整个仓库具有再分发权:再分发还取决于仓库内其他资源 与各随包 mod 的许可条件。上面这条分工是把"哪些文件适用哪个许可"写清,不是对第三方的授权声明。
—/ 5

No ratings yet

Verified DSH bundle

Commit 011d50177bb3

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout