dsh-boot-suite 2.13.12 → 2.13.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +3 -1
  3. package/lib/assets/voices/f/line-1.mp3 +0 -0
  4. package/lib/assets/voices/f/line-2.mp3 +0 -0
  5. package/lib/assets/voices/f/line-3.mp3 +0 -0
  6. package/lib/assets/voices/f/line-4.mp3 +0 -0
  7. package/lib/assets/voices/f/line-5.mp3 +0 -0
  8. package/lib/assets/voices/f/line-6.mp3 +0 -0
  9. package/lib/assets/voices/g/line-1.mp3 +0 -0
  10. package/lib/assets/voices/g/line-2.mp3 +0 -0
  11. package/lib/assets/voices/g/line-3.mp3 +0 -0
  12. package/lib/assets/voices/g/line-4.mp3 +0 -0
  13. package/lib/assets/voices/g/line-5.mp3 +0 -0
  14. package/lib/assets/voices/g/line-6.mp3 +0 -0
  15. package/lib/assets/voices/h/line-1.mp3 +0 -0
  16. package/lib/assets/voices/h/line-2.mp3 +0 -0
  17. package/lib/assets/voices/h/line-3.mp3 +0 -0
  18. package/lib/assets/voices/h/line-4.mp3 +0 -0
  19. package/lib/assets/voices/h/line-5.mp3 +0 -0
  20. package/lib/assets/voices/h/line-6.mp3 +0 -0
  21. package/lib/assets/voices/i/line-1.mp3 +0 -0
  22. package/lib/assets/voices/i/line-2.mp3 +0 -0
  23. package/lib/assets/voices/i/line-3.mp3 +0 -0
  24. package/lib/assets/voices/i/line-4.mp3 +0 -0
  25. package/lib/assets/voices/i/line-5.mp3 +0 -0
  26. package/lib/assets/voices/i/line-6.mp3 +0 -0
  27. package/lib/assets/voices/j/line-1.mp3 +0 -0
  28. package/lib/assets/voices/j/line-2.mp3 +0 -0
  29. package/lib/assets/voices/j/line-3.mp3 +0 -0
  30. package/lib/assets/voices/j/line-4.mp3 +0 -0
  31. package/lib/assets/voices/j/line-5.mp3 +0 -0
  32. package/lib/assets/voices/j/line-6.mp3 +0 -0
  33. package/lib/assets/voices/k/line-1.mp3 +0 -0
  34. package/lib/assets/voices/k/line-2.mp3 +0 -0
  35. package/lib/assets/voices/k/line-3.mp3 +0 -0
  36. package/lib/assets/voices/k/line-4.mp3 +0 -0
  37. package/lib/assets/voices/k/line-5.mp3 +0 -0
  38. package/lib/assets/voices/k/line-6.mp3 +0 -0
  39. package/lib/assets/voices/manifest.json +32 -0
  40. package/lib/client.js +101 -55
  41. package/lib/host-startup.js +48 -0
  42. package/lib/host-wallpaper.js +8 -2
  43. package/lib/splash.js +73 -23
  44. package/package.json +2 -2
  45. package/test/smoke-client.mjs +27 -4
  46. package/test/smoke-host.mjs +23 -4
  47. package/test/smoke-package.mjs +4 -1
  48. package/test/smoke-splash.mjs +8 -1
  49. package/test/smoke-wallpaper-client.mjs +1 -1
  50. package/test/smoke-wallpaper-host.mjs +7 -2
  51. package/lib/assets/line-1.mp3 +0 -0
  52. package/lib/assets/line-2.mp3 +0 -0
  53. package/lib/assets/line-3.mp3 +0 -0
  54. package/lib/assets/line-4.mp3 +0 -0
  55. package/lib/assets/line-5.mp3 +0 -0
  56. package/lib/assets/line-6.mp3 +0 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,75 @@
1
+ ## [2.13.20] — 2026-10-30
2
+
3
+ - 修设置页旧文案:声音效果默认值标注修正(「无(原声)」/「机器人(默认)」)、开关改为「启用配音播报」、配音音色兜底从已删除的 builtin 改为 f。
4
+ - 无功能变化,仅文案与默认值标注,老配置不受影响。
5
+
6
+ ## [2.13.19] — 2026-10-30
7
+
8
+ ### 变更
9
+ - **对外音色精简**:英文只保留音色F(默认),日文保留 5 套(G–K);设置页重排并改名为「英文音色F(默认)」+「日文音色1–5」。本地被撤下的英文音色 A–E 已移到 `~/.dsh/startup-voices/旧按音频-暂不使用`,没有删除。
10
+ - **音色F 换回最早那套原始干音**:用 dry-01..05 + dry-06-原版 重做 F 的 6 句,停顿/时长/响度逐句复刻老版本(line-1 3.670s、line-2 3.250s、line-3 1.440s、line-4 1.090s、line-5 2.750s、line-6 4.302s),老用户听感不变。
11
+
12
+ ### 修复
13
+ - **终幕欢迎语进得太早**:05→06 的过场和声钟还没响完,「Welcome to DeepSeek Harness」就开口了。现在默认等 550ms(实测最顺)再进最后一句;设置页「语音与音效」新增「终幕语音延迟」滑块(0–3000ms,50ms 步进),可边预览边微调(滑杆属临时调试项,后续版本会删掉)。
14
+ - 默认配音改为「音色F(英文)+ 机器人效果」,贴近旧版听感;用户可在设置里改回任意音色/无效果。
15
+ - **音色 H / I 的 line-2 尾音**:日文「ください」的「さい」之前收得太早;按实际波形能量重切这两条(只改 H、I 各一条),其余音色不动。
16
+ - 版本号 2.13.18 → 2.13.19;client BUILD `cw-42`。
17
+
18
+ ## [2.13.17] — 2026-10-30
19
+
20
+ ### 修复
21
+ - **11 套音色全部重新切分**:之前第 2 句起开头会被切掉一点(软辅音/气声被当成静音,日语「ユーザー」的 you 音不完整);现在句间切点取静音段中点、按真实开口/收尾各留 120ms,A–K(英/日)全部重切并已试听确认。
22
+ - **启动动画完整没声音**:音色包路由注册成了 `/dsh-startup/voice/`(尾部多一个斜杠),真实 DSH 的 prefix 路由匹配不到 → 每句配音都拿不到素材、静音。改为 `/dsh-startup/voice` 并兼容旧地址。
23
+ - **丢图目录显示「?」、打开图片文件夹按钮没反应**:新装/老用户目录不存在时面板拿到空 dropDir;现在插件启动时自动创建 `~/.dsh/chat-wallpaper`,`/images` 也兜底创建;打开按钮会回显成功/失败。
24
+
25
+ ### 新增
26
+ - **聊天背景「色彩模式」**:彩色(默认,不调色)/ 黑白(原版素材,不染色)。黑白模式会关掉主题色纱,适合本身就是黑白的动态壁纸,避免误以为插件把画面染灰了。
27
+
28
+ ---
29
+
30
+ ## [2.13.15] — 2026-10-30
31
+
32
+ ### 修复
33
+ - **「立即预览」不认未保存的修改**:以前预览只灌宿主已保存的配置,选完「配音音色 / 声音效果」直接点预览,跑的还是旧配置(例如选日文音色仍播英文内置包)。现在预览会把当前面板里的修改一并带上;要永久生效仍要点「保存设置」。
34
+ - 版本号与构建号更新(client BUILD cw-38)。
35
+
36
+ ## [2.13.16] — 2026-10-30
37
+
38
+ 本次把旧的 TTS 配音链路彻底退役,配音只保留「音色包 + 声音效果」:
39
+
40
+ - 删除设置页「语音来源」「音色」「播报语言」「语速/音调」等旧 TTS 选项,只保留「配音音色」和「声音效果」。
41
+ - 删除内置 TTS 生成的旧配音素材(line-1..6、dry-line-1..5),默认音色改为音色 A;旧配置里的 builtin 自动迁移为 A。
42
+ - 配音素材拿不到时改为静音,不再回落系统 TTS(避免旧机械音盖掉新音色)。
43
+ - 11 套干音(A–F 英文、G–K 日文)+ 5 种声音效果保持可用。
44
+
45
+ ## [2.13.14] — 2026-10-30
46
+
47
+ ### 新增:11 套配音音色 + 5 种声音效果
48
+ - 内置新增 A–K 共 11 套完整配音(A–F 英文、G–K 日文),每套 6 句;设置页「配音音色」里直接选。
49
+ - 「声音效果」可再叠 AI 机械音 / 机器人 / 无线电 / 老电视 / 低沉,或选「无」保留干净人声;与模板编辑器同一套参数,实时处理、不改素材文件。
50
+ - 模板自带的配音不受全局音色/效果影响;缺素材仍自动回落 TTS。
51
+ - 素材已统一做响度归一(I=-18),切换音色不会忽大忽小。
52
+
53
+ ### 构建
54
+ - 版本号 2.13.13 → 2.13.14;client BUILD `cw-37`;全套测试通过。
55
+
56
+ ---
57
+ ## [2.13.13] — 2026-10-29
58
+
59
+ ### 新增:第一幕配音可选「处理版 / 原声」
60
+ - 内置 5 句英文真声此前只有经过效果处理的版本(AI 机械音)。新增一套原声干音素材 `lib/assets/dry-line-1..5.mp3`——与处理版同一时长、同一前后留白,只换文件,不破坏动画节奏。
61
+ - 设置页「语音与音效」新增「配音效果」:**处理版(AI 机械音,默认)** / **原声(不处理)**。
62
+ - 只影响内置真声素材;模板自带的配音、以及选择「系统 TTS」时都不受影响;默认仍为处理版,老用户升级后听感不变。
63
+
64
+ ### 改进:壁纸「刷新」给出明确反馈
65
+ - 之前点「刷新」没有任何提示,看起来像没反应。现在按钮会变成「刷新中…」,完成后显示「已重新读取:图片 N 张 / 视频 N 个」。
66
+ - 图片为空时的提示补上「只扫描这一层,子文件夹里的不算;支持 jpg/png/webp/gif/avif/bmp」,直接对应“图放进去了但不显示”的常见原因。
67
+
68
+ ### 构建
69
+ - 版本号 2.13.12 → 2.13.13;client BUILD `cw-36`;全套测试通过。
70
+
71
+ ---
72
+
1
73
  ## [2.13.12] — 2026-10-29
