# 代码审查报告(2026-10-29) > 本文保留上一轮审查结论;后续发现、修复、验证及第四节待办的最新处理见 [后续全面审查](audit-followup-2026-10-29.md)。联播提示和缺失 Buffer id 的处理已完成;背景库搜索后按用户要求移除。 审查对象:`dsh-shadertoy-skin` **0.4.3**(`main` @ `bafcfb0`)。 范围:Host half、client half、WebGL2 渲染器与 Worker、设置页、工具链、CI/CD、README 与文档。 方法:逐文件通读 + 定向检索风险模式 + 全量回归套件复跑(本地 msedge,CI 为 chrome + SwiftShader)。 本报告只记录结论;决策与取舍另见 [`00-决策与方案复核.md`](00-决策与方案复核.md) §9.18。 ## 结论 - **确定的缺陷 16 项已全部修复**(两轮审查),每项都有对应回归或可复跑的检查。 - **CI/CD 5 处结构性改进**:语法检查全量化、浏览器套件并行化、`surfaces`/`transparency` 纳入 CI、发布流程改为「先全量验证再发布」、新增独立的 TypeScript 类型检查 job。 - **TypeScript 7 作为纯类型检查器接入**(不重写成 `.ts`、不加构建步骤),详见本节末与决策记录 §9.19。 - **四项产品决策已按用户意见处理**:音乐结束判定做了「图像是否参与」的研究并改为**音画按较晚结束一方联播**(§9.20 / §9.22);`init` 乱序用 revision 修掉;商店截图改手动维护(§9.21);不提供英文界面。 ## 一、已修复 | # | 优先级 | 问题 | 位置 | 修法 | 回归 | |---|---|---|---|---|---| | 1 | P0 | `npm run check` 在 npm 12 上必然失败:`npm pack --dry-run --json` 由数组改为「以包名为键的对象」,`[0].files` 取到 `undefined` | `tools/check-bundle.mjs` | 新增 `packReportFiles()` 同时接受两种结构;补 README / LICENSE / `assets/`、`screenshots.json` 打包断言 | 本机 npm 12.2.1 下 `npm run check` 302/302;CI npm 11 走数组分支;parser 自检两条形状 | | 2 | P0 | `test:multi` 偶发 30 s 超时(CI 在一条 docs-only 提交上红过):面板状态同步与测试注入的 `init` 竞争,控制器 100 ms 去抖会顶掉已排队的 preset | `tools/multipass-regression.mjs` | 注入后 3 s 内未生效就重发(最多 10 次),失败时断言给出明确原因 | `npm run test:multi` 连跑通过;修复前可稳定复现 | | 3 | P1 | 音频设备不可用/异常会走 `stop()`,把整个背景(含渲染 Worker)杀掉 | `native-dist/player-controller.js` | `audioFailure()` 只拆音频链路并通过 `audio` 状态上报;渲染继续 | `test:worker` 新增「坏 `AudioContext` 下渲染继续、上报 audio error」 | | 4 | P1 | `iChannelTime` 对 font / noise 等纹理通道返回当前帧时间,ShaderToy 语义应为 0 | `native-dist/pipeline.js` | 纹理通道给 0,仅 buffer 通道给自己的 pass 时间 | `test:multi` 注入「`iChannelTime[0]==0 && [1]==0`」的 shader,帧数 > 10 后读回像素 | | 5 | P2 | 键盘纹理每帧整块上传:`endFrame()` 无条件 `keyboardDirty = true`,等于每帧 3 KB `texSubImage2D` | `native-dist/pipeline.js` | 只有「本帧按下」行确实非零时才置 dirty | 既有 keyboard/voxel 预设渲染回归 | | 6 | P1 | `resize()` 内的帧缓冲分配异常会抛进消息处理器和 rAF 循环 | `native-dist/player-runtime.js` | 捕获、保留上一次可用尺寸、只报告一次 | `test:worker` / `test:multi` 全绿 | | 7 | P2 | 大型背景库下设置页卡顿:拖滑块的每个 `input` 事件都把整份状态(最多 1.5 MB 代码)序列化进 localStorage 恢复日志,并重建背景下拉 | `native-dist/panel.html` | 恢复日志只在 320 ms 防抖后的真实保存里写;下拉只在选项或当前作品变化时重建;代码页「未保存」改为 extra 文件 dirty 标记;行号仅行数变化时重建;粘贴/代码草稿改为「首次立即写 + 300 ms 节流 + pagehide 冲刷」,立即刷新也不会丢草稿 | `test:panel` / `test:ui` / `test:editor` / `test:project` / `test:audit`(刷新后草稿恢复) | | 8 | P2 | 设置页细节:两处「音乐时长」同名标签;resize 事件逐次全量刷新;主栏模式仍是 `role="dialog"` | `native-dist/panel.html` | 滑块行改名「时长(秒)」;resize 合并到每帧一次;主栏模式改 `role="region"` 并用页头标题作 accessible name | `test:ui` / `test:surfaces`(含 390 px 窄屏) | | 9 | P2 | Host 只校验 entry id 非空,长度无上限 | `index.js` | id `trim().slice(0, 200)`,所有引用改用清洗后的 id | `test:host` | | 10 | P2 | README 预设数量过期(11 → 12;第三方「八个」→「九个」) | `README.md` | 三处改齐 | 人工核对 `native-dist/presets/index.json`(12 项,3 MIT + 9 第三方) | | 11 | P1 | `pipeline.resize()` 非事务:逐个 pass 删旧 target 再建新 target,中途帧缓冲分配失败会让 pass 没有 target,而 `width/height` 已更新,之后每帧 `channels()` 都抛异常 | `native-dist/pipeline.js` | 先分配完整新一代替换集,全部成功后再删除旧 target;失败时清理已分配资源并保留旧尺寸;`channels()` 额外守卫空 target 列表 | `test:renderer` / `test:multi` / `test:audio`(真实 WebGL) | | 12 | P1 | `init` / `params` 消息没有序号:延迟送达的「上一个作品」的 init 可能覆盖刚切换的新作品(CI 的 `test:multi` 假失败就是它的表现) | `client.js` / `native-dist/player-controller.js` | client 给每条 init / params 打单调递增 revision,控制器按「两条独立流」丢弃比已应用版本更旧的消息;重试复用同一 revision | `test:worker` 新增「乱序 init/params 被忽略」回归 | | 13 | P1 | 画面与音乐各自回绕:`take-on-me` 画面 209 s、音乐 180 s,音乐每 180 s 重播一次,而画面还在结局段落里,越播越不同步 | `native-dist/player-runtime.js` | 两边结束点都已知时按较晚的一方联播(`jointLoopPeriod()`),音频在音乐结束后输出静音直到联播周期再回绕;只有一方已知时保持独立 | `test:multi` 注入两组显式时长断言 `loopPeriod` | | 14 | P2 | 联播周期在非 1× 速度下单位不一致:`state.time` 是倍速缩放的画面时钟,音乐是实时时钟,`max()` 会对应不同真实秒数 | `native-dist/player-runtime.js` | `jointLoopPeriod()` 在 `speed !== 1` 时返回 0,退回各自独立循环 | `test:multi` 断言 2× 下 `loopPeriod === 0` | | 15 | P2 | 设置页自动保存时整份状态被序列化两次(请求体 + 恢复日志,最大 1.5 MB 代码) | `native-dist/panel.html` | 恢复日志直接存请求体的 JSON 字符串;旧的对象格式日志仍可恢复 | `test:panel` / `test:audit` / `test:editor`(含刷新后草稿恢复) | | 16 | P2 | 每次切换作品都重新生成 font SDF(两份 512² 距离场)、noise 表和程序化城市再上传 | `native-dist/textures.js` | 模块级缓存只读的生成结果;键盘纹理保持每管线独立、导入图片不缓存(可能较大) | `test:multi` / `test:renderer` / `test:project`(真实 WebGL) | ### TypeScript 7 类型检查(不是重写) - 加入 `typescript@7.0.2`(精确版本)与 `@types/node@24`,`tsconfig.json` 用 `allowJs` + `checkJs` + `strict` + `noEmit`,覆盖 `index.js`、`native-dist/project.js`、`pipeline.js`、`textures.js`。 - 包仍然是零构建的纯 `.js`:不产出 `.d.ts`、不改 npm 包内容、不改「改文件刷新即生效」的开发流程。详细取舍见 [`00-决策与方案复核.md` §9.19](00-决策与方案复核.md)。 - `npm run typecheck` 已接入 `npm test` 与 CI;类型化过程中发现并修复了上面第 11 项。 ## 二、CI/CD 改动 1. `ci.yml` 语法检查从手写文件清单改为 `git ls-files '*.js' '*.mjs' '*.cjs'` 全量:此前 `tools/inspect-live.mjs` 等从未被检查过,新增脚本也要人工补清单(当时的 `make-market-assets.cjs` 后来也拆成了只生成图标的 `make-icon.cjs`)。 2. 浏览器套件从「一个 job 串行跑 10 个套件(约 13 分钟)」拆成 **12 个并行 job**(`renderer` / `audio` / `panel` / `ui` / `audit` / `core` / `project` / `multi` / `worker` / `editor` / `surfaces` / `transparency`):失败按套件命名、互不阻塞、单套件超时 15 分钟。 3. 把原先「只在本地跑」的 `test:surfaces`、`test:transparency` 纳入 CI(两者都只用 `browser-harness` + SwiftShader,本机已复跑通过)。 4. `publish.yml`:发布前先并行跑同一批 12 个套件(`verify` matrix),**全部通过**才进入 `publish`;`publish` 只保留 tag/版本校验、`npm test` 与 OIDC `npm publish`。原来的串行套件接近 15 分钟超时线,且与 CI 重复。 5. `docs/PUBLISHING.md` 同步两条流水线的描述与 npm 11/12 `pack --json` 差异说明。 6. 新增 `typecheck` job(`npm ci` + `npm run typecheck`),发布前的 `npm test` 同样包含类型检查。 ## 三、核对过、确认没有问题的部分 - **Host half**:路由前缀/方法校验、`resolveInside` + `realpath` 双重边界(含 Windows 大小写归一)、状态 PUT 的串行原子替换、独立临时文件、ETag/If-Match 409、save-id 重放幂等、本地预设列表的符号链接与大小限制、`/state` 损坏时保留原文件。 - **client half**:玻璃 token 只覆盖语义背景 token、不碰静态色板;主题观察器避免 `theme/change` 回环;`tokensIntact` 按值修复;启动自愈(iframe / glass / sidebar / player init)与 safe-mode 逃生阀;鼠标隔离(UI 目标、文本选区、滚动条、拖拽阈值、pointer capture)。 - **渲染器**:ES3 → ES1 回落、uniform 重定义跳过(最多 8 次)、编译错误保留旧 program、`@duration` 与 fade 结束探测、多 pass 的 96 MB/8MP 预算、`EXT_color_buffer_float` 缺失报错、上下文丢失/恢复与 1 分钟熔断、Worker 看门狗与 `transferControlToOffscreen` 隔离。 - **音频**:4096 样本块、16-bit 打包、lookahead/prebuffer、`once` 模式排空尾音、loop 接缝淡入淡出、自动结束探测、静音时跳过合成(未知时长仍合成以探测结束)。 - **项目格式**:`normalizeCode` 的 include 循环/文件名/图片内嵌校验、`sourceFor` 2 MB 展开预算、ShaderToy JSON 的 Common/Buffer/Image/Sound 解析与不支持输入的明确报错。 - **设置页**:自动保存队列、409/413/502 等错误文案、恢复日志与 draft、内置作品「另存为副本」、导出/导入、行号/字号/自动换行/专注编辑/高度持久化、键盘可达的下拉与页签。 - **打包与许可**:`files` 白名单与 `npm pack` 清单一致、第三方预设的署名/许可/来源校验、`docs/` `tools/` `assets/` `screenshots.json` 不进包、OIDC provenance 链路已验证。 ## 四、第二轮审查(同日补充) 用户提出「按音乐和图像两者最晚结束的一方判断」后重新审了一遍时间轴、状态保存与纹理生成,又修了 4 项(#13–#16),并整理出后续可做项。 ### 本轮修复(详见上表 #13–#16) - **联播周期**:音画都已知结束点时按 `max(画面, 音乐)` 一起回绕(1× 速度下)。这是「最晚结束一方」的落地方式,修掉了 `take-on-me` 音乐在结局画面上提前重播的真实不同步。 - **速度单位**:倍速下联播周期会变成不同的真实秒数,改为只在 1× 启用,其他速度退回独立循环。 - **恢复日志双序列化**:自动保存不再把整份状态序列化两次。 - **生成纹理缓存**:font SDF / noise / 程序化城市跨作品复用,切换不再重算。 ### 发现但未改(值得做、需要产品或用户拍板) | 项 | 类型 | 说明 | |---|---|---| | 键盘通道在背景中不可用 | 功能缺口 | `keyboard` 纹理只在独立预览页接收按键;背景里为了不抢 DSH 输入一直保持松开。要做需明确「仅在画面区域聚焦时转发」的规则,风险高 | | 窗口失焦不降帧 | 省电优化 | 只有 `document.hidden`(最小化/切标签)会跳过绘制;同屏失焦仍按 fps 跑。可加「失焦降到 10 fps」选项,但有人会把 DSH 放副屏看背景,需默认值决策 | | 每作品独立 fps/分辨率 | 小功能 | 现在 fps/scale 是全局;状态里已有 per-preset `overrides` 结构,加 UI 即可 | | 背景库搜索 / 过滤 | 功能 | 200 条上限内只能滚动找 | | 「循环方式」文案未体现联播 | UX | 设置页可加只读提示「画面 209 s / 音乐 180 s → 按 209 s 联播」,数据要从播放器回传 | | 手动循环周期只重置画面 | 语义确认 | `loopSeconds` 手动模式目前只重置画面,音乐仍按自身时长循环;若期望音乐也按该周期回绕需要改语义(可能截断音乐) | | 极端 ShaderToy JSON 的 buffer 通道映射 | 健壮性 | `parseDocument` 在 buffer pass 既无 `outputs[].id` 又无 `id` 时,通道映射可能落到最后一个 buffer;真实导出几乎不会出现,可加兜底 id | ### 性能开销现状(可核对) - **每帧**:每个 buffer pass + image pass 各一次全屏三角形绘制;**没有逐帧 GPU 回读**。 - **画面结束检测**:2 Hz 的 16×16 GPU blit + 1 KB readback,仅在 auto + 无显式时长时运行。 - **键盘纹理**:从「每帧 3 KB 上传」降为「仅按键帧上传」。 - **音频**:每 93 ms 合成 4096 样本 + 读回 16 KB + JS 解码,实测约 10–12 块/秒;静音且时长已知时跳过合成,未知时长仍合成以探测结束。 - **纹理生成**:font SDF(两份 512² 八邻域距离场)约 200 万次迭代 + noise + city 只生成一次。 - **设置页**:自动保存 320 ms 防抖;恢复日志复用请求 JSON;滑块拖动不再重建背景下拉或整表序列化;详情只在行数变化时重建行号。 - **玻璃**:token 覆盖只在签名变化时应用;body 样式被改时合并到每帧一次修复,不在主题回调里递归。 - **内存边界**:多 pass 96 MB、纹理总量 1600 万像素、状态 2 MB、代码 1.5 MB、音频队列 32 块。 ## 五、产品决策与后续选项(本轮已处理) 1. **英文 UI / i18n**:保持中文(用户已明确不需要英文界面)。`locale/*.json` 只作为插件管理器展示资源保留。 2. **Awesome DSH 列表的条目描述**:暂不更新(用户明确不用管)。 3. **是否发版**:本轮先只合并 `main`;全部改动完成后再由用户决定是否发 0.4.4(版本号与 npm 均未动)。 4. **音乐结束判定**:已研究,结论是**图像不参与**(`take-on-me` 音乐 180 s 结束而图像还有 29 s,是硬反例);若之后有「间奏被截断」反馈,优先做音频包络置信度。详见 [`research/04-audio-image-end.md`](research/04-audio-image-end.md) 与决策记录 §9.20。 5. **`init` / `params` 乱序**:已在 `fix/init-revision` 分支修复(client 打 revision、控制器按流丢弃旧值),并加了 `test:worker` 回归;不再是遗留问题。 6. **商店截图**:不再自动从演示视频截帧,改成手动维护;`tools/make-market-assets.cjs` 删除,只保留图标生成 `tools/make-icon.cjs`,规范写在 [`market/README.md`](market/README.md)。`docs/market/*.png` 与 `screenshots.json` 的既有文件保留,用户用自己截的图覆盖同名文件即可。 7. **音画联播**:已按用户「较晚结束一方」的方案实现(1× 速度下),详见 [`research/04-audio-image-end.md`](research/04-audio-image-end.md) §五 与决策记录 §9.22。 ## 六、验证 本机(Windows 11 / msedge / SwiftShader): ``` npm run typecheck ok(strict checkJs,index.js + project/pipeline/textures,无构建产物) npm run check 302/302 npm run test:host ok npm run test:renderer ok npm run test:audio ok(peak 0.281 / rms 0.113,12.1 chunks/s) npm run test:core ok npm run test:panel ok npm run test:ui ok npm run test:audit ok npm run test:project ok npm run test:multi ok(修复前可复现超时;修复后连跑 2 次通过,含新增 iChannelTime 断言) npm run test:worker ok(含新增音频设备失败、乱序 revision 回归) npm run test:editor ok npm run test:surfaces ok(六种主题/透明度组合) npm run test:transparency ok(18 组组合) ``` 第二轮新增的 `test:multi` 断言:音画联播周期(画面晚 / 音乐晚两组)与 2× 速度下不启用联播。 `npm pack --dry-run` 当前为 67 个文件(随包新增三个多文件项目与 MPL 许可后),与 `files` 白名单一致;`site/`、`docs/`、`tools/`、`screenshots.json`、`tsconfig.json` 不进包(`check-bundle` 里都有断言)。