dsh-boot-suite 2.13.21 → 2.15.16

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 (40) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +9 -2
  3. package/lib/client.js +445 -23
  4. package/lib/host-startup.js +196 -3
  5. package/lib/host-wallpaper.js +32 -11
  6. package/lib/splash.js +270 -0
  7. package/package.json +2 -2
  8. package/templates/division-bigfish-codex/app.js +269 -0
  9. package/templates/division-bigfish-codex/assets/egg-bg.png +0 -0
  10. package/templates/division-bigfish-codex/assets/voice-full-plus.wav +0 -0
  11. package/templates/division-bigfish-codex/assets/voice-line1.wav +0 -0
  12. package/templates/division-bigfish-codex/assets/voice-line2.wav +0 -0
  13. package/templates/division-bigfish-codex/assets/voice-line3.wav +0 -0
  14. package/templates/division-bigfish-codex/assets/voice-line4.wav +0 -0
  15. package/templates/division-bigfish-codex/assets/voice-orig1.wav +0 -0
  16. package/templates/division-bigfish-codex/assets/voice-orig3.wav +0 -0
  17. package/templates/division-bigfish-codex/assets/voice-reconnect.wav +0 -0
  18. package/templates/division-bigfish-codex/assets/whale-badge-line.png +0 -0
  19. package/templates/division-bigfish-codex/config.js +24 -0
  20. package/templates/division-bigfish-codex/index.html +1 -0
  21. package/templates/division-bigfish-codex/motion.js +30 -0
  22. package/templates/division-bigfish-codex/preview-easteregg.mp4 +0 -0
  23. package/templates/division-bigfish-codex/preview-normal.mp4 +0 -0
  24. package/templates/division-bigfish-codex/render.html +1 -0
  25. package/templates/division-bigfish-codex/style.css +18 -0
  26. package/templates/division-bigfish-codex/template.json +10 -0
  27. package/templates/division-bigfish-codex/voice-env.js +1 -0
  28. package/templates/division-bigfish-codex/voice.js +10 -0
  29. package/templates/division-bigfish-codex//344/272/244/346/216/245-Codex/347/211/210/346/216/245/345/205/245/346/226/271/346/241/210.md +207 -0
  30. package/templates/division-bigfish-codex//346/217/222/344/273/266/346/216/245/345/205/245/345/245/221/347/272/246.md +177 -0
  31. package/test/smoke-client.mjs +4 -4
  32. package/test/smoke-host.mjs +23 -1
  33. package/test/smoke-package.mjs +92 -1
  34. package/test/smoke-splash.mjs +2 -0
  35. package/test/smoke-wallpaper-client.mjs +1 -1
  36. package/test/smoke-wallpaper-host.mjs +7 -1
  37. package/tools/README.md +1 -1
  38. package/tools/install.ps1 +7 -4
  39. package/tools/make-upgrade-helper.mjs +20 -0
  40. package/tools/stage-local.mjs +158 -0
@@ -91,6 +91,13 @@ const DEFAULTS = {
91
91
  voiceFx: 'robot', // 声音效果:none | ai-mech | robot | radio | oldtv | dark(默认机器人,贴近旧版听感)
92
92
  voiceLead: 240, // 提示音之后留一小段空白再开口
93
93
  welcomeDelay: 550, // 05→06 过场和声钟之后等多久再进终幕欢迎语(0–3000ms;实测 550 最顺)
94
+ /* v2.15.0:iframe 启动模板(division-bigfish-codex)专属文案覆盖;空串 = 用模板自带的 config.js */
95
+ bigfishUsername: '',
96
+ bigfishTitle: '',
97
+ bigfishDesc: '',
98
+ bigfishForceEgg: false, // 仅下一次启动:强制判定掉线彩蛋(便于测试)
99
+ bigfishVoiceFx: 'none', // 全景大肥鱼模板内五句配音的效果:none|ai-mech|robot|radio|oldtv|dark
100
+ bigfishVoiceVolume: 50, // 全景大肥鱼模板内五句配音的音量(0–100,默认 50%)
94
101
  identity: 'JOYCE MOORE', // ← 设置页里可改的「身份名称」
95
102
  identityId: '0087',
96
103
  accessLevel: '3',
@@ -115,6 +122,26 @@ const DEFAULTS = {
115
122
  transitionMs: 600,
116
123
  transitionDir: 'left', // 仅 scanline / slide 使用:left|right|up|down
117
124
  hiddenTemplates: [], // v2.12.4:被用户「隐藏」的内置模板 id 列表(本地模板删除走文件)
125
+ testTemplates: [], // v2.14.0:被归到「测试类模板」的模板 id 列表(正式下拉不再显示)
126
+ promotedTemplates: [], // v2.14.0:被用户从测试类放回正式列表的模板 id 列表
127
+ /* v2.14.0 用户自备 BGM:只读本机文件、不上传、不内置。三段 = 第一幕 / 第二幕(含终幕) / 结尾淡出 */
128
+ bgmEnabled: false,
129
+ bgmLoop: true, // 整体循环;关掉后拍速/拍数/选段不再参与,整首只播一遍
130
+ bgmFile: '', // 主音乐(存于 ~/.dsh/startup-music 的文件名)
131
+ bgmFile2: '', // 第二段换曲;'' = 同主音乐
132
+ bgmVolume: 0.5,
133
+ bgmBpm: 100, // 拍速(只用于算选段时长)
134
+ bgmBeats1: 16, // 第一段拍数(选段时长 = 拍数 * 60 / BPM)
135
+ bgmBeats2: 32, // 第二段拍数
136
+ bgm1Start: 0, // 第一段在音频中的起点(秒)
137
+ bgm2Start: 0, // 第二段起点(秒)
138
+ bgm1FadeIn: 1, // 第一段淡入(秒)
139
+ bgm1FadeOut: 1, // 第一段淡出(到第二段前,秒)
140
+ bgm2FadeIn: 1, // 第二段淡入(秒)
141
+ bgm2FadeOut: 2, // 第二段/结束淡出(秒)
142
+ bgmCrossfade: 1200, // 两段交叉时长(毫秒)
143
+ bgm2Loop: false, // 第二段是否循环
144
+ bgmDuck: true // 配音时自动降低音乐(闪避)
118
145
  }
119
146
 
120
147
  const TEXT_KEYS = [
@@ -139,7 +166,7 @@ const ENUMS = {
139
166
  transitionOut: TRANSITION_TYPES,
140
167
  transitionDir: TRANSITION_DIRS
141
168
  }
142
- const BOOL_KEYS = ['enabled', 'requireInteraction', 'allowSkip', 'sound', 'bedNoise', 'voice', 'voiceCue']
169
+ const BOOL_KEYS = ['enabled', 'requireInteraction', 'allowSkip', 'sound', 'bedNoise', 'voice', 'voiceCue', 'bgmEnabled', 'bgmLoop', 'bgm2Loop', 'bgmDuck']
143
170
  /** 数值字段的合法区间(越界即夹取)。 */
144
171
  const RANGES = {
145
172
  welcomeDelay: [0, 3000],
@@ -149,7 +176,19 @@ const RANGES = {
149
176
  voiceRate: [0.5, 2],
150
177
  voiceLead: [0, 1200],
151
178
  voicePitch: [0, 2],
152
- voiceVolume: [0, 1]
179
+ voiceVolume: [0, 1],
180
+ bgmVolume: [0, 1],
181
+ bgmBpm: [40, 220],
182
+ bgmBeats1: [4, 128],
183
+ bgmBeats2: [4, 128],
184
+ bgm1Start: [0, 7200],
185
+ bgm2Start: [0, 7200],
186
+ bgm1FadeIn: [0, 10],
187
+ bgm1FadeOut: [0, 10],
188
+ bgm2FadeIn: [0, 10],
189
+ bgm2FadeOut: [0, 10],
190
+ bgmCrossfade: [0, 5000],
191
+ bigfishVoiceVolume: [0, 100]
153
192
  }
154
193
 
155
194
  function normalize(input) {
@@ -175,8 +214,15 @@ function normalize(input) {
175
214
  out[key] = value
176
215
  }
177
216
  }
217
+ const tplIdOk = (x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(x)
178
218
  out.hiddenTemplates = Array.isArray(src.hiddenTemplates)
179
- ? [...new Set(src.hiddenTemplates.filter((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(x)))].slice(0, 60)
219
+ ? [...new Set(src.hiddenTemplates.filter(tplIdOk))].slice(0, 60)
220
+ : []
221
+ out.testTemplates = Array.isArray(src.testTemplates)
222
+ ? [...new Set(src.testTemplates.filter(tplIdOk))].slice(0, 200)
223
+ : []
224
+ out.promotedTemplates = Array.isArray(src.promotedTemplates)
225
+ ? [...new Set(src.promotedTemplates.filter(tplIdOk))].slice(0, 200)
180
226
  : []
181
227
  if (!String(out.identity).trim()) out.identity = DEFAULTS.identity
182
228
  if (!String(out.identityId).trim()) {
@@ -577,6 +623,57 @@ function apply(ctx) {
577
623
  ]
578
624
  const TEMPLATE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/
579
625
 
626
+ /* v2.15.0:iframe 启动模板的静态文件路由。
627
+ /dsh-startup/template-file/<模板id>/<相对路径>:只放行白名单扩展名,禁止 .. 和绝对路径。
628
+ division-bigfish-codex/config.js 会追加设置页里的「连接用户名 / 标题 / 正文」覆盖。 */
629
+ const TEMPLATE_FILE_EXT_RE = /\.(html|js|css|json|wav|mp3|ogg|m4a|aac|png|jpe?g|webp|gif|svg|mp4|webm|woff2?|ttf|txt)$/i
630
+ const TEMPLATE_FILE_SEG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
631
+ const TEMPLATE_FILE_TYPES = {
632
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
633
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
634
+ '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
635
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif',
636
+ '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
637
+ '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.txt': 'text/plain; charset=utf-8'
638
+ }
639
+ disposers.push(registerRoute({
640
+ kind: 'prefix',
641
+ path: ROUTE_BASE + '/template-file',
642
+ handler: (req, res) => {
643
+ const rest = decodeURIComponent(String(req.url || '').split('?')[0])
644
+ .slice((ROUTE_BASE + '/template-file/').length)
645
+ const parts = rest.split('/')
646
+ const id = String(parts[0] || '')
647
+ const rel = parts.slice(1).join('/')
648
+ if (!TEMPLATE_ID_RE.test(id) || !rel || rel.includes('..') || rel.includes(':')) { res.writeHead(404); res.end(); return }
649
+ if (!rel.split('/').every((seg) => TEMPLATE_FILE_SEG_RE.test(seg))) { res.writeHead(404); res.end(); return }
650
+ if (!TEMPLATE_FILE_EXT_RE.test(rel)) { res.writeHead(404); res.end(); return }
651
+ const roots = [path.join(USER_TPL_ROOT, id), path.join(PACKAGE_ROOT, 'templates', id)]
652
+ let target = null
653
+ for (const root of roots) {
654
+ const file = path.join(root, rel)
655
+ try { if (fs.statSync(file).isFile()) { target = file; break } } catch (err) { /* 下一处 */ }
656
+ }
657
+ if (!target) { res.writeHead(404); res.end(); return }
658
+ const ext = (rel.match(TEMPLATE_FILE_EXT_RE) || [''])[0].toLowerCase()
659
+ try {
660
+ let bytes = fs.readFileSync(target)
661
+ if (id === 'division-bigfish-codex' && rel === 'config.js') {
662
+ const cfg = readConfig()
663
+ const extra = []
664
+ if (cfg.bigfishUsername) extra.push('window.CONFIG.username=' + JSON.stringify(String(cfg.bigfishUsername)) + ';')
665
+ if (cfg.bigfishTitle) extra.push('window.CONFIG.title=' + JSON.stringify(String(cfg.bigfishTitle)) + ';')
666
+ if (cfg.bigfishDesc) extra.push('window.CONFIG.description=' + JSON.stringify(String(cfg.bigfishDesc)) + ';')
667
+ if (cfg.bigfishVoiceFx && cfg.bigfishVoiceFx !== 'none') extra.push('window.CONFIG.voiceFx=' + JSON.stringify(String(cfg.bigfishVoiceFx)) + ';')
668
+ const vv = Number(cfg.bigfishVoiceVolume); if (Number.isFinite(vv)) extra.push('window.CONFIG.voiceVolume=' + Math.max(0, Math.min(1, vv / 100)) + ';')
669
+ if (extra.length) bytes = Buffer.concat([bytes, Buffer.from('\n/* dsh-boot-suite overrides */\n' + extra.join('\n') + '\n', 'utf8')])
670
+ }
671
+ res.writeHead(200, { 'Content-Type': TEMPLATE_FILE_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' })
672
+ res.end(bytes)
673
+ } catch (err) { res.writeHead(500); res.end() }
674
+ }
675
+ }))
676
+
580
677
  function readTemplate(id) {
581
678
  if (!id || !TEMPLATE_ID_RE.test(String(id))) return null
582
679
  try { if ((readConfig().hiddenTemplates || []).includes(String(id))) return null } catch (err) { /* 配置读不到就不隐藏 */ }
@@ -1194,6 +1291,102 @@ function apply(ctx) {
1194
1291
  }
1195
1292
  }))
1196
1293
 
1294
+ /* ── v2.14.0:用户自备背景音乐(BGM)────────────────────────────
1295
+ 文件只存本机 ~/.dsh/startup-music,不上传任何服务器;设置页只保存文件名。 */
1296
+ const MUSIC_DIR = path.join(DSH_HOME, 'startup-music')
1297
+ const MUSIC_INDEX = path.join(MUSIC_DIR, 'index.json')
1298
+ const MUSIC_FILE_RE = /^music-[a-z0-9]{3,16}-[a-z0-9._-]{1,90}\.(mp3|wav|ogg|m4a|aac|flac)$/i
1299
+ const MUSIC_EXT_RE = /\.(mp3|wav|ogg|m4a|aac|flac)$/i
1300
+ const MUSIC_TYPES = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac' }
1301
+ function readMusicIndex() {
1302
+ try {
1303
+ const arr = JSON.parse(fs.readFileSync(MUSIC_INDEX, 'utf8'))
1304
+ return Array.isArray(arr) ? arr.filter((x) => x && typeof x.file === 'string' && MUSIC_FILE_RE.test(x.file)) : []
1305
+ } catch (e) { return [] }
1306
+ }
1307
+ function writeMusicIndex(arr) {
1308
+ try {
1309
+ fs.mkdirSync(MUSIC_DIR, { recursive: true })
1310
+ fs.writeFileSync(MUSIC_INDEX, JSON.stringify(arr, null, 2) + '\n', 'utf8')
1311
+ } catch (e) { /* 索引失败不影响文件本体 */ }
1312
+ }
1313
+ function sendAudioFile(res, file) {
1314
+ const full = path.join(MUSIC_DIR, file)
1315
+ const buf = fs.readFileSync(full)
1316
+ const ext = (file.match(MUSIC_EXT_RE) || ['.mp3'])[0].toLowerCase()
1317
+ res.writeHead(200, {
1318
+ 'Content-Type': MUSIC_TYPES[ext] || 'application/octet-stream',
1319
+ 'Content-Length': String(buf.length),
1320
+ 'Cache-Control': 'no-store'
1321
+ })
1322
+ res.end(buf)
1323
+ }
1324
+ disposers.push(registerRoute({
1325
+ kind: 'exact',
1326
+ path: ROUTE_BASE + '/music/list',
1327
+ handler: async (req, res) => {
1328
+ if ((req.method || 'GET').toUpperCase() !== 'GET') { sendJson(res, 405, { ok: false }); return }
1329
+ try { sendJson(res, 200, { ok: true, files: readMusicIndex(), dir: MUSIC_DIR.replace(/\\/g, '/') }) }
1330
+ catch (err) { sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) }
1331
+ }
1332
+ }))
1333
+ disposers.push(registerRoute({
1334
+ kind: 'exact',
1335
+ path: ROUTE_BASE + '/music/file',
1336
+ handler: async (req, res) => {
1337
+ if ((req.method || 'GET').toUpperCase() !== 'GET') { sendJson(res, 405, { ok: false }); return }
1338
+ let u = null
1339
+ try { u = new URL(String(req.url || ''), 'http://localhost') } catch (eU) { sendJson(res, 400, { ok: false }); return }
1340
+ const name = String(u.searchParams.get('name') || '')
1341
+ if (!MUSIC_FILE_RE.test(name) || !fs.existsSync(path.join(MUSIC_DIR, name))) { sendJson(res, 404, { ok: false, error: 'music not found' }); return }
1342
+ try { sendAudioFile(res, name) } catch (err) { sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) }
1343
+ }
1344
+ }))
1345
+ disposers.push(registerRoute({
1346
+ kind: 'exact',
1347
+ path: ROUTE_BASE + '/music/upload',
1348
+ handler: async (req, res) => {
1349
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false }); return }
1350
+ let u = null
1351
+ try { u = new URL(String(req.url || ''), 'http://localhost') } catch (eU) { sendJson(res, 400, { ok: false }); return }
1352
+ const rawName = String(u.searchParams.get('name') || '').replace(/[\\/]/g, '').trim().slice(0, 160)
1353
+ const m = rawName.match(MUSIC_EXT_RE)
1354
+ if (!m) { sendJson(res, 400, { ok: false, error: '只支持 mp3 / wav / ogg / m4a / aac / flac' }); return }
1355
+ const bytes = await readRawBody(req, 100 * 1024 * 1024)
1356
+ if (!bytes || !bytes.length) { sendJson(res, 413, { ok: false, error: '文件为空或超过 100MB' }); return }
1357
+ const ext = m[0].toLowerCase()
1358
+ const stamp = Date.now().toString(36)
1359
+ const rand = Math.random().toString(36).slice(2, 8)
1360
+ const file = 'music-' + stamp + '-' + rand + ext
1361
+ try {
1362
+ fs.mkdirSync(MUSIC_DIR, { recursive: true })
1363
+ fs.writeFileSync(path.join(MUSIC_DIR, file), bytes)
1364
+ const list = readMusicIndex()
1365
+ list.unshift({ file, name: rawName.replace(MUSIC_EXT_RE, '').slice(0, 80) || file, size: bytes.length, added: Date.now() })
1366
+ writeMusicIndex(list.slice(0, 60))
1367
+ sendJson(res, 200, { ok: true, file, name: rawName.replace(MUSIC_EXT_RE, ''), size: bytes.length })
1368
+ } catch (err) { sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) }
1369
+ }
1370
+ }))
1371
+ disposers.push(registerRoute({
1372
+ kind: 'exact',
1373
+ path: ROUTE_BASE + '/music/delete',
1374
+ handler: async (req, res) => {
1375
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false }); return }
1376
+ const text = await readBody(req, 8 * 1024)
1377
+ let body = null
1378
+ try { body = JSON.parse(text) } catch (e) { body = null }
1379
+ const file = String((body && body.file) || '')
1380
+ if (!MUSIC_FILE_RE.test(file)) { sendJson(res, 400, { ok: false, error: 'bad file' }); return }
1381
+ try {
1382
+ const full = path.join(MUSIC_DIR, file)
1383
+ if (fs.existsSync(full)) fs.unlinkSync(full)
1384
+ writeMusicIndex(readMusicIndex().filter((x) => x.file !== file))
1385
+ sendJson(res, 200, { ok: true, file })
1386
+ } catch (err) { sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) }
1387
+ }
1388
+ }))
1389
+
1197
1390
  disposers.push(registerRoute({
1198
1391
  kind: 'exact',
1199
1392
  path: ROUTE_BASE + '/templates/delete',
@@ -695,19 +695,40 @@ export default {
695
695
  }
696
696
  }
697
697
 
698
+ /* v2.14.0:壁纸路由也接入信任栅栏(与 host-startup 同款;DSH connection 服务可拒绝未授权请求) */
699
+ function resolveWebServer(c) {
700
+ return (typeof c.get === 'function' ? c.get('webServer') : undefined) || c.webServer
701
+ }
702
+ function rejected(req, res) {
703
+ try {
704
+ const conn = (typeof ctx.get === 'function' ? ctx.get('connection') : undefined) || ctx.connection
705
+ if (!conn || typeof conn.requestRejection !== 'function') return false
706
+ const code = conn.requestRejection(req)
707
+ if (!code) return false
708
+ res.statusCode = code
709
+ res.end()
710
+ return true
711
+ } catch { return false }
712
+ }
713
+ function registerRoute(route) {
714
+ const ws = resolveWebServer(ctx)
715
+ const handler = route.handler
716
+ ws.register(Object.assign({}, route, { handler: (req, res) => (rejected(req, res) ? undefined : handler(req, res)) }))
717
+ }
718
+
698
719
  /* v2.13.17:开机就把丢图目录建好;老用户/新装用户都不会再看到「?」 */
699
720
  try { mkdirSync(wallpaperImageDir(), { recursive: true }) } catch { /* 忽略 */ }
700
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/config', handler: configHandler }))
701
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/image', handler: imageHandler }))
702
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/images', handler: imagesHandler }))
703
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/set', handler: setHandler }))
704
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/open-dir', handler: openDirHandler }))
705
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/videos', handler: videosHandler }))
706
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/video', handler: videoHandler }))
707
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/video-preview', handler: videoPreviewHandler }))
708
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/videos/delete', handler: videosDeleteHandler }))
709
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/videos/roots', handler: videoRootsHandler }))
710
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/status', handler: statusHandler }))
721
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/config', handler: configHandler }))
722
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/image', handler: imageHandler }))
723
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/images', handler: imagesHandler }))
724
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/set', handler: setHandler }))
725
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/open-dir', handler: openDirHandler }))
726
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/videos', handler: videosHandler }))
727
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/video', handler: videoHandler }))
728
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/video-preview', handler: videoPreviewHandler }))
729
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/videos/delete', handler: videosDeleteHandler }))
730
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/videos/roots', handler: videoRootsHandler }))
731
+ ctx.effect(() => registerRoute({ kind: 'exact', path: '/chat-wallpaper/status', handler: statusHandler }))
711
732
 
712
733
  const cfg = readConfig()
713
734
  const img = resolveImage(cfg)
package/lib/splash.js CHANGED
@@ -90,6 +90,23 @@
90
90
  voiceSource: 'asset', // asset=优先用真声素材 / tts=全部 TTS 合成
91
91
  voiceLead: 240,
92
92
  welcomeDelay: 550, // 终幕欢迎语相对 05→06 和声钟的延迟(ms)
93
+ /* v2.14.0 用户自备 BGM(与 lib/host-startup.js DEFAULTS 对齐) */
94
+ bgmEnabled: false,
95
+ bgmFile: '',
96
+ bgmFile2: '',
97
+ bgmVolume: 0.5,
98
+ bgmBpm: 100,
99
+ bgmBeats1: 16,
100
+ bgmBeats2: 32,
101
+ bgm1Start: 0,
102
+ bgm2Start: 0,
103
+ bgm1FadeIn: 1,
104
+ bgm1FadeOut: 1,
105
+ bgm2FadeIn: 1,
106
+ bgm2FadeOut: 2,
107
+ bgmCrossfade: 1200,
108
+ bgm2Loop: false,
109
+ bgmDuck: true,
93
110
  identity: 'JOYCE MOORE', // ← 设置页可改的"身份名称"
94
111
  identityId: '0087',
95
112
  accessLevel: '3',
@@ -273,6 +290,10 @@
273
290
  if (file && /^[a-z0-9][a-z0-9._-]{0,40}\.(mp3|wav|ogg|m4a|aac)$/i.test(file) && TEMPLATE && TEMPLATE.id) {
274
291
  return '/dsh-startup/template-asset/' + encodeURIComponent(TEMPLATE.id) + '/' + file
275
292
  }
293
+ /* v2.14.0:全局 line-N 已并入音色包,别再回落已删除的 lib/assets/line-N.mp3(会 404) */
294
+ var pack = (typeof C !== 'undefined' && C && C.voicePack) || 'f'
295
+ if (pack === 'builtin') pack = 'f'
296
+ if (/^line-[1-9]\d?$/.test(name)) return '/dsh-startup/voice/' + pack + '/' + name + '.mp3'
276
297
  return '/dsh-startup/asset/' + name + '.mp3'
277
298
  }
278
299
  var MAX_STEPS = 24
@@ -1483,6 +1504,17 @@
1483
1504
  preload: function () { loadAsset('sfx-tick'); loadAsset('sfx-ticks-run') },
1484
1505
  ensureAsset: ensureAsset,
1485
1506
  playAssetAndWait: playAssetAndWait,
1507
+ ctx: ac,
1508
+ decodeBuffer: function (ab) {
1509
+ var c = ac()
1510
+ return new Promise(function (resolve, reject) {
1511
+ if (!c) { reject(new Error('no audio context')); return }
1512
+ try {
1513
+ var p = c.decodeAudioData(ab, resolve, reject)
1514
+ if (p && typeof p.then === 'function') p.then(resolve, reject)
1515
+ } catch (e) { reject(e) }
1516
+ })
1517
+ },
1486
1518
  /* 文字渐显 / 阶段推进 / 按钮确认 —— 优先用真素材 */
1487
1519
  tick: function () { if (!playAsset('sfx-tick', 0.45)) tone(1760, 0.035, 'square', 0.022) },
1488
1520
  confirm: function () { if (!playAsset('sfx-tick', 0.85)) { tone(660, 0.09, 'triangle', 0.07); tone(990, 0.17, 'triangle', 0.055, 0.085) } },
@@ -1655,7 +1687,9 @@
1655
1687
  return audio.ensureAsset(useAsset).then(function (ok) {
1656
1688
  if (!ok || finished) return 0
1657
1689
  if (C.voiceCue !== false) audio.cue()
1690
+ if (isGlobalLine) bgm.duck(true)
1658
1691
  return audio.playAssetAndWait(useAsset, 0.85, isGlobalLine ? fx : 'none').then(function (ms) {
1692
+ if (isGlobalLine) bgm.duck(false)
1659
1693
  if (ms > 0) setMeter(true)
1660
1694
  return ms
1661
1695
  })
@@ -1798,11 +1832,243 @@
1798
1832
  }
1799
1833
 
1800
1834
  var runStarted = false
1835
+ /* ── v2.14.0 用户自备 BGM ───────────────────────────────────────
1836
+ 只播放用户本机导入的音乐(宿主 /dsh-startup/music/file 提供)。
1837
+ 三段:第一幕(loop) → 第二幕(可换曲) → 结束淡出;两段之间交叉叠化。
1838
+ 任何异常都静默失败,绝不阻塞开机动画。 */
1839
+ var bgm = (function () {
1840
+ var ACTIVE = false
1841
+ var SECOND = false
1842
+ var cur = null /* { src, gain, name, which } */
1843
+ var baseVol = 0
1844
+ var buffered = {}
1845
+ var ducked = false
1846
+
1847
+ function num(v, d) { var n = Number(v); return isFinite(n) ? n : d }
1848
+ function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
1849
+ function ctxNow() { var c = audio.ctx(); return c ? c.currentTime : 0 }
1850
+
1851
+ function segOf(which) {
1852
+ var bpm = clamp(num(C.bgmBpm, 100), 40, 220)
1853
+ var beats = which === 1 ? clamp(Math.round(num(C.bgmBeats1, 16)), 4, 128) : clamp(Math.round(num(C.bgmBeats2, 32)), 4, 128)
1854
+ return {
1855
+ start: Math.max(0, num(which === 1 ? C.bgm1Start : C.bgm2Start, 0)),
1856
+ dur: beats * 60 / bpm,
1857
+ fadeIn: clamp(num(which === 1 ? C.bgm1FadeIn : C.bgm2FadeIn, 1), 0, 10),
1858
+ fadeOut: clamp(num(which === 1 ? C.bgm1FadeOut : C.bgm2FadeOut, 1), 0, 10),
1859
+ loop: which === 2 ? (C.bgmLoop !== false && C.bgm2Loop === true) : (C.bgmLoop !== false)
1860
+ }
1861
+ }
1862
+
1863
+ function load(name) {
1864
+ if (!name) return Promise.resolve(null)
1865
+ if (buffered[name]) return Promise.resolve(buffered[name])
1866
+ return fetch('/dsh-startup/music/file?name=' + encodeURIComponent(name), { cache: 'no-store' })
1867
+ .then(function (r) { if (!r.ok) throw new Error('bgm http ' + r.status); return r.arrayBuffer() })
1868
+ .then(function (ab) { return audio.decodeBuffer(ab) })
1869
+ .then(function (buf) { if (buf) buffered[name] = buf; return buf })
1870
+ .catch(function () { return null })
1871
+ }
1872
+
1873
+ function kill(fadeSec) {
1874
+ var old = cur
1875
+ cur = null
1876
+ if (!old) return
1877
+ try {
1878
+ var c = audio.ctx()
1879
+ var t = c ? c.currentTime : 0
1880
+ var g = old.gain.gain
1881
+ try {
1882
+ var v = g.value
1883
+ g.cancelScheduledValues(t)
1884
+ g.setValueAtTime(v, t)
1885
+ g.linearRampToValueAtTime(0, t + Math.max(0.05, fadeSec))
1886
+ } catch (e) {}
1887
+ var src = old.src
1888
+ var waitMs = Math.round(Math.max(0.05, fadeSec) * 1000) + 80
1889
+ setTimeout(function () { try { src.stop(); src.disconnect() } catch (e2) {} }, waitMs)
1890
+ setTimeout(function () { try { old.gain.disconnect() } catch (e3) {} }, waitMs + 50)
1891
+ } catch (e) {}
1892
+ }
1893
+
1894
+ function startSeg(which, name) {
1895
+ return load(name).then(function (buf) {
1896
+ if (!ACTIVE || !buf) return
1897
+ var c = audio.ctx()
1898
+ if (!c) return
1899
+ var seg = segOf(which)
1900
+ var loop = seg.loop
1901
+ var dur = loop ? Math.min(buf.duration, Math.max(0.05, seg.dur)) : Math.max(0.05, buf.duration - seg.start)
1902
+ var src = c.createBufferSource()
1903
+ src.buffer = buf
1904
+ src.loop = loop
1905
+ var loopEnd = Math.min(buf.duration, seg.start + dur)
1906
+ if (seg.start >= buf.duration - 0.05) seg.start = 0
1907
+ src.loopStart = Math.max(0, Math.min(seg.start, buf.duration - 0.05))
1908
+ src.loopEnd = Math.max(src.loopStart + 0.05, loopEnd)
1909
+ var gain = c.createGain()
1910
+ gain.gain.value = 0
1911
+ src.connect(gain)
1912
+ gain.connect(c.destination)
1913
+ var t = c.currentTime
1914
+ var level = baseVol
1915
+ if (ducked && C.bgmDuck !== false) level = baseVol * 0.35
1916
+ var fi = Math.max(0.02, seg.fadeIn)
1917
+ try {
1918
+ gain.gain.setValueAtTime(0, t)
1919
+ gain.gain.linearRampToValueAtTime(level, t + fi)
1920
+ } catch (e) {}
1921
+ try { src.start(t, seg.start) } catch (e) { try { src.start() } catch (e2) { return } }
1922
+ cur = { src: src, gain: gain, name: name, which: which }
1923
+ })
1924
+ }
1925
+
1926
+ function begin() {
1927
+ if (C.bgmEnabled !== true) { diagRun('bgm off'); return }
1928
+ if (!C.bgmFile) { diagRun('bgm no file'); return }
1929
+ ACTIVE = true
1930
+ SECOND = false
1931
+ ducked = false
1932
+ baseVol = clamp(num(C.bgmVolume, 0.5), 0, 1)
1933
+ try { audio.ctx() } catch (e) {}
1934
+ diagRun('bgm start file=' + C.bgmFile)
1935
+ startSeg(1, String(C.bgmFile))
1936
+ /* 预载第二段,换曲时无空档 */
1937
+ var name2 = String(C.bgmFile2 || C.bgmFile)
1938
+ if (name2) load(name2)
1939
+ }
1940
+
1941
+ return {
1942
+ start: function () { if (ACTIVE) return; begin() },
1943
+ /* v2.15.10:iframe 模板掉线彩蛋——弹出时截断,重连后重播 */
1944
+ restart: function () { if (ACTIVE) return; begin() },
1945
+ cut: function () { if (!ACTIVE) return; ACTIVE = false; diagRun('bgm cut'); kill(0.12) },
1946
+ toSecond: function () {
1947
+ if (!ACTIVE || SECOND) return
1948
+ SECOND = true
1949
+ var name = String(C.bgmFile2 || C.bgmFile || '')
1950
+ if (!name) return
1951
+ var cross = clamp(num(C.bgmCrossfade, 1200), 0, 5000) / 1000
1952
+ diagRun('bgm to second file=' + name)
1953
+ startSeg(2, name)
1954
+ kill(cross > 0 ? cross : 0.12)
1955
+ },
1956
+ stop: function () {
1957
+ if (!ACTIVE) return
1958
+ ACTIVE = false
1959
+ diagRun('bgm stop')
1960
+ var out = clamp(num(C.bgm2FadeOut, 2), 0, 10)
1961
+ kill(out > 0 ? out : 0.15)
1962
+ },
1963
+ duck: function (on) {
1964
+ if (!ACTIVE || !cur || C.bgmDuck === false) return
1965
+ ducked = !!on
1966
+ var c = audio.ctx()
1967
+ if (!c) return
1968
+ var t = c.currentTime
1969
+ var target = on ? baseVol * 0.35 : baseVol
1970
+ try {
1971
+ cur.gain.gain.cancelScheduledValues(t)
1972
+ cur.gain.gain.setTargetAtTime(target, t, on ? 0.12 : 0.25)
1973
+ } catch (e) {}
1974
+ }
1975
+ }
1976
+ })()
1977
+
1978
+ /* v2.15.0:iframe 启动模板(如 division-bigfish-codex)。
1979
+ 整屏 iframe 自己演、自己判定彩蛋;宿主只负责挂载/收尾。
1980
+ - 完成:iframe postMessage {type:'dsh-startup-done'} 或 window 'dsh-startup-done' 事件
1981
+ - 跳过:postMessage skip / Esc / 设置页「立即预览」的跳过 API
1982
+ - 同源时顺手把作者编辑器侧栏藏掉,只留动画舞台。 */
1983
+ var iframeCleanup = null
1984
+
1985
+ function runIframeTemplate(tpl) {
1986
+ diagRun('iframe template ' + tpl.id)
1987
+ var entry = String(tpl.entry || 'index.html').replace(/^\/+/, '')
1988
+ var wrap = document.createElement('div')
1989
+ wrap.setAttribute('data-dsh-iframe-template', tpl.id)
1990
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:#000;overflow:hidden'
1991
+ var frame = document.createElement('iframe')
1992
+ frame.setAttribute('allow', 'autoplay; fullscreen')
1993
+ frame.setAttribute('allowfullscreen', 'true')
1994
+ frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;background:#000;display:block'
1995
+ frame.style.visibility = 'hidden'
1996
+ frame.src = '/dsh-startup/template-file/' + encodeURIComponent(tpl.id) + '/' + entry + (C.bigfishForceEgg ? '?egg=1' : '')
1997
+ wrap.appendChild(frame)
1998
+ document.body.appendChild(wrap)
1999
+
2000
+ var settle = null
2001
+ var done = new Promise(function (resolve) { settle = resolve })
2002
+ function finishOnce() { if (settle) { var fn2 = settle; settle = null; fn2() } }
2003
+ function onMessage(e) {
2004
+ var d = e && e.data
2005
+ if (d && typeof d === 'object' && (d.type === 'dsh-startup-done' || d.type === 'dsh-startup-skip')) finishOnce()
2006
+ /* v2.15.8:模板内配音开始时闪避 BGM */
2007
+ if (d && typeof d === 'object' && d.type === 'dsh-startup-voice') bgm.duck(d.on === true)
2008
+ if (d && typeof d === 'object' && d.type === 'dsh-startup-bgm') { if (d.on === false) bgm.cut(); else bgm.restart() }
2009
+ }
2010
+ function onKey(e) {
2011
+ if (e.key === 'Escape') { finishOnce(); return }
2012
+ /* v2.15.3:焦点被 DSH 输入框抢走后,父页面把空格转发进 iframe 切 tips */
2013
+ if (e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space') {
2014
+ e.preventDefault()
2015
+ try { frame.contentWindow.postMessage({ type: 'dsh-tip-next' }, '*') } catch (eTip) { /* noop */ }
2016
+ }
2017
+ }
2018
+ window.addEventListener('message', onMessage)
2019
+ window.addEventListener('dsh-startup-done', finishOnce)
2020
+ document.addEventListener('keydown', onKey, true)
2021
+ frame.addEventListener('load', function () {
2022
+ try {
2023
+ var doc = frame.contentDocument
2024
+ if (!doc || !doc.head) return
2025
+ var st = doc.createElement('style')
2026
+ st.textContent = 'html,body{overflow:hidden!important;background:#000!important}main{display:block!important}aside{display:none!important}#stage{width:100vw!important;height:100vh!important;display:grid!important;place-items:center!important}svg#scene{width:100vw!important;height:100vh!important;max-height:none!important}'
2027
+ doc.head.appendChild(st)
2028
+ } catch (err) { /* 跨域就跳过(同源正常情况下不会) */ }
2029
+ /* v2.15.2:把焦点交给 iframe,模板内才能收到空格切 tips;
2030
+ 同时把 Esc 处理器注入 iframe(焦点进去后父页面的 keydown 收不到)。 */
2031
+ try {
2032
+ var ew = frame.contentWindow
2033
+ if (ew) {
2034
+ try { ew.focus() } catch (eFocus) { /* noop */ }
2035
+ doc.addEventListener('keydown', function (eKey) {
2036
+ if (eKey.key === 'Escape') {
2037
+ try { ew.parent.postMessage({ type: 'dsh-startup-skip' }, '*') } catch (e9) { /* noop */ }
2038
+ }
2039
+ })
2040
+ }
2041
+ } catch (err2) { /* noop */ }
2042
+ try { frame.style.visibility = 'visible' } catch (err) { /* noop */ }
2043
+ })
2044
+ setTimeout(function () { try { frame.style.visibility = 'visible' } catch (err) { /* noop */ } }, 2000)
2045
+ var safety = setTimeout(finishOnce, (Number(tpl.duration) > 0 ? Number(tpl.duration) * 1000 : 45000) + 90000)
2046
+ iframeCleanup = function () {
2047
+ clearTimeout(safety)
2048
+ window.removeEventListener('message', onMessage)
2049
+ window.removeEventListener('dsh-startup-done', finishOnce)
2050
+ document.removeEventListener('keydown', onKey, true)
2051
+ try { if (wrap.parentNode) wrap.parentNode.removeChild(wrap) } catch (err) { /* noop */ }
2052
+ finishOnce()
2053
+ }
2054
+ return done.then(function () {
2055
+ /* v2.15.11:模板不再自己做白闪;结尾由插件退场转场接管。
2056
+ 这里不提前卸 iframe / 停 BGM,交给被转场盖住的 finish() 统一清理。 */
2057
+ return transOut(transType('Out'), finish)
2058
+ })
2059
+ }
2060
+
1801
2061
  function run() {
1802
2062
  if (runStarted) { diagRun('run skipped (duplicate entry)'); return Promise.resolve() }
1803
2063
  runStarted = true
2064
+ if (TEMPLATE && TEMPLATE.engine === 'iframe' && TEMPLATE.id) {
2065
+ /* v2.15.8:iframe 模板也支持用户自备 BGM(父页面播,模板自己演) */
2066
+ try { bgm.start() } catch (eBgmIframe) { /* 静默失败,绝不阻塞启动 */ }
2067
+ return runIframeTemplate(TEMPLATE)
2068
+ }
1804
2069
  diagRun('run enter timeline=' + (TIMELINE ? 'v2' : 'v1') + ' tpl=' + ((TEMPLATE && TEMPLATE.id) || '?') + ' tpl2=' + ((TEMPLATE2 && TEMPLATE2.id) || 'none'))
1805
2070
  audio.bedStart() /* 本地补丁:开机即"开通道",底噪一路铺到揭幕 */
2071
+ bgm.start()
1806
2072
  /* v2 时间线模板:不演档案开场,直接跑图层时序 */
1807
2073
  if (TIMELINE) {
1808
2074
  /* v2.12.0:第一幕入场转场(非默认类型才挂覆盖层,且非阻塞 —— 第一幕照跑) */
@@ -2880,6 +3146,8 @@
2880
3146
  if (!TEMPLATE2 || !Array.isArray(TEMPLATE2.layers) || !TEMPLATE2.layers.length) {
2881
3147
  return Promise.resolve()
2882
3148
  }
3149
+ /* v2.15.9:没有第二幕就不切第二段音乐(原来提前调用,导致「不播放第二幕」也会换曲) */
3150
+ bgm.toSecond()
2883
3151
  /* v2.12.0:衔接/退场选择在切换点现场读(设置页「立即预览」会换掉 window.__DSH_STARTUP__) */
2884
3152
  var joinType = transType('Join')
2885
3153
  var joinOverlay = transActive(joinType)
@@ -3578,6 +3846,8 @@
3578
3846
  }
3579
3847
 
3580
3848
  function finish() {
3849
+ bgm.stop()
3850
+ if (iframeCleanup) { try { var cleanIframe = iframeCleanup; iframeCleanup = null; cleanIframe() } catch (eIframe) { /* noop */ } }
3581
3851
  if (finished) return
3582
3852
  diagRun('finish videos=' + videoCount())
3583
3853
  finished = true
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-boot-suite",
3
- "version": "2.13.21",
4
- "description": "DeepSeek Harness 启动动画与聊天背景插件:两幕开机动画、自定义动画导入、三段转场、6 套干音音色(英文 1 + 日文 5)+ 5 种声音效果、聊天背景图片/循环视频、更新前自动回滚;聊天背景新增黑白原版素材模式。 第二幕提供 2K/4K 两档可选(4K 由社区 1catmint1 的 8K 超分版下采样,BSD-3-Clause)。",
3
+ "version": "2.15.16",
4
+ "description": "两幕启动动画(默认档案暖白 + 鲸鱼娘扫描睁眼,2K/4K 可切),支持导入视频/图片、三段转场、11→6 套配音音色与 5 种声音效果、聊天背景/循环视频、内置 iframe 启动模板(全景大肥鱼-含彩蛋版)与更新前自动回滚。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",