2
74
 
3
75
  ### 修复:更新时「浏览器下载监听」过早超时、找不到下载文件
package/README.md CHANGED
@@ -31,7 +31,7 @@ DeepSeek Harness(DSH)冷启动时会先播六幕动画,播完进聊天,
31
31
  | 入口 | DSH 设置页 →「启动动画」 | DSH 设置页 →「聊天背景」 |
32
32
  | 内容 | 六幕序列、逐字打出的英文副标题、环形准星、色散故障 | 静态底图 / 大字横幅 |
33
33
  | 素材 | 5 句真声台词 + 终幕欢迎语 + 2 个界面音效(mp3) | 插件自带默认底图 + 你自己的图片 |
34
- | 可调 | 33 项:身份名称 / 编号 / 权限等级 / 配色 / 速度 / 音色 / 语速 / 每处文案 | 默认/自选底图、循环视频、纱的浓度、横幅文字、丢图即换 |
34
+ | 可调 | 34 项:身份名称 / 编号 / 权限等级 / 配色 / 速度 / 音色 / 语速 / 每处文案 | 默认/自选底图、循环视频、纱的浓度、横幅文字、丢图即换 |
35
35
 
36
36
  两个半侧**各自独立**:动画坏了不影响壁纸,壁纸坏了不影响动画,界面照常进。
37
37
 
@@ -104,6 +104,7 @@ https://github.com/LeeRolling/dsh-boot-suite/releases/latest/download/dsh-boot-s
104
104
 
105
105
  - 中文主字 + 英文等宽副标题,**逐字打出**,像终端接收数据
106
106
  - **真声台词**(`lib/assets/line-1..6.mp3`),素材缺失自动回落系统 TTS
107
+ - **配音音色可选** —— 内置 6 套完整配音(英文音色F(默认)+ 日文音色1–5),每套 6 句;另可叠 AI 机械音 / 机器人 / 无线电 / 老电视 / 低沉 5 种声音效果,或选「无」保留原声
107
108
  - **语音没播完不推进** —— 每步停留时长由实际音频决定
108
109
  - 显示模式:每次会话 / 总是 / 每天;可要求确认,也允许跳过
109
110
 
@@ -120,6 +121,7 @@ https://github.com/LeeRolling/dsh-boot-suite/releases/latest/download/dsh-boot-s
120
121
  - **窗口按钮固定高对比(v2.13.10)**:右上角最小化 / 最大化 / 关闭是 Windows 原生画在页面之上的,插件给它们固定一对「浅色外观=浅底深图标、深色外观=深底浅图标」的颜色,不再被半透明壁纸底色带成白底白图标;片头播放期间标题栏仍保持透明
121
122
  - **只留在聊天里**:不和启动动画第二幕抢画面(v2.11.8 起开场视频已移除);v2.13.0 新增可选的「循环视频」背景(默认关闭)
122
123
  - **循环视频(v2.13.0)**:把 mp4/webm 或壁纸引擎项目文件夹放进 `~/.dsh/chat-wallpaper/loops/`(可添加自定义目录),设置页「聊天背景」→「循环视频」可选 静态底图/循环视频、手动/随机轮换;默认静音循环、只读本机;列表可删除(壁纸引擎项目整体删除)
124
+ - **色彩模式(v2.13.17)**:聊天背景可切「彩色(默认,不调色)/ 黑白(原版素材,不染色)」;黑白模式关掉主题色纱,画面保持原样
123
125
 
124
126
  配置:`~/.dsh/chat-wallpaper.json`(设置页改)
125
127
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,32 @@
1
+ [
2
+ {
3
+ "id": "f",
4
+ "name": "英文音色F(默认)",
5
+ "lang": "en"
6
+ },
7
+ {
8
+ "id": "g",
9
+ "name": "日文音色1",
10
+ "lang": "ja"
11
+ },
12
+ {
13
+ "id": "h",
14
+ "name": "日文音色2",
15
+ "lang": "ja"
16
+ },
17
+ {
18
+ "id": "i",
19
+ "name": "日文音色3",
20
+ "lang": "ja"
21
+ },
22
+ {
23
+ "id": "j",
24
+ "name": "日文音色4",
25
+ "lang": "ja"
26
+ },
27
+ {
28
+ "id": "k",
29
+ "name": "日文音色5",
30
+ "lang": "ja"
31
+ }
32
+ ]
package/lib/client.js CHANGED
@@ -249,10 +249,11 @@ window.__ModuleLoader__.load({
249
249
  面板也要能正常显示这些控件,而不是一片 undefined。 */
250
250
  var FALLBACK = {
251
251
  voice: true, voiceLang: 'en', voiceName: '', voiceRate: 0.88, voicePitch: 0.85,
252
- voiceVolume: 0.9, voiceCue: true, voiceLead: 240, voiceSource: 'asset',
252
+ voiceVolume: 0.9, voiceCue: true, voiceLead: 240, welcomeDelay: 550, voiceSource: 'asset', voicePack: 'f', voiceFx: 'robot',
253
253
  sound: true, soundVolume: 0.5, bedNoise: false,
254
254
  transitionIn: 'dissolve', transitionJoin: 'dissolve', transitionOut: 'dissolve',
255
- transitionMs: 600, transitionDir: 'left'
255
+ transitionMs: 600, transitionDir: 'left',
256
+ colorMode: 'color'
256
257
  }
257
258
  function num(v, d) {
258
259
  var n = Number(v)
@@ -265,7 +266,7 @@ window.__ModuleLoader__.load({
265
266
  —— 桌面端走浏览器半侧自举时没有 1,就靠这一条
266
267
  3) 下面这个常量:两份都没拿到时的兜底,别让面板显示 undefined
267
268
  版本号本身只在 lib/index.js 里读一次,这里不重复维护。 */
268
- var FALLBACK_VERSION = '2.13.12'
269
+ var FALLBACK_VERSION = '2.13.20'
269
270
  /* 手动选包成功后置 true:让还在后台跑的“浏览器下载监听”不要再弹第二次更新器 */
270
271
  var __manualUpdateApplied = false
271
272
  function readInjectedVersion() {
@@ -285,6 +286,7 @@ window.__ModuleLoader__.load({
285
286
  var [busy, setBusy] = react.useState(false)
286
287
  var [templates, setTemplates] = react.useState([])
287
288
  var [hiddenTemplates, setHiddenTemplates] = react.useState([])
289
+ var [voicePacks, setVoicePacks] = react.useState([])
288
290
  var [update, setUpdate] = react.useState(null)
289
291
  var [checking, setChecking] = react.useState(false)
290
292
  var [updating, setUpdating] = react.useState(false)
@@ -321,6 +323,25 @@ window.__ModuleLoader__.load({
321
323
  }
322
324
  react.useEffect(() => { refreshTemplates() }, [])
323
325
 
326
+ /* 音色包列表:来自 lib/assets/voices/manifest.json,由宿主只读下发 */
327
+ function refreshVoices() {
328
+ return fetch('/dsh-startup/voices', { headers: { Accept: 'application/json' }, cache: 'no-store' })
329
+ .then((r) => r.json())
330
+ .then((j) => { if (j && j.ok === true && Array.isArray(j.packs)) setVoicePacks(j.packs) })
331
+ .catch(() => { /* 拉不到就只显示内置音色 */ })
332
+ }
333
+ react.useEffect(() => { refreshVoices() }, [])
334
+
335
+ function voiceOptions() {
336
+ var opts = []
337
+ for (var i = 0; i < voicePacks.length; i++) {
338
+ var v = voicePacks[i]
339
+ if (!v || !v.id) continue
340
+ opts.push([v.id, String(v.name || v.id)])
341
+ }
342
+ return opts
343
+ }
344
+
324
345
  function set(key, value) {
325
346
  setNotice(null)
326
347
  setDraft((d) => Object.assign({}, d, { [key]: value }))
@@ -644,7 +665,10 @@ window.__ModuleLoader__.load({
644
665
  .then((r) => r.json())
645
666
  .then((j) => {
646
667
  if (!j || j.ok !== true) return
647
- if (j.config) window.__DSH_STARTUP__ = Object.assign({}, FALLBACK, j.config)
668
+ /* v2.13.15:预览必须带上「当前还没保存的修改」。
669
+ 原来只灌宿主已保存的配置:用户选完音色/效果直接点「立即预览」,
670
+ 运行时拿到的还是旧配置 —— 表现就是「选了日文音色,预览仍播英文」。 */
671
+ if (j.config) window.__DSH_STARTUP__ = Object.assign({}, FALLBACK, j.config, draft || {})
648
672
  try {
649
673
  if (j.template) window.__DSH_STARTUP_TEMPLATE__ = j.template
650
674
  else delete window.__DSH_STARTUP_TEMPLATE__
@@ -903,57 +927,39 @@ window.__ModuleLoader__.load({
903
927
  h('div', { className: '__ss_group' },
904
928
  h('div', { className: '__ss_groupTitle' }, '语音与音效'),
905
929
  h(Check, {
906
- label: '女声播报(念每一行的英文)',
907
- hint: '照参考片里那位女声的节奏:文字出现的同一时刻念出来。'
908
- + '动画会等这句话念完再走下一步,不会把语音掐断;浏览器要求先有一次点击才准出声,'
909
- + '所以第一次点按钮/按 Enter 时会把当前这行补念一次。',
930
+ label: '启用配音播报',
931
+ hint: '每一行的台词都在文字出现的同一时刻念出来,动画等这句话念完再走下一步。',
910
932
  checked: draft.voice,
911
933
  onChange: (v) => set('voice', v)
912
934
  }),
913
- h(Field, {
914
- label: '语音来源',
915
- hint: '素材 = 插件内置的真声(来自你给的音频,5 句);TTS = 用系统语音合成。'
916
- + '素材缺失时无论选哪个都会自动回落到 TTS;终幕那句嵌了可配置的身份名称,始终走 TTS。'
935
+ draft.voice ? h(Field, {
936
+ label: '配音音色',
937
+ hint: '6 套完整干音:英文 1 套(音色F·默认)+ 日文 5 套(日文音色1–5);可再叠声音效果。'
938
+ + '选完音色后,下面的「声音效果」可以再叠一层处理,或选「无」保留原声。'
917
939
  },
918
940
  h(Select, {
919
- value: draft.voiceSource || 'asset',
920
- options: [['asset', '真声素材(推荐)'], ['tts', '系统 TTS 合成']],
921
- onChange: (v) => set('voiceSource', v)
941
+ value: draft.voicePack || 'f',
942
+ options: voiceOptions(),
943
+ onChange: (v) => set('voicePack', v)
922
944
  })
923
- ),
924
- h(Field, { label: '播报语言' },
945
+ ) : null,
946
+ draft.voice ? h(Field, {
947
+ label: '声音效果',
948
+ hint: '默认「机器人」;5 个音效都来自模板编辑器里的同一套参数,实时叠加在所选音色上。'
949
+ + '选「无(原声)」就是干净人声;只影响全局配音素材,模板自带的配音不受影响。'
950
+ },
925
951
  h(Select, {
926
- value: draft.voiceLang,
927
- options: [['en', '英语(照参考片)'], ['zh', '中文'], ['both', '中英各念一遍']],
928
- onChange: (v) => set('voiceLang', v)
952
+ value: draft.voiceFx || 'none',
953
+ options: [
954
+ ['none', '无(原声)'],
955
+ ['ai-mech', 'AI 机械音'],
956
+ ['robot', '机器人(默认)'],
957
+ ['radio', '无线电'],
958
+ ['oldtv', '老电视'],
959
+ ['dark', '低沉']
960
+ ],
961
+ onChange: (v) => set('voiceFx', v)
929
962
  })
930
- ),
931
- draft.voice ? h(VoicePicker, {
932
- value: draft.voiceName,
933
- onChange: (v) => set('voiceName', v),
934
- onPreview: () => speakSample(null, draft)
935
- }) : null,
936
- draft.voice ? h('div', { className: '__ss_row' },
937
- h('button', {
938
- className: '__ss_btn',
939
- type: 'button',
940
- onClick: () => {
941
- set('voiceRate', 0.88)
942
- set('voicePitch', 0.85)
943
- set('voiceName', '')
944
- set('voiceCue', true)
945
- set('voiceLead', 240)
946
- }
947
- }, '↺ 套用「电影旁白」档'),
948
- h('span', { className: '__ss_hint' }, '语速 0.88 · 音调 0.85(压抑、深沉)· 自动挑神经网络女声 · 提示音 + 开口留白')
949
- ) : null,
950
- draft.voice ? h('div', { className: '__ss_two' },
951
- h(Field, { label: '语速' },
952
- h(Range, { value: num(draft.voiceRate, 1), min: 0.5, max: 2, display: num(draft.voiceRate, 1).toFixed(2) + '×', onChange: (v) => set('voiceRate', v) })
953
- ),
954
- h(Field, { label: '音调' },
955
- h(Range, { value: num(draft.voicePitch, 1), min: 0, max: 2, display: num(draft.voicePitch, 1).toFixed(2), onChange: (v) => set('voicePitch', v) })
956
- )
957
963
  ) : null,
958
964
  draft.voice ? h(Field, { label: '开口前留白', hint: '提示音响过之后停多久再开口。电影旁白/机场广播都有这一拍,240ms 起最像"系统在播报"。' },
959
965
  h(Range, {
@@ -962,6 +968,13 @@ window.__ModuleLoader__.load({
962
968
  onChange: (v) => set('voiceLead', v)
963
969
  })
964
970
  ) : null,
971
+ draft.voice ? h(Field, { label: '终幕语音延迟', hint: '05→06 的过场和声钟约 1.5 秒;默认等它收尾再进最后一句。觉得欢迎语进得太早/太晚,就调这里。' },
972
+ h(Range, {
973
+ value: num(draft.welcomeDelay, 550), min: 0, max: 3000, step: 50,
974
+ display: Math.round(num(draft.welcomeDelay, 550)) + ' ms',
975
+ onChange: (v) => set('welcomeDelay', v)
976
+ })
977
+ ) : null,
965
978
  draft.voice ? h(Field, { label: '音量' },
966
979
  h(Range, { value: num(draft.voiceVolume, 0.9), min: 0, max: 1, display: Math.round(num(draft.voiceVolume, 0.9) * 100) + '%', onChange: (v) => set('voiceVolume', v) })
967
980
  ) : null,
@@ -1587,7 +1600,7 @@ window.__ModuleLoader__.load({
1587
1600
  /* 每次改客户端就把这个号 +1。
1588
1601
  有了它在控制台一眼就能判断"浏览器里跑的是不是最新那份" ——
1589
1602
  排查问题时,分不清是代码问题还是旧包,会白猜一轮。 */
1590
- var BUILD = 'cw-35'
1603
+ var BUILD = 'cw-43'
1591
1604
 
1592
1605
  function log() {
1593
1606
  try {
@@ -2093,6 +2106,8 @@ window.__ModuleLoader__.load({
2093
2106
  L.push(B + ' > ' + V + ' { position: absolute; inset: 0; }')
2094
2107
  L.push('html.dsh-cw-light ' + B + ' > ' + V + ' { background: linear-gradient(' + cfg.veilLightA + ', ' + cfg.veilLightB + '); }')
2095
2108
  L.push('html.dsh-cw-dark ' + B + ' > ' + V + ' { background: linear-gradient(' + cfg.veilDarkA + ', ' + cfg.veilDarkB + '); }')
2109
+ /* v2.13.17:黑白原版素材模式 —— 不染色,直接关掉主题色纱(模糊仍可单独调) */
2110
+ L.push('html.dsh-cw-mono ' + B + ' > ' + V + ' { background: transparent !important; }')
2096
2111
  /* v2.13.11 镜头模糊:只模糊纱后面的壁纸/视频,纱上的聊天文字不受影响。
2097
2112
  0 = 不生成规则(完全不动);100 = 约 24px。 */
2098
2113
  if (Number(cfg.veilBlur) > 0) {
@@ -2388,6 +2403,8 @@ window.__ModuleLoader__.load({
2388
2403
  current = cfg
2389
2404
  var dark = looksDark()
2390
2405
  setThemeClass(dark)
2406
+ /* v2.13.17:黑白(原版素材)模式 → 关掉主题色纱,保留原画面 */
2407
+ try { document.documentElement.classList.toggle('dsh-cw-mono', cfg.colorMode === 'mono') } catch (e) { /* 忽略 */ }
2391
2408
  log('套用壁纸', { image: cfg.__image, theme: dark ? 'dark' : 'light' })
2392
2409
  injectCss(cfg)
2393
2410
  mountBg(cfg) /* 最底:聊天底图(用户自己定的那张,可选)*/
@@ -2421,7 +2438,7 @@ window.__ModuleLoader__.load({
2421
2438
  removeBg()
2422
2439
  removeBanner()
2423
2440
  try {
2424
- document.documentElement.classList.remove('dsh-cw-dark', 'dsh-cw-light')
2441
+ document.documentElement.classList.remove('dsh-cw-dark', 'dsh-cw-light', 'dsh-cw-mono')
2425
2442
  themeClass = null
2426
2443
  } catch (e) { /* 忽略 */ }
2427
2444
  }
@@ -2555,9 +2572,11 @@ window.__ModuleLoader__.load({
2555
2572
  var [busy, setBusy] = cwReact.useState(false)
2556
2573
  var [error, setError] = cwReact.useState(null)
2557
2574
  var [notice, setNotice] = cwReact.useState(null)
2575
+ var [refreshing, setRefreshing] = cwReact.useState(false)
2558
2576
 
2559
- function load() {
2577
+ function load(manual) {
2560
2578
  setError(null)
2579
+ if (manual) { setNotice(null); setRefreshing(true) }
2561
2580
  return Promise.all([api('/images'), api('/config', { cache: 'no-store' }), api('/videos')])
2562
2581
  .then(function (arr) {
2563
2582
  var images = arr[0] || {}
@@ -2578,6 +2597,7 @@ window.__ModuleLoader__.load({
2578
2597
  veilBlur: cfg.veilBlur == null ? 0 : Math.max(0, Math.min(100, Number(cfg.veilBlur) || 0)),
2579
2598
  image: conf.image || null,
2580
2599
  imageFit: cfg.imageFit === 'contain' ? 'contain' : 'cover',
2600
+ colorMode: cfg.colorMode === 'mono' ? 'mono' : 'color',
2581
2601
  video: {
2582
2602
  bgMode: vids.bgMode || 'image',
2583
2603
  videoMode: vids.videoMode || 'single',
@@ -2590,8 +2610,13 @@ window.__ModuleLoader__.load({
2590
2610
  }
2591
2611
  setData(next)
2592
2612
  setDraft({ banner: next.banner, bannerOpacity: next.bannerOpacity, dark: looksDark(), newRoot: '', shuffleMin: next.video.shuffleMinutes })
2613
+ if (manual) {
2614
+ setNotice('已重新读取:图片 ' + next.images.length + ' 张 / 视频 ' + next.video.items.length + ' 个'
2615
+ + (next.dropDirExists ? '' : '(丢图目录还不存在,点「打开图片文件夹」会创建)'))
2616
+ }
2593
2617
  })
2594
2618
  .catch(function (e) { setError('读取失败:' + String((e && e.message) || e)) })
2619
+ .then(function () { if (manual) setRefreshing(false) })
2595
2620
  }
2596
2621
  cwReact.useEffect(function () { load() }, [])
2597
2622
 
@@ -2694,11 +2719,20 @@ window.__ModuleLoader__.load({
2694
2719
  cwH('br'),
2695
2720
  '循环视频:' + (data.video && data.video.items.length ? ('已扫描 ' + data.video.items.length + ' 段' + (data.video.bgMode === 'video' && data.video.currentId ? ',正在播放' : ',未启用')) : '(视频目录里还没有视频)'),
2696
2721
  cwH('br'),
2697
- '丢图目录:' + (data.dropDir || '?')
2722
+ '丢图目录:' + (data.dropDir || '(尚未创建,点「打开图片文件夹」会自动创建)')
2698
2723
  ),
2699
2724
  cwH('div', { className: '__ss_row' },
2700
- cwH('button', { className: '__ss_btn', type: 'button', onClick: function () { api('/open-dir', { method: 'POST' }) } }, '打开图片文件夹'),
2701
- cwH('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { load() } }, '刷新')
2725
+ cwH('button', {
2726
+ className: '__ss_btn', type: 'button', disabled: busy,
2727
+ onClick: function () {
2728
+ setNotice(null); setError(null)
2729
+ api('/open-dir', { method: 'POST' }).then(function (j) {
2730
+ if (j && j.ok) setNotice('已打开图片文件夹:' + (j.opened || data.dropDir))
2731
+ else setError('打开图片文件夹失败:' + ((j && j.error) || '宿主没有返回'))
2732
+ })
2733
+ }
2734
+ }, '打开图片文件夹'),
2735
+ cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || refreshing, onClick: function () { load(true) } }, refreshing ? '刷新中…' : '刷新')
2702
2736
  )
2703
2737
  ),
2704
2738
 
@@ -2707,7 +2741,7 @@ window.__ModuleLoader__.load({
2707
2741
  cwH('div', { className: '__ss_hint' }, '点一下立刻换。第一张「默认背景(内置)」是插件自带、开箱就有的;把图丢进上面的目录也会出现在这里,按修改时间取最新那张。'),
2708
2742
  data.images.length
2709
2743
  ? cwH('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } }, data.images.map(thumb))
2710
- : cwH('div', { className: '__ss_hint' }, '目录里还没有图。点「打开图片文件夹」,把 jpg / png / webp 丢进去,再点刷新。'),
2744
+ : cwH('div', { className: '__ss_hint' }, '目录里还没有图。点「打开图片文件夹」,把 jpg / png / webp 丢进去,再点刷新。只扫描这一层,子文件夹里的不算(支持 jpg/png/webp/gif/avif/bmp)。'),
2711
2745
  cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2712
2746
  cwH('span', { className: '__ss_status' }, '图片适配'),
2713
2747
  cwH('select', {
@@ -2749,6 +2783,18 @@ window.__ModuleLoader__.load({
2749
2783
  cwH('option', { value: 'shuffle' }, '随机轮换')
2750
2784
  )
2751
2785
  ),
2786
+ cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2787
+ cwH('span', { className: '__ss_status' }, '色彩模式'),
2788
+ cwH('select', {
2789
+ className: '__ss_select', disabled: busy,
2790
+ value: data.colorMode,
2791
+ onChange: function (ev) { apply({ colorMode: ev.target.value }, true) }
2792
+ },
2793
+ cwH('option', { value: 'color' }, '彩色(默认)'),
2794
+ cwH('option', { value: 'mono' }, '黑白(原版素材,不染色)')
2795
+ )
2796
+ ),
2797
+ cwH('div', { className: '__ss_hint' }, '黑白模式会关掉主题色纱、保留原画面颜色,适合本身就是黑白的动态壁纸;彩色模式不做任何调色。'),
2752
2798
  data.video.videoMode === 'shuffle'
2753
2799
  ? cwH('div', { className: '__ss_row' },
2754
2800
  cwH('span', { className: '__ss_status' }, '轮换间隔(分钟,0 = 每次启动随机)'),
@@ -2799,7 +2845,7 @@ window.__ModuleLoader__.load({
2799
2845
  : cwH('div', { className: '__ss_hint' }, '还没有视频。把 mp4/webm 或壁纸引擎的项目文件夹放进上面的目录,再点下方「刷新」。'),
2800
2846
  cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2801
2847
  cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || !(data.video.roots && data.video.roots.length), onClick: function () { if (data.video.roots && data.video.roots.length) rootAction('open', data.video.roots[0]) } }, '打开视频文件夹'),
2802
- cwH('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { load() } }, '刷新')
2848
+ cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || refreshing, onClick: function () { load(true) } }, refreshing ? '刷新中…' : '刷新')
2803
2849
  )
2804
2850
  ),
2805
2851
 
@@ -19,6 +19,18 @@ import { spawn } from 'node:child_process'
19
19
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
20
  const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
21
21
  const CONFIG_FILE = path.join(DSH_HOME, 'dsh-startup.json')
22
+ const VOICE_ROOT = path.join(PACKAGE_ROOT, 'lib', 'assets', 'voices')
23
+ /* 音色包清单由 lib/assets/voices/manifest.json 描述(随安装包发布)。
24
+ 读不到就当作没有扩展音色,内置音色不受影响。 */
25
+ const VOICE_PACKS = (() => {
26
+ try {
27
+ const list = JSON.parse(fs.readFileSync(path.join(VOICE_ROOT, 'manifest.json'), 'utf8'))
28
+ if (!Array.isArray(list)) return []
29
+ return list.filter((v) => v && /^[a-z]$/.test(String(v.id)) && (v.lang === 'en' || v.lang === 'ja'))
30
+ .map((v) => ({ id: String(v.id), name: String(v.name || '').slice(0, 12) || ('音色 ' + String(v.id).toUpperCase()), lang: v.lang }))
31
+ } catch (err) { return [] }
32
+ })()
33
+ const VOICE_PACK_IDS = [...VOICE_PACKS.map((v) => v.id)]
22
34
 
23
35
  const NS = 'dsh-startup-screen'
24
36
  const ROUTE_BASE = '/dsh-startup'
@@ -75,7 +87,10 @@ const DEFAULTS = {
75
87
  voiceCue: true, // 念台词前的一声轻提示
76
88
  sfx: 'audio', // 音效来源:audio=lib/assets 里的真素材 / synth=现场合成
77
89
  voiceSource: 'asset', // 语音来源:asset=优先用真声素材(拿不到自动回落 TTS)/ tts=全 TTS
90
+ voicePack: 'f', // 配音音色:f..k=音色包(默认 F 英文);用户在设置里可换
91
+ voiceFx: 'robot', // 声音效果:none | ai-mech | robot | radio | oldtv | dark(默认机器人,贴近旧版听感)
78
92
  voiceLead: 240, // 提示音之后留一小段空白再开口
93
+ welcomeDelay: 550, // 05→06 过场和声钟之后等多久再进终幕欢迎语(0–3000ms;实测 550 最顺)
79
94
  identity: 'JOYCE MOORE', // ← 设置页里可改的「身份名称」
80
95
  identityId: '0087',
81
96
  accessLevel: '3',
@@ -117,6 +132,8 @@ const ENUMS = {
117
132
  voiceLang: ['en', 'zh', 'both'],
118
133
  sfx: ['audio', 'synth'],
119
134
  voiceSource: ['asset', 'tts'],
135
+ voicePack: VOICE_PACK_IDS,
136
+ voiceFx: ['none', 'ai-mech', 'robot', 'radio', 'oldtv', 'dark'],
120
137
  transitionIn: TRANSITION_TYPES,
121
138
  transitionJoin: TRANSITION_TYPES,
122
139
  transitionOut: TRANSITION_TYPES,
@@ -125,6 +142,7 @@ const ENUMS = {
125
142
  const BOOL_KEYS = ['enabled', 'requireInteraction', 'allowSkip', 'sound', 'bedNoise', 'voice', 'voiceCue']
126
143
  /** 数值字段的合法区间(越界即夹取)。 */
127
144
  const RANGES = {
145
+ welcomeDelay: [0, 3000],
128
146
  speed: [0.35, 3],
129
147
  transitionMs: [200, 2000],
130
148
  soundVolume: [0, 1],
@@ -397,6 +415,36 @@ function apply(ctx) {
397
415
  }
398
416
  }))
399
417
 
418
+ /* 音色包:6 套扩展配音(英文 F + 日文 G–K)。设置页只拿 id/名称/语言,音频走只读白名单路由;
419
+ id 与文件名都白名单,且不接受子路径 —— 和 /asset 同样防目录穿越。 */
420
+ disposers.push(registerRoute({
421
+ kind: 'exact',
422
+ path: ROUTE_BASE + '/voices',
423
+ handler: (req, res) => sendJson(res, 200, { ok: true, packs: VOICE_PACKS, default: 'f' })
424
+ }))
425
+ disposers.push(registerRoute({
426
+ kind: 'prefix',
427
+ path: ROUTE_BASE + '/voice',
428
+ handler: (req, res) => {
429
+ const rest = decodeURIComponent(String(req.url || '').split('?')[0])
430
+ .slice((ROUTE_BASE + '/voice').length).replace(/^\/+/, '')
431
+ const parts = rest.split('/')
432
+ const id = String(parts[0] || '')
433
+ const name = String(parts[1] || '')
434
+ if (!/^[a-z]$/.test(id) || !/^line-[1-6]\.mp3$/.test(name) || !VOICE_PACK_IDS.includes(id)) {
435
+ res.writeHead(404); res.end(); return
436
+ }
437
+ let bytes
438
+ try { bytes = fs.readFileSync(path.join(VOICE_ROOT, id, name)) } catch (err) { res.writeHead(404); res.end(); return }
439
+ res.writeHead(200, {
440
+ 'Content-Type': 'audio/mpeg',
441
+ 'Cache-Control': 'no-store',
442
+ 'Content-Length': String(bytes.length)
443
+ })
444
+ res.end(bytes)
445
+ }
446
+ }))
447
+
400
448
  /* 模板自带声音:/dsh-startup/template-asset/<模板id>/<文件名>
401
449
  先找用户目录 ~/.dsh/startup-templates/<id>/assets/,再找插件自带 templates/<id>/assets/。
402
450
  id 与文件名都过白名单,且不接受子路径 —— 模板作者塞不进来任意文件读取。 */
@@ -63,7 +63,8 @@ const DEFAULTS = {
63
63
  videoMode: 'single', // single 手动选 / shuffle 随机轮换
64
64
  videoId: '', // 选中的视频 id(视频文件的绝对路径规范化)
65
65
  videoRoots: [], // 额外的视频存放目录(默认 loops 始终包含)
66
- videoShuffleMinutes: 0 // 0 = 每次启动随机;>0 = 每 N 分钟换一段
66
+ videoShuffleMinutes: 0, // 0 = 每次启动随机;>0 = 每 N 分钟换一段
67
+ colorMode: 'color' // color 彩色 | mono 黑白(原版素材,不染色)
67
68
  }
68
69
 
69
70
  function readConfig() {
@@ -407,6 +408,8 @@ export default {
407
408
  const cfg = readConfig()
408
409
  const cur = resolveImage(cfg)
409
410
  const dir = wallpaperImageDir()
411
+ /* v2.13.17:目录不存在时直接建出来,面板不再显示「?」,打开按钮也才有东西可开 */
412
+ try { mkdirSync(dir, { recursive: true }) } catch { /* 权限不足就保持原样 */ }
410
413
  json(res, 200, {
411
414
  ok: true,
412
415
  dropDir: dir.replace(/\\/g, '/'),
@@ -464,7 +467,7 @@ export default {
464
467
  * 只接受白名单键,值做长度/类型校验,避免面板把配置写成垃圾。
465
468
  */
466
469
  const ALLOWED_SET = new Set(['image', 'imageFit', 'veilLightA', 'veilLightB', 'veilDarkA', 'veilDarkB', 'veilBlur', 'banner', 'bannerOpacity',
467
- 'bgMode', 'videoMode', 'videoId', 'videoShuffleMinutes'])
470
+ 'bgMode', 'videoMode', 'videoId', 'videoShuffleMinutes', 'colorMode'])
468
471
  const setHandler = (req, res) => {
469
472
  let body = ''
470
473
  req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy() })
@@ -483,6 +486,7 @@ export default {
483
486
  continue
484
487
  }
485
488
  if (k === 'bgMode') { if (v === 'image' || v === 'video') { cfg.bgMode = v; applied.bgMode = v } continue }
489
+ if (k === 'colorMode') { if (v === 'color' || v === 'mono') { cfg.colorMode = v; applied.colorMode = v } continue }
486
490
  if (k === 'videoMode') { if (v === 'single' || v === 'shuffle') { cfg.videoMode = v; applied.videoMode = v } continue }
487
491
  if (k === 'videoShuffleMinutes') {
488
492
  const n = Number(v)
@@ -691,6 +695,8 @@ export default {
691
695
  }
692
696
  }
693
697
 
698
+ /* v2.13.17:开机就把丢图目录建好;老用户/新装用户都不会再看到「?」 */
699
+ try { mkdirSync(wallpaperImageDir(), { recursive: true }) } catch { /* 忽略 */ }
694
700
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/config', handler: configHandler }))
695
701
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/image', handler: imageHandler }))
696
702
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/images', handler: imagesHandler }))
package/lib/splash.js CHANGED
@@ -88,7 +88,8 @@
88
88
  voiceCue: true, // 念台词前的一声轻提示
89
89
  sfx: 'audio', // audio=真素材(用户音频里抠的音效)/ synth=现场合成
90
90
  voiceSource: 'asset', // asset=优先用真声素材 / tts=全部 TTS 合成
91
- voiceLead: 240, // 提示音之后留一小段空白再开口
91
+ voiceLead: 240,
92
+ welcomeDelay: 550, // 终幕欢迎语相对 05→06 和声钟的延迟(ms)
92
93
  identity: 'JOYCE MOORE', // ← 设置页可改的"身份名称"
93
94
  identityId: '0087',
94
95
  accessLevel: '3',
@@ -264,6 +265,9 @@
264
265
  没有就回落全局 lib/assets/line-N.mp3(原来的行为)。
265
266
  这样不同模板可以有各自的配音,互不覆盖。 */
266
267
  function assetUrl(name) {
268
+ /* 扩展音色包:slot 形如 voice:a:line-1,走 /dsh-startup/voice/a/line-1.mp3 */
269
+ var mv = /^voice:([a-z]):(line-[1-9]\d?)$/.exec(name)
270
+ if (mv) return '/dsh-startup/voice/' + mv[1] + '/' + mv[2] + '.mp3'
267
271
  var map = (TEMPLATE && TEMPLATE.assets) || null
268
272
  var file = (map && map[name]) ? String(map[name]).replace(/^.*\//, '') : ''
269
273
  if (file && /^[a-z0-9][a-z0-9._-]{0,40}\.(mp3|wav|ogg|m4a|aac)$/i.test(file) && TEMPLATE && TEMPLATE.id) {
@@ -1299,7 +1303,6 @@
1299
1303
  function ensureAsset(name) {
1300
1304
  if (buffers[name]) return Promise.resolve(true)
1301
1305
  if (pending[name]) return pending[name]
1302
- if (C.voiceSource === 'tts') return Promise.resolve(false)
1303
1306
  pending[name] = new Promise(function (resolve) {
1304
1307
  try {
1305
1308
  fetch(assetUrl(name))
@@ -1314,20 +1317,58 @@
1314
1317
  return pending[name]
1315
1318
  }
1316
1319
 
1317
- /** 播素材并等它播完,resolve 实际时长(ms)。拿不到素材 resolve 0。 */
1318
- function playAssetAndWait(name, gain) {
1320
+ /* 配音效果预设:与模板编辑器里的 5 个音效同参数(AI 机械音/机器人/无线电/老电视/低沉)。
1321
+ 素材先解码成 buffer,再在 BufferSource 后面串滤波 + 失真 + 环形调制,实时生效。 */
1322
+ var VOICE_FX_PRESETS = {
1323
+ 'ai-mech': { hp: 130, lp: 4200, peak: { f: 260, g: 4, q: 1.0 }, drive: 0.15, ring: 0 },
1324
+ 'robot': { hp: 180, lp: 3200, peak: { f: 1500, g: 3, q: 0.9 }, drive: 0.35, ring: 42 },
1325
+ 'radio': { hp: 420, lp: 2800, peak: { f: 1800, g: 5, q: 1.2 }, drive: 0.6, ring: 0 },
1326
+ 'oldtv': { hp: 220, lp: 2200, peak: null, drive: 0.45, ring: 0 },
1327
+ 'dark': { hp: 90, lp: 1200, peak: { f: 180, g: 5, q: 0.8 }, drive: 0.1, ring: 0 }
1328
+ }
1329
+ function voiceDriveCurve(amount) {
1330
+ var k = Math.max(0, Math.min(1, Number(amount) || 0)) * 60
1331
+ var n = 1024, curve = new Float32Array(n)
1332
+ for (var i = 0; i < n; i++) {
1333
+ var x = (i * 2) / n - 1
1334
+ curve[i] = ((3 + k) * x * 20 * Math.PI / 180) / (Math.PI + k * Math.abs(x))
1335
+ }
1336
+ return curve
1337
+ }
1338
+ /** 播素材并等它播完,resolve 实际时长(ms)。fx 传 5 个效果之一;拿不到素材 resolve 0。 */
1339
+ function playAssetAndWait(name, gain, fx) {
1319
1340
  return new Promise(function (resolve) {
1320
1341
  var c = ac(true)
1321
1342
  var buf = buffers[name]
1322
1343
  if (!c || !buf) { resolve(0); return }
1344
+ var osc = null
1323
1345
  try {
1324
1346
  var src = c.createBufferSource()
1325
1347
  src.buffer = buf
1326
1348
  var g = c.createGain()
1327
1349
  g.gain.value = Math.min(1, (gain == null ? 0.85 : gain) * clamp01(C.voiceVolume, 0, 1) * 1.6)
1328
- src.connect(g); g.connect(c.destination)
1329
- src.onended = function () { resolve(Math.round(buf.duration * 1000)) }
1350
+ var node = src
1351
+ var P = fx && VOICE_FX_PRESETS[fx]
1352
+ if (P) {
1353
+ if (P.hp) { var hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = P.hp; node.connect(hp); node = hp }
1354
+ if (P.lp) { var lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = P.lp; node.connect(lp); node = lp }
1355
+ if (P.peak) { var pk = c.createBiquadFilter(); pk.type = 'peaking'; pk.frequency.value = P.peak.f; pk.gain.value = P.peak.g; pk.Q.value = P.peak.q; node.connect(pk); node = pk }
1356
+ if (P.drive) { var ws = c.createWaveShaper(); ws.curve = voiceDriveCurve(P.drive); ws.oversample = '2x'; node.connect(ws); node = ws }
1357
+ if (P.ring) {
1358
+ var ringGain = c.createGain(); ringGain.gain.value = 0.55
1359
+ osc = c.createOscillator(); osc.frequency.value = P.ring; osc.type = 'sine'
1360
+ var modGain = c.createGain(); modGain.gain.value = 0.45
1361
+ osc.connect(modGain); modGain.connect(ringGain.gain)
1362
+ node.connect(ringGain); node = ringGain
1363
+ }
1364
+ }
1365
+ node.connect(g); g.connect(c.destination)
1366
+ src.onended = function () {
1367
+ if (osc) { try { osc.stop() } catch (eO) {}; try { osc.disconnect() } catch (eD) {} }
1368
+ resolve(Math.round(buf.duration * 1000))
1369
+ }
1330
1370
  src.start()
1371
+ if (osc) { try { osc.start() } catch (eS) {} }
1331
1372
  } catch (e) { resolve(0) }
1332
1373
  })
1333
1374
  }
@@ -1599,28 +1640,32 @@
1599
1640
  * onend 时按估算兜底,绝不把动画卡死。
1600
1641
  */
1601
1642
  line: function (en, zh, asset) {
1602
- /* 有真声素材就优先用素材(音色就是你给的那个);拿不到再回落 TTS */
1603
- if (asset && C.voice && C.voiceSource !== 'tts' && !finished) {
1604
- return audio.ensureAsset(asset).then(function (ok) {
1605
- if (!ok || finished) return 0
1606
- if (C.voiceCue !== false) audio.cue()
1607
- return audio.playAssetAndWait(asset, 0.85).then(function (ms) {
1608
- if (ms > 0) setMeter(true)
1609
- return ms
1610
- })
1611
- }).then(function (ms) {
1612
- if (ms > 0) { saidOnce = true; return ms }
1613
- return ttsLine(en, zh)
1614
- })
1643
+ /* v2.13.16:旧 TTS 配音链路退役。这里只用「音色包 + 声音效果」两段:
1644
+ 音色包决定用哪套干音(a–k);效果在运行时叠加。
1645
+ 素材缺失时静音,不再回落系统 TTS,避免旧机械音盖掉新音色。 */
1646
+ if (!asset || !C.voice || finished) return Promise.resolve(0)
1647
+ var useAsset = asset
1648
+ var tplAssets = (TEMPLATE && TEMPLATE.assets) || null
1649
+ var isGlobalLine = /^line-[1-9]\d?$/.test(asset) && !(tplAssets && tplAssets[asset])
1650
+ if (isGlobalLine) {
1651
+ var pack = String(C.voicePack || 'a')
1652
+ if (/^[a-k]$/.test(pack)) useAsset = 'voice:' + pack + ':' + asset
1615
1653
  }
1616
- return ttsLine(en, zh)
1654
+ var fx = String(C.voiceFx || 'none')
1655
+ return audio.ensureAsset(useAsset).then(function (ok) {
1656
+ if (!ok || finished) return 0
1657
+ if (C.voiceCue !== false) audio.cue()
1658
+ return audio.playAssetAndWait(useAsset, 0.85, isGlobalLine ? fx : 'none').then(function (ms) {
1659
+ if (ms > 0) setMeter(true)
1660
+ return ms
1661
+ })
1662
+ })
1617
1663
  },
1618
-
1619
1664
  /** TTS 路径:素材缺失 / voiceSource=tts / 终幕那条动态名字 */
1620
1665
  tts: function (en, zh) { return ttsLine(en, zh) },
1621
1666
 
1622
1667
  /** 不等待的版本:设置页试听 / 任何"念完就行"的场合 */
1623
- say: function (en, zh) { return this.line(en, zh) },
1668
+ say: function (en, zh) { return ttsLine(en, zh) },
1624
1669
 
1625
1670
  /** 浏览器要求"先有用户手势"才放声:第一次点击/按键解锁 + 补播当前行 */
1626
1671
  unlock: function () {
@@ -3455,7 +3500,12 @@
3455
3500
  /* 终幕欢迎语:用从 Video Project 3 截出来的真声素材(line-6)。
3456
3501
  素材缺失 / voiceSource=tts / voice 关闭时自动静音,不会退回 TTS 念白 ——
3457
3502
  因为这条素材是「欢迎访问」整句,和动态身份名称无关。 */
3458
- var welcome = voice.line(null, null, 'line-6')
3503
+ /* 05→06 的过场和声钟约 1.5 秒。旧行为是钟声一响欢迎语就开口,听感太挤;
3504
+ 现在按配置等钟声收尾再进最后一句,默认 1500ms,可在设置里微调。 */
3505
+ var welcomeDelay = Number(C.welcomeDelay)
3506
+ if (!isFinite(welcomeDelay)) welcomeDelay = 1500
3507
+ welcomeDelay = Math.max(0, Math.min(3000, welcomeDelay))
3508
+ var welcome = wait(welcomeDelay).then(function () { return voice.line(null, null, 'line-6') })
3459
3509
 
3460
3510
  ui.status.classList.remove('dsu-in')
3461
3511
  ui.subEn.classList.remove('dsu-in')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-boot-suite",
3
- "version": "2.13.12",
4
- "description": "DeepSeek Harness 开机套件:冷启动开机动画 + 聊天壁纸。开机动画走设置页「启动动画」(两幕可选,默认档案暖白 → 鲸鱼娘扫描睁眼;支持导入自定义动画、三段自定义转场、本地动画删除、真声台词 + 界面音效);聊天壁纸走设置页「聊天背景」(自带一张默认背景图,开箱即有;静态底图(可铺满或完整显示)/ 循环视频 + 大字横幅,聊天正文文字随壁纸+纱的实际亮度自适应(纱浓度可调),侧边栏/标题栏跟随外观深浅;桌面窗口按钮颜色固定高对比,支持丢图即换、自定义视频目录、壁纸引擎项目文件夹、纱的浓度与镜头模糊调节,视频默认静音循环;更新等待最长 30 分钟、可一键打开下载文件夹或手动选择安装包,诊断报告生成中会防连点)。两个半侧各自独立:动画坏了不影响壁纸,壁纸坏了不影响动画。",
3
+ "version": "2.13.20",
4
+ "description": "DeepSeek Harness 启动动画与聊天背景插件:两幕开机动画、自定义动画导入、三段转场、6 套干音音色(英文 1 + 日文 5)+ 5 种声音效果、聊天背景图片/循环视频、更新前自动回滚;聊天背景新增黑白原版素材模式。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -112,6 +112,12 @@ const fetchStub = (url, init) => {
112
112
  if (!HIDDEN.includes(id)) HIDDEN.push(id)
113
113
  return Promise.resolve({ json: () => Promise.resolve({ ok: true, id, removed: false, hidden: true }) })
114
114
  }
115
+ if (u.includes('/dsh-startup/voices')) {
116
+ return Promise.resolve({ json: () => Promise.resolve({ ok: true, packs: [
117
+ { id: 'f', name: '英文音色F(默认)', lang: 'en' },
118
+ { id: 'g', name: '日文音色1', lang: 'ja' },
119
+ ], default: 'f' }) })
120
+ }
115
121
  if (u.includes('/dsh-startup/templates')) {
116
122
  return Promise.resolve({ json: () => Promise.resolve({ ok: true, templates: liveTemplates(), hidden: HIDDEN.slice() }) })
117
123
  }
@@ -210,11 +216,14 @@ check('渲染出「权限等级」字段', texts.includes('权限等级'))
210
216
  const idInput = find(tree, (n) => n.type === 'input' && n.props.type === 'text' && n.props.value === 'JOYCE MOORE')
211
217
  check('身份名称输入框回填了当前配置', !!idInput, idInput && idInput.props.value)
212
218
  check('渲染出「启动动画」开关', texts.includes('启用启动动画'))
213
- check('渲染出「女声播报」开关', texts.includes('女声播报(念每一行的英文)'))
214
- check('渲染出「播报语言」字段', texts.includes('播报语言'))
219
+ check('渲染出「启用配音播报」开关', texts.includes('启用配音播报'))
220
+ check('已删除旧 TTS「播报语言」字段', !texts.includes('播报语言'))
221
+ check('渲染出「配音音色」字段', texts.includes('配音音色'))
222
+ check('渲染出「声音效果」字段', texts.includes('声音效果'))
223
+ check('渲染出「终幕语音延迟」字段', texts.includes('终幕语音延迟'))
215
224
  check('渲染出「界面音效」开关', texts.includes('界面音效'))
216
- check('渲染出音色选择(无 speechSynthesis 时给出提示)', texts.includes('音色'))
217
- check('渲染出语速/音调/音量', texts.includes('语速') && texts.includes('音调') && texts.includes('音量'))
225
+ check('已删除旧 TTS 音色选择', !texts.includes('试听所选音色') && !texts.includes('语音来源'))
226
+ check('渲染出音量、不再有语速/音调', texts.includes('音量') && !texts.includes('语速') && !texts.includes('音调'))
218
227
  check('渲染出「立即预览」按钮', !!find(tree, (n) => n.type === 'button' && n.props.children === '立即预览'))
219
228
  check('渲染出「恢复默认」按钮', !!find(tree, (n) => n.type === 'button' && n.props.children === '恢复默认'))
220
229
 
@@ -491,6 +500,20 @@ previewBtn.props.onClick()
491
500
  await flush(); await flush(); await flush()
492
501
  check('立即预览先重新拉配置', requests.length > beforePreview && requests.slice(beforePreview).some((r) => /\/dsh-startup\/config/.test(r.url || '')), '')
493
502
  check('立即预览调用 __DSH_STARTUP_API__.replay()', windowStub.__replayed === true)
503
+ /* v2.13.15 回归:选完「配音音色」直接点预览(还没点保存)也必须按当前选择播 */
504
+ tree = renderPanel()
505
+ const voiceSel = walk(tree).filter((n) => n.type === 'select' && Array.isArray(n.props.children) && n.props.children.some((o) => o && o.props.value === 'g'))[0]
506
+ check('配音音色下拉能从 /voices 拿到音色G', !!voiceSel, JSON.stringify(walk(tree).filter((n) => n.type === 'select').map((n) => (n.props.children || []).map((o) => o && o.props.value))))
507
+ if (voiceSel) {
508
+ voiceSel.props.onChange({ target: { value: 'g' } })
509
+ await flush()
510
+ tree = renderPanel()
511
+ const previewBtn2 = find(tree, (n) => n.type === 'button' && n.props.children === '立即预览')
512
+ previewBtn2.props.onClick()
513
+ await flush(); await flush(); await flush()
514
+ check('立即预览使用未保存的 draft(voicePack=g)', windowStub.__DSH_STARTUP__ && windowStub.__DSH_STARTUP__.voicePack === 'g', windowStub.__DSH_STARTUP__ && windowStub.__DSH_STARTUP__.voicePack)
515
+ check('preview 合并 draft 的源码标记存在', code.includes('j.config, draft || {}'))
516
+ }
494
517
  delete windowStub.__DSH_STARTUP_API__
495
518
  previewBtn.props.onClick()
496
519
  await flush(); await flush(); await flush()
@@ -132,7 +132,7 @@ check('没有 Config 导出(普通对象不是 schemastery schema)', mod.Con
132
132
 
133
133
  console.log('\n[2] apply(ctx):路由与注入')
134
134
  mod.apply(ctx)
135
- check('注册了 38 条路由(开机动画 27 + 聊天壁纸 11)', ROUTES.size === 38, [...ROUTES.keys()].join(' | '))
135
+ check('注册了 40 条路由(开机动画 29 + 聊天壁纸 11)', ROUTES.size === 40, [...ROUTES.keys()].join(' | '))
136
136
  check('注册了 1 个 tapIndex', TAPS.length === 1)
137
137
  check('用了 ctx.effect 管理生命周期(开机动画 1 + 壁纸 11)', effects === 12, String(effects))
138
138
 
@@ -154,9 +154,9 @@ r = await call('prefix /dsh-startup/asset', 'GET', undefined, '/dsh-startup/asse
154
154
  check('目录穿越被拒 -> 404', r.statusCode === 404, String(r.statusCode))
155
155
  r = await call('prefix /dsh-startup/asset', 'GET', undefined, '/dsh-startup/asset/nope.mp3')
156
156
  check('不存在的素材 -> 404', r.statusCode === 404, String(r.statusCode))
157
- for (const f of ['line-1', 'line-2', 'line-3', 'line-4', 'line-5']) {
158
- const rr = await call('prefix /dsh-startup/asset', 'GET', undefined, '/dsh-startup/asset/' + f + '.mp3')
159
- check('真声素材 ' + f + '.mp3 -> 200', rr.statusCode === 200 && rr.body.length > 5000, rr.statusCode + ' ' + rr.body.length)
157
+ for (const f of ['line-1', 'dry-line-1']) {
158
+ const rr = await call('prefix /dsh-startup/asset', 'GET', undefined, '/dsh-startup/asset/' + f + '.mp3')
159
+ check('旧 TTS 素材已退役:' + f + '.mp3 -> 404', rr.statusCode === 404, String(rr.statusCode))
160
160
  }
161
161
 
162
162
  console.log('\n[4] 配置读写落盘')
@@ -165,6 +165,9 @@ let cfg = JSON.parse(r.body)
165
165
  check('GET config ok', cfg.ok === true)
166
166
  check('默认身份名称 = JOYCE MOORE', cfg.config.identity === 'JOYCE MOORE', cfg.config.identity)
167
167
  check('默认身份编号 = 0087', cfg.config.identityId === '0087', cfg.config.identityId)
168
+ check('默认配音音色 = F(英文)', cfg.config.voicePack === 'f', cfg.config.voicePack)
169
+ check('默认声音效果 = 机器人(robot)', cfg.config.voiceFx === 'robot', cfg.config.voiceFx)
170
+ check('默认终幕语音延迟 = 550ms', cfg.config.welcomeDelay === 550, cfg.config.welcomeDelay)
168
171
  check('返回了配置文件路径', typeof cfg.file === 'string' && cfg.file.startsWith(SMOKE_HOME), cfg.file)
169
172
  check('返回了插件版本号(设置面板顶部要显示它)', typeof cfg.version === 'string' && /^\d+\.\d+\.\d+/.test(cfg.version), cfg.version)
170
173
 
@@ -433,6 +436,22 @@ console.log('\n[6h] v2.13.12:打开下载文件夹路由存在且只接受 POS
433
436
  check('GET /open-downloads 返回 405', getOpen.statusCode === 405, String(getOpen.statusCode))
434
437
  }
435
438
 
439
+ console.log('\n[6i] v2.13.19:6 套音色包清单与只读音频路由')
440
+ {
441
+ const vp = await call('exact /dsh-startup/voices', 'GET')
442
+ const vpj = JSON.parse(vp.body)
443
+ check('GET /voices ok', vpj.ok === true)
444
+ check('6 个音色包(f、g–k)', Array.isArray(vpj.packs) && vpj.packs.length === 6, String(vpj.packs && vpj.packs.length))
445
+ check('F 包英文 / K 包日文', vpj.packs[0].id === 'f' && vpj.packs[0].lang === 'en' && vpj.packs[5].id === 'k' && vpj.packs[5].lang === 'ja', JSON.stringify(vpj.packs.slice(0, 1).concat(vpj.packs.slice(-1))))
446
+ check('默认音色 = f', vpj.default === 'f', String(vpj.default))
447
+ const va = await call('prefix /dsh-startup/voice', 'GET', undefined, '/dsh-startup/voice/f/line-1.mp3')
448
+ check('音色F line-1 音频 200 且是 mp3', va.statusCode === 200 && va.body.length > 1000 && (va.body[0] === 0xFF || va.body.slice(0, 3).toString() === 'ID3'), va.statusCode + ' ' + va.body.length)
449
+ const vb = await call('prefix /dsh-startup/voice', 'GET', undefined, '/dsh-startup/voice/f/line-7.mp3')
450
+ check('非法句号 line-7 -> 404', vb.statusCode === 404, String(vb.statusCode))
451
+ const vz = await call('prefix /dsh-startup/voice', 'GET', undefined, '/dsh-startup/voice/z/line-1.mp3')
452
+ check('未注册音色 z -> 404', vz.statusCode === 404, String(vz.statusCode))
453
+ }
454
+
436
455
  console.log('\n[7] XSS 边界:身份名称里的尖括号不会逃出内联脚本')
437
456
  await call('exact /dsh-startup/config', 'POST', JSON.stringify({ patch: { identity: '</script><img src=x onerror=alert(1)>' } }))
438
457
  const payload = inlinePayload(renderRows(before, collectRows()))
@@ -8,7 +8,7 @@ if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
8
8
  }
9
9
  const pkg = JSON.parse(buf.toString('utf8'));
10
10
  if (!pkg.version) throw new Error('package.json 缺少 version');
11
- for (const f of ['lib/index.js', 'lib/splash.js', 'lib/client.js', 'lib/assets/default-background.jpg', 'templates', 'tools/install.ps1', 'tools/update-apply.ps1', 'tools/rollback-version.ps1']) {
11
+ for (const f of ['lib/index.js', 'lib/splash.js', 'lib/client.js', 'lib/assets/default-background.jpg', 'templates', 'tools/install.ps1', 'tools/update-apply.ps1', 'tools/rollback-version.ps1', 'lib/assets/voices/manifest.json']) {
12
12
  if (!fs.existsSync(path.join(root, f))) throw new Error('缺少 ' + f);
13
13
  }
14
14
  const installPs1 = fs.readFileSync(path.join(root, 'tools/install.ps1'), 'utf8');
@@ -24,4 +24,7 @@ const rollbackPs1 = fs.readFileSync(path.join(root, 'tools/rollback-version.ps1'
24
24
  for (const needle of ['TargetPkg', 'CheckOnly', 'DSH_BOOT_SUITE_ROLLBACK', '回滚日志']) {
25
25
  if (!rollbackPs1.includes(needle)) throw new Error('rollback-version.ps1 缺少关键逻辑:' + needle);
26
26
  }
27
+ for (const f of ['line-1.mp3', 'line-6.mp3', 'dry-line-1.mp3']) {
28
+ if (fs.existsSync(path.join(root, 'lib/assets', f))) throw new Error('旧 TTS 配音素材未删除:' + f);
29
+ }
27
30
  console.log('ok package.json version=' + pkg.version + ',无 BOM,关键文件齐全(含更新前回滚 checkpoint)');
@@ -327,11 +327,18 @@ console.log('\n[8] 终幕用真声欢迎语(来自 Video Project 3 的 line-6
327
327
  {
328
328
  const src = fs.readFileSync(BUNDLE, 'utf8')
329
329
  check('全程没有 voice.tts( 调用(不再用 TTS 念白)', !/voice\.tts\(/.test(src))
330
- check('终幕挂了 line-6 素材', /voice\.line\(null, null, 'line-6'\)/.test(src))
330
+ check('终幕挂了 line-6 素材', /voice\.line\(null, null, 'line-6'\)/.test(src))
331
+ check('终幕欢迎语按 welcomeDelay 延迟后播放', /C\.welcomeDelay/.test(src) && /welcomeDelay = Math\.max\(0, Math\.min\(3000/.test(src))
331
332
  check('揭幕前会等欢迎语播完', /\.then\(function \(\) \{ return welcome \}\)/.test(src))
332
333
  check('预加载包含 line-6', /ensureAsset\('line-6'\)/.test(src))
333
334
  check('五步仍然各自挂着真声素材', (src.match(/audio: 'line-/g) || []).length === 5)
334
335
  check('总共 6 份人声素材被引用', (src.match(/'line-[1-6]'/g) || []).length >= 6)
336
+ check('配音音色可切换:voicePack 为 f–k 时走 voice:<pack>:line-N 路由',
337
+ /C\.voicePack/.test(src) && /'voice:' \+ pack \+ ':' \+ asset/.test(src)
338
+ && /tplAssets && tplAssets\[asset\]/.test(src))
339
+ check('5 套声音效果预设都在,播放链路按 C.voiceFx 叠加',
340
+ ['ai-mech', 'robot', 'radio', 'oldtv', 'dark'].every((k) => src.includes("'" + k + "':"))
341
+ && /playAssetAndWait\(useAsset, 0\.85, isGlobalLine \? fx : 'none'\)/.test(src))
335
342
  }
336
343
 
337
344
  console.log('\n[9] 模板:分幕可以自定义(1~24 幕,缺字段回落内置)')
@@ -463,7 +463,7 @@ ok(intervals.every((t) => t.cleared) || intervals.length === 0, '旧的 interval
463
463
  intervals.map((t) => (t.cleared ? 'cleared' : 'live')).join(','))
464
464
 
465
465
  console.log('\n=== ⑦ v2.13.0 测试钩子 / BUILD ===')
466
- ok(mod.__BUILD === 'cw-35', '__BUILD = cw-35(排查浏览器跑的是不是新包)', String(mod.__BUILD))
466
+ ok(mod.__BUILD === 'cw-43', '__BUILD = cw-43(排查浏览器跑的是不是新包)', String(mod.__BUILD))
467
467
  ok(mod.__VIDEO_ID === 'dsh-cw-video', '__VIDEO_ID = dsh-cw-video', String(mod.__VIDEO_ID))
468
468
  ok(typeof mod.__startShuffle === 'function', '__startShuffle 导出')
469
469
  ok(typeof mod.__stopShuffle === 'function', '__stopShuffle 导出')
@@ -163,10 +163,10 @@ try {
163
163
  sortKeys(r.json) === 'config,image,imageOk,imageSource,ok,video', sortKeys(r.json))
164
164
  check('/config ok=true', r.json && r.json.ok === true, r.json && String(r.json.ok))
165
165
  const CONFIG_KEYS = ['banner', 'bannerOpacity', 'fallbackDark', 'fallbackLight', 'image', 'imageFit',
166
- 'sidebarDark', 'sidebarLight', 'veilBlur', 'veilDarkA', 'veilDarkB', 'veilLightA', 'veilLightB',
166
+ 'sidebarDark', 'sidebarLight', 'veilBlur', 'veilDarkA', 'veilDarkB', 'veilLightA', 'veilLightB', 'colorMode',
167
167
  /* v2.13.0 循环视频 */
168
168
  'bgMode', 'videoMode', 'videoId', 'videoRoots', 'videoShuffleMinutes']
169
- check('config 恰好 18 个键(12 旧 + 5 循环视频 + 1 镜头模糊)',
169
+ check('config 恰好 19 个键(12 旧 + 5 循环视频 + 1 镜头模糊 + 1 色彩模式)',
170
170
  sortKeys(r.json && r.json.config) === CONFIG_KEYS.slice().sort().join(','), sortKeys(r.json && r.json.config))
171
171
  const DEAD_KEYS = ['video', 'mode', 'placement', 'loopStart', 'loopEnd', 'oncePerSession',
172
172
  'waitForBoot', 'fadeOutMs', 'objectFit', 'opacity', 'playbackRate', 'fps']
@@ -177,6 +177,7 @@ try {
177
177
  check('/config 的 imageSource = builtin', r.json && r.json.imageSource === 'builtin', String(r.json && r.json.imageSource))
178
178
  check('★ v2.13.4 config.imageFit 默认 cover', r.json && r.json.config && r.json.config.imageFit === 'cover', String(r.json && r.json.config && r.json.config.imageFit))
179
179
  check('★ v2.13.11 config.veilBlur 默认 0(不模糊)', r.json && r.json.config && r.json.config.veilBlur === 0, String(r.json && r.json.config && r.json.config.veilBlur))
180
+ check('★ v2.13.17 config.colorMode 默认 color', r.json && r.json.config && r.json.config.colorMode === 'color', String(r.json && r.json.config && r.json.config.colorMode))
180
181
  const v0 = (r.json && r.json.video) || {}
181
182
  check('★ /config.video 默认值:enabled=false / mode=single / shuffleMinutes=0 / selectedId 空 / count=0 / current=null',
182
183
  v0.enabled === false && v0.mode === 'single' && v0.shuffleMinutes === 0 &&
@@ -228,6 +229,10 @@ try {
228
229
  check('★ veilBlur 下限夹到 0', r.json && r.json.applied && r.json.applied.veilBlur === 0, JSON.stringify(r.json && r.json.applied))
229
230
  r = await postJson('exact /chat-wallpaper/set', { veilBlur: 'abc' })
230
231
  check('★ 非法 veilBlur 被忽略', r.json && r.json.applied && !('veilBlur' in r.json.applied), JSON.stringify(r.json && r.json.applied))
232
+ r = await postJson('exact /chat-wallpaper/set', { colorMode: 'mono' })
233
+ check('★ v2.13.17 /set 接受 colorMode=mono', r.json && r.json.applied && r.json.applied.colorMode === 'mono', JSON.stringify(r.json && r.json.applied))
234
+ r = await postJson('exact /chat-wallpaper/set', { colorMode: 'rainbow' })
235
+ check('★ 非法 colorMode 被忽略', r.json && r.json.applied && !('colorMode' in r.json.applied), JSON.stringify(r.json && r.json.applied))
231
236
  r = await callJson('exact /chat-wallpaper/config')
232
237
  check('再读 /config:banner = B', r.json && r.json.config && r.json.config.banner === 'B', r.json && r.json.config && r.json.config.banner)
233
238
  const cfgFile = path.join(HOME, 'chat-wallpaper.json')
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file