dsh-boot-suite 2.13.21 → 2.15.15

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 +113 -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 +255 -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 +89 -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
package/lib/client.js CHANGED
@@ -253,7 +253,10 @@ window.__ModuleLoader__.load({
253
253
  sound: true, soundVolume: 0.5, bedNoise: false,
254
254
  transitionIn: 'dissolve', transitionJoin: 'dissolve', transitionOut: 'dissolve',
255
255
  transitionMs: 600, transitionDir: 'left',
256
- colorMode: 'color'
256
+ colorMode: 'color',
257
+ bgmEnabled: false, bgmFile: '', bgmFile2: '', bgmVolume: 0.5, bgmBpm: 100, bgmBeats1: 16, bgmBeats2: 32,
258
+ bgm1Start: 0, bgm2Start: 0, bgm1FadeIn: 1, bgm1FadeOut: 1, bgm2FadeIn: 1, bgm2FadeOut: 2,
259
+ bgmCrossfade: 1200, bgmLoop: true, bgm2Loop: false, bgmDuck: true
257
260
  }
258
261
  function num(v, d) {
259
262
  var n = Number(v)
@@ -266,7 +269,7 @@ window.__ModuleLoader__.load({
266
269
  —— 桌面端走浏览器半侧自举时没有 1,就靠这一条
267
270
  3) 下面这个常量:两份都没拿到时的兜底,别让面板显示 undefined
268
271
  版本号本身只在 lib/index.js 里读一次,这里不重复维护。 */
269
- var FALLBACK_VERSION = '2.13.21'
272
+ var FALLBACK_VERSION = '2.15.15'
270
273
  /* 手动选包成功后置 true:让还在后台跑的“浏览器下载监听”不要再弹第二次更新器 */
271
274
  var __manualUpdateApplied = false
272
275
  function readInjectedVersion() {
@@ -276,6 +279,126 @@ window.__ModuleLoader__.load({
276
279
  } catch (e) { return '' }
277
280
  }
278
281
 
282
+ /* ── v2.14.0:用户自备 BGM 的波形选段编辑器 ─────────────────────
283
+ 不拖左右边界:选段宽度 = 拍数 × 60 / BPM,整段左右拖动。
284
+ 音频只在本机读取;画布峰值由 Web Audio 解码后计算。 */
285
+ var __waveCtx = null
286
+ function waveCtx() {
287
+ if (__waveCtx) return __waveCtx
288
+ try {
289
+ var AC = window.AudioContext || window.webkitAudioContext
290
+ __waveCtx = AC ? new AC() : null
291
+ } catch (e) { __waveCtx = null }
292
+ return __waveCtx
293
+ }
294
+ function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, Number(v) || 0)) }
295
+
296
+ function WaveformEditor(props) {
297
+ var canvasRef = react.useRef(null)
298
+ var [peaks, setPeaks] = react.useState(null)
299
+ var [dur, setDur] = react.useState(0)
300
+ var [err, setErr] = react.useState('')
301
+ var [drag, setDrag] = react.useState(null)
302
+ var file = props.file || ''
303
+ var bpm = clampNum(props.bpm, 40, 220)
304
+ var beats = clampNum(props.beats, 4, 128)
305
+ var winSec = beats * 60 / bpm
306
+
307
+ react.useEffect(function () {
308
+ var alive = true
309
+ setPeaks(null); setDur(0); setErr('')
310
+ if (!file) return function () { alive = false }
311
+ fetch('/dsh-startup/music/file?name=' + encodeURIComponent(file), { cache: 'no-store' })
312
+ .then(function (r) { if (!r.ok) throw new Error('读取失败'); return r.arrayBuffer() })
313
+ .then(function (ab) {
314
+ var c = waveCtx()
315
+ if (!c) throw new Error('浏览器不支持音频解码')
316
+ return c.decodeAudioData(ab)
317
+ })
318
+ .then(function (buf) {
319
+ if (!alive) return
320
+ var data = buf.getChannelData(0)
321
+ var n = 900
322
+ var step = Math.max(1, Math.floor(data.length / n))
323
+ var out = new Float32Array(n)
324
+ var max = 0.0001
325
+ for (var i = 0; i < n; i++) {
326
+ var from = i * step
327
+ var to = Math.min(data.length, from + step)
328
+ var m = 0
329
+ for (var j = from; j < to; j += 3) { var v = data[j] < 0 ? -data[j] : data[j]; if (v > m) m = v }
330
+ out[i] = m; if (m > max) max = m
331
+ }
332
+ for (var k = 0; k < n; k++) out[k] = out[k] / max
333
+ setPeaks(out); setDur(buf.duration)
334
+ })
335
+ .catch(function (e) { if (alive) setErr(String((e && e.message) || e)) })
336
+ return function () { alive = false }
337
+ }, [file])
338
+
339
+ react.useEffect(function () {
340
+ var cv = canvasRef.current
341
+ if (!cv) return
342
+ var W = 900, H = 110
343
+ var dpr = window.devicePixelRatio || 1
344
+ cv.width = W * dpr; cv.height = H * dpr
345
+ var g = cv.getContext('2d')
346
+ if (!g) return
347
+ g.setTransform(dpr, 0, 0, dpr, 0, 0)
348
+ g.clearRect(0, 0, W, H)
349
+ g.fillStyle = 'rgba(255,255,255,.05)'; g.fillRect(0, 0, W, H)
350
+ if (!peaks || !dur) return
351
+ var start = clampNum(props.start, 0, Math.max(0, dur - winSec))
352
+ var x1 = start / dur * W
353
+ var x2 = Math.min(W, (start + winSec) / dur * W)
354
+ g.fillStyle = 'rgba(255,117,0,.16)'; g.fillRect(x1, 0, Math.max(2, x2 - x1), H)
355
+ g.strokeStyle = '#ffb27a'; g.lineWidth = 2
356
+ g.strokeRect(x1 + 1, 1, Math.max(2, x2 - x1) - 2, H - 2)
357
+ g.strokeStyle = 'rgba(255,255,255,.55)'; g.lineWidth = 1
358
+ g.beginPath()
359
+ for (var i = 0; i < peaks.length; i++) {
360
+ var x = i / (peaks.length - 1) * W
361
+ var h = Math.max(1, peaks[i] * (H - 16))
362
+ g.moveTo(x, H / 2 - h / 2); g.lineTo(x, H / 2 + h / 2)
363
+ }
364
+ g.stroke()
365
+ g.fillStyle = 'rgba(0,0,0,.45)'; g.fillRect(0, 0, Math.max(0, Math.min(x1, W)), H)
366
+ g.fillRect(Math.min(W, x2), 0, Math.max(0, W - x2), H)
367
+ }, [peaks, dur, props.start, winSec])
368
+
369
+ function onDown(e) {
370
+ if (!peaks || !dur) return
371
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (e2) {}
372
+ setDrag({ x: e.clientX, start: clampNum(props.start, 0, Math.max(0, dur - winSec)) })
373
+ }
374
+ function onMove(e) {
375
+ if (!drag || !dur) return
376
+ var rect = e.currentTarget.getBoundingClientRect()
377
+ if (!rect.width) return
378
+ var dxSec = (e.clientX - drag.x) / rect.width * dur
379
+ var next = clampNum(drag.start + dxSec, 0, Math.max(0, dur - winSec))
380
+ next = Math.round(next * 100) / 100
381
+ if (next !== props.start) props.onChange(next)
382
+ }
383
+ function onUp() { setDrag(null) }
384
+
385
+ var start = clampNum(props.start, 0, Math.max(0, dur - winSec))
386
+ var fmt = function (t) { return (Math.round(t * 10) / 10).toFixed(1) + 's' }
387
+ return h('div', { className: '__ss_wave' },
388
+ h('canvas', {
389
+ ref: canvasRef,
390
+ style: { width: '100%', height: '110px', display: 'block', borderRadius: '8px', cursor: 'grab', touchAction: 'none' },
391
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp
392
+ }),
393
+ h('div', { className: '__ss_hint', style: { marginTop: '6px' } },
394
+ err ? ('波形读取失败:' + err)
395
+ : (!peaks ? '正在读取波形…'
396
+ : (props.label || '') + ' 选段 ' + fmt(start) + ' → ' + fmt(Math.min(dur, start + winSec)) +
397
+ '(' + Math.round(winSec * 10) / 10 + 's · ' + beats + ' 拍 @ ' + bpm + ' BPM);按住波形左右拖动选段')
398
+ )
399
+ )
400
+ }
401
+
279
402
  function Panel() {
280
403
  var [draft, setDraft] = react.useState(null)
281
404
  var [defaults, setDefaults] = react.useState(null)
@@ -287,6 +410,7 @@ window.__ModuleLoader__.load({
287
410
  var [templates, setTemplates] = react.useState([])
288
411
  var [hiddenTemplates, setHiddenTemplates] = react.useState([])
289
412
  var [voicePacks, setVoicePacks] = react.useState([])
413
+ var [musicFiles, setMusicFiles] = react.useState([])
290
414
  var [update, setUpdate] = react.useState(null)
291
415
  var [checking, setChecking] = react.useState(false)
292
416
  var [updating, setUpdating] = react.useState(false)
@@ -332,6 +456,67 @@ window.__ModuleLoader__.load({
332
456
  }
333
457
  react.useEffect(() => { refreshVoices() }, [])
334
458
 
459
+ /* 背景音乐列表与导入(文件只写本机 ~/.dsh/startup-music) */
460
+ function refreshMusic() {
461
+ return fetch(STARTUP_ROUTE + '/music/list', { cache: 'no-store', headers: { Accept: 'application/json' } })
462
+ .then(function (r) { return r.json() })
463
+ .then(function (j) { if (j && j.ok === true && Array.isArray(j.files)) setMusicFiles(j.files) })
464
+ .catch(function () { /* 拉不到就显示空 */ })
465
+ }
466
+ react.useEffect(function () { refreshMusic() }, [])
467
+
468
+ function importMusic(which) {
469
+ var inp = document.createElement('input')
470
+ inp.type = 'file'
471
+ inp.accept = '.mp3,.wav,.ogg,.m4a,.aac,.flac,audio/*'
472
+ inp.onchange = function () {
473
+ var f0 = inp.files && inp.files[0]
474
+ if (!f0) return
475
+ if (f0.size > 100 * 1024 * 1024) { setError('音乐文件不能超过 100MB'); return }
476
+ setBusy(true); setError(null); setNotice(null)
477
+ f0.arrayBuffer().then(function (ab) {
478
+ return fetch(STARTUP_ROUTE + '/music/upload?name=' + encodeURIComponent(f0.name), {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/octet-stream' },
481
+ body: ab
482
+ })
483
+ }).then(function (r) { return r.json() }).then(function (j) {
484
+ if (!j || j.ok !== true) throw new Error((j && j.error) || '导入失败')
485
+ return refreshMusic().then(function () {
486
+ set(which === 2 ? 'bgmFile2' : 'bgmFile', j.file)
487
+ setNotice('音乐已导入到本机(不会上传)。选好选段后点「保存设置」。')
488
+ })
489
+ }).catch(function (e) {
490
+ setError('导入音乐失败:' + String((e && e.message) || e))
491
+ }).then(function () { setBusy(false) })
492
+ }
493
+ inp.click()
494
+ }
495
+
496
+ function deleteMusic(fileName) {
497
+ if (!fileName) return
498
+ if (!window.confirm('删除这首本地音乐?\n只删 ~/.dsh/startup-music 里的副本,不会动你原来的文件。')) return
499
+ setBusy(true)
500
+ fetch(STARTUP_ROUTE + '/music/delete', {
501
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
502
+ body: JSON.stringify({ file: fileName })
503
+ }).then(function (r) { return r.json() }).then(function (j) {
504
+ if (!j || j.ok !== true) throw new Error((j && j.error) || '删除失败')
505
+ return refreshMusic()
506
+ }).catch(function (e) { setError('删除失败:' + String((e && e.message) || e)) })
507
+ .then(function () { setBusy(false) })
508
+ }
509
+
510
+ function musicOptions() {
511
+ var opts = [['', '同第一段']]
512
+ for (var i = 0; i < musicFiles.length; i++) {
513
+ var m = musicFiles[i]
514
+ if (!m || !m.file) continue
515
+ opts.push([m.file, String(m.name || m.file)])
516
+ }
517
+ return opts
518
+ }
519
+
335
520
  function voiceOptions() {
336
521
  var opts = []
337
522
  for (var i = 0; i < voicePacks.length; i++) {
@@ -679,6 +864,9 @@ window.__ModuleLoader__.load({
679
864
  .catch(() => { /* 拿不到最新配置就用旧的,至少还能重播 */ })
680
865
  .then(function () {
681
866
  setBusy(false)
867
+ /* v2.15.2:点「立即预览」后按钮仍保持焦点,播放时空格会再次触发按钮 click(看起来像重播)。
868
+ 重播前先失焦,空格才能交给模板自己的交互(切 tips / 彩蛋按钮)。 */
869
+ try { var ae = document.activeElement; if (ae && typeof ae.blur === 'function') ae.blur() } catch (eBlur) { /* noop */ }
682
870
  var api = window.__DSH_STARTUP_API__
683
871
  if (api && typeof api.replay === 'function') { api.replay(); return }
684
872
  window.location.reload()
@@ -712,17 +900,55 @@ window.__ModuleLoader__.load({
712
900
  }
713
901
  return false
714
902
  }
715
- /* 模板下拉:'' = 引擎内置那套;其余来自宿主扫描到的模板 */
903
+ /* v2.14.0:模板分类。正式下拉只放「正式模板」;其余归到「测试类模板」。
904
+ 默认:内置模板里只有 archive-warm(第一幕)与 whale-girl-scan / -4k(第二幕)算正式;
905
+ 用户本地导入的算正式;被手动「归类」或「移出」的以配置为准。 */
906
+ var PROD_TEMPLATES = { template: { 'archive-warm': 1 }, act2: { 'whale-girl-scan': 1, 'whale-girl-scan-4k': 1 } }
907
+ function findTemplate(id) {
908
+ for (var fi = 0; fi < (templates || []).length; fi++) if (templates[fi] && templates[fi].id === id) return templates[fi]
909
+ return null
910
+ }
911
+ function templateLabel(t) { return t.name + (t.source === 'user' ? ' · 本地' : '') }
912
+ function isTestTemplate(id, act) {
913
+ if (!id) return false
914
+ var tt = Array.isArray(draft.testTemplates) ? draft.testTemplates : []
915
+ var pp = Array.isArray(draft.promotedTemplates) ? draft.promotedTemplates : []
916
+ if (tt.indexOf(id) >= 0) return true
917
+ if (pp.indexOf(id) >= 0) return false
918
+ var t = findTemplate(id)
919
+ if (t && t.source === 'user') return false
920
+ var prod = PROD_TEMPLATES[act] || {}
921
+ return !prod[id]
922
+ }
923
+ function testOptionsFor(act) {
924
+ return (templates || []).filter(function (t) { return isTestTemplate(t.id, act) }).map(function (t) { return [t.id, templateLabel(t)] })
925
+ }
926
+ function toggleTest(id, act) {
927
+ if (!id) return
928
+ var now = isTestTemplate(id, act)
929
+ var tt = (Array.isArray(draft.testTemplates) ? draft.testTemplates : []).slice()
930
+ var pp = (Array.isArray(draft.promotedTemplates) ? draft.promotedTemplates : []).slice()
931
+ if (now) { if (pp.indexOf(id) < 0) pp.push(id); tt = tt.filter(function (x) { return x !== id }) }
932
+ else { if (tt.indexOf(id) < 0) tt.push(id); pp = pp.filter(function (x) { return x !== id }) }
933
+ set('testTemplates', tt)
934
+ set('promotedTemplates', pp)
935
+ }
716
936
  var templateOptions = [['', '内置(档案暖白)']].concat(
717
- (templates || []).map(function (t) {
718
- return [t.id, t.name + (t.source === 'user' ? ' · 本地' : '')]
719
- })
937
+ (templates || []).filter(function (t) { return !isTestTemplate(t.id, 'template') }).map(function (t) { return [t.id, templateLabel(t)] })
720
938
  )
939
+ if (draft.template && isTestTemplate(draft.template, 'template')) {
940
+ var curT = findTemplate(draft.template)
941
+ templateOptions = [[draft.template, '【测试类】' + (curT ? templateLabel(curT) : draft.template)]].concat(templateOptions)
942
+ }
721
943
  var act2Options = [['', '不播放(第一幕结束直接进聊天)']].concat(
722
- (templates || []).map(function (t) {
723
- return [t.id, t.name + (t.source === 'user' ? ' · 本地' : '')]
724
- })
944
+ (templates || []).filter(function (t) { return !isTestTemplate(t.id, 'act2') }).map(function (t) { return [t.id, templateLabel(t)] })
725
945
  )
946
+ if (draft.act2 && isTestTemplate(draft.act2, 'act2')) {
947
+ var curT2 = findTemplate(draft.act2)
948
+ act2Options = [[draft.act2, '【测试类】' + (curT2 ? templateLabel(curT2) : draft.act2)]].concat(act2Options)
949
+ }
950
+ var testOptions1 = testOptionsFor('template')
951
+ var testOptions2 = testOptionsFor('act2')
726
952
  var transitionOptions = [
727
953
  ['dissolve', '交叉叠化(默认)'],
728
954
  ['flash', '闪白'],
@@ -740,6 +966,13 @@ window.__ModuleLoader__.load({
740
966
  ['down', '从上往下']
741
967
  ]
742
968
 
969
+ /* 档案暖白专属设置组:只有第一幕选「档案暖白」(或内置空值)时才显示;
970
+ 其他模板(含新模板/用户导入)应由模板自己提供参数,而不是这套 */
971
+ var isArchiveWarm = !draft.template || draft.template === 'archive-warm'
972
+ var isBigfish = draft.template === 'division-bigfish-codex'
973
+ var hasSecondAct = !!(draft.act2 && draft.act2 !== '')
974
+ var musicLoop = draft.bgmLoop !== false
975
+
743
976
  return h('div', { className: '__ss_root' },
744
977
  versionLine,
745
978
 
@@ -783,6 +1016,119 @@ window.__ModuleLoader__.load({
783
1016
  )
784
1017
  ),
785
1018
 
1019
+ /* 用户自备 BGM(v2.14.0;v2.15.9 不循环模式 + 无第二幕不出第二段) */
1020
+ h('div', { className: '__ss_group', style: (isArchiveWarm || isBigfish) ? null : { display: 'none' } },
1021
+ h('div', { className: '__ss_groupTitle' }, '背景音乐(用户自备)'),
1022
+ h('div', { className: '__ss_hint' },
1023
+ '导入你自己的音乐给开机动画配乐:只存本机、不上传、不内置。请确认你拥有该音乐的使用权。'
1024
+ ),
1025
+ isBigfish ? h('div', { className: '__ss_hint' }, '当前选的是「全景大肥鱼-含彩蛋版」:音乐从第一幕开始,到动画结束淡出;这个模板没有第二幕,第二段音乐不参与,音量、循环、淡入淡出、闪避有效。') : null,
1026
+ h(Check, {
1027
+ checked: draft.bgmEnabled === true,
1028
+ onChange: (v) => set('bgmEnabled', v),
1029
+ label: '启用背景音乐'
1030
+ }),
1031
+ h(Check, {
1032
+ checked: musicLoop,
1033
+ onChange: (v) => set('bgmLoop', v),
1034
+ label: '循环播放'
1035
+ }),
1036
+ !musicLoop ? h('div', { className: '__ss_hint' }, '已关闭循环:整首音乐只播一遍,拍速/拍数/选段自动隐藏;闪避仍然有效。') : null,
1037
+ h('div', { className: '__ss_row' },
1038
+ h('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { importMusic(1) } }, '🎵 导入音乐'),
1039
+ h('span', { className: '__ss_hint' }, draft.bgmFile || '还没有导入音乐'),
1040
+ draft.bgmFile ? h('button', {
1041
+ className: '__ss_btn', type: 'button', disabled: busy,
1042
+ onClick: function () { deleteMusic(draft.bgmFile) }
1043
+ }, '删除') : null
1044
+ ),
1045
+ hasSecondAct ? h(Field, { label: '第二段换曲', hint: '默认与第一段同一首;第二段从「启动动画第二幕」开始,覆盖终幕欢迎语。' },
1046
+ h('div', { className: '__ss_row' },
1047
+ h(Select, { value: draft.bgmFile2 || '', options: musicOptions(), onChange: (v) => set('bgmFile2', v) }),
1048
+ h('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { importMusic(2) } }, '导入第二首')
1049
+ )
1050
+ ) : null,
1051
+ musicLoop ? h('div', { className: '__ss_two' },
1052
+ h(Field, { label: '拍速(BPM)', hint: '只用来计算选段长度:时长 = 拍数 × 60 / BPM' },
1053
+ h(Select, {
1054
+ value: String(draft.bgmBpm || 100),
1055
+ options: [60, 70, 80, 90, 100, 110, 120, 130, 140, 160, 180].map(function (b) { return [String(b), b + ' BPM'] }),
1056
+ onChange: (v) => set('bgmBpm', Number(v))
1057
+ })),
1058
+ h(Field, { label: '第一幕拍数', hint: '至少 2 个八拍;默认 16 拍。' },
1059
+ h(Select, {
1060
+ value: String(draft.bgmBeats1 || 16),
1061
+ options: [8, 16, 24, 32, 48, 64, 96, 128].map(function (b) { return [String(b), b + ' 拍'] }),
1062
+ onChange: (v) => set('bgmBeats1', Number(v))
1063
+ }))
1064
+ ) : null,
1065
+ (musicLoop && draft.bgmFile) ? h(Field, { label: '第一段选段' },
1066
+ h(WaveformEditor, {
1067
+ file: draft.bgmFile, label: '第一幕',
1068
+ bpm: draft.bgmBpm, beats: draft.bgmBeats1,
1069
+ start: draft.bgm1Start, onChange: (v) => set('bgm1Start', v)
1070
+ })) : null,
1071
+ (hasSecondAct && musicLoop) ? h(Field, { label: '第二幕拍数', hint: '默认 32 拍;第二段结束直接进聊天。' },
1072
+ h(Select, {
1073
+ value: String(draft.bgmBeats2 || 32),
1074
+ options: [8, 16, 24, 32, 48, 64, 96, 128].map(function (b) { return [String(b), b + ' 拍'] }),
1075
+ onChange: (v) => set('bgmBeats2', Number(v))
1076
+ })) : null,
1077
+ (hasSecondAct && musicLoop && (draft.bgmFile2 || draft.bgmFile)) ? h(Field, { label: '第二段选段' },
1078
+ h(WaveformEditor, {
1079
+ file: draft.bgmFile2 || draft.bgmFile, label: '第二幕',
1080
+ bpm: draft.bgmBpm, beats: draft.bgmBeats2,
1081
+ start: draft.bgm2Start, onChange: (v) => set('bgm2Start', v)
1082
+ })) : null,
1083
+ h('div', { className: '__ss_two' },
1084
+ h(Field, { label: '第一段淡入 / 淡出', hint: '0–10 秒,可小数。' },
1085
+ h('div', { className: '__ss_row' },
1086
+ h(Range, {
1087
+ min: 0, max: 10, step: 0.1, value: draft.bgm1FadeIn == null ? 1 : draft.bgm1FadeIn,
1088
+ display: (draft.bgm1FadeIn == null ? 1 : draft.bgm1FadeIn).toFixed(1) + 's',
1089
+ onChange: (v) => set('bgm1FadeIn', v)
1090
+ }),
1091
+ h(Range, {
1092
+ min: 0, max: 10, step: 0.1, value: draft.bgm1FadeOut == null ? 1 : draft.bgm1FadeOut,
1093
+ display: (draft.bgm1FadeOut == null ? 1 : draft.bgm1FadeOut).toFixed(1) + 's',
1094
+ onChange: (v) => set('bgm1FadeOut', v)
1095
+ })
1096
+ )),
1097
+ hasSecondAct ? h(Field, { label: '第二段淡入 / 结束淡出', hint: '结束淡出 = 最后一段音乐收到 0 的时长。' },
1098
+ h('div', { className: '__ss_row' },
1099
+ h(Range, {
1100
+ min: 0, max: 10, step: 0.1, value: draft.bgm2FadeIn == null ? 1 : draft.bgm2FadeIn,
1101
+ display: (draft.bgm2FadeIn == null ? 1 : draft.bgm2FadeIn).toFixed(1) + 's',
1102
+ onChange: (v) => set('bgm2FadeIn', v)
1103
+ }),
1104
+ h(Range, {
1105
+ min: 0, max: 10, step: 0.1, value: draft.bgm2FadeOut == null ? 2 : draft.bgm2FadeOut,
1106
+ display: (draft.bgm2FadeOut == null ? 2 : draft.bgm2FadeOut).toFixed(1) + 's',
1107
+ onChange: (v) => set('bgm2FadeOut', v)
1108
+ })
1109
+ )) : null
1110
+ ),
1111
+ h('div', { className: '__ss_two' },
1112
+ h(Field, { label: '音乐音量', hint: '相对配音 0–100%,默认 50%。' },
1113
+ h(Range, {
1114
+ min: 0, max: 1, step: 0.05, value: draft.bgmVolume == null ? 0.5 : draft.bgmVolume,
1115
+ display: Math.round((draft.bgmVolume == null ? 0.5 : draft.bgmVolume) * 100) + '%',
1116
+ onChange: (v) => set('bgmVolume', v)
1117
+ })),
1118
+ hasSecondAct ? h(Field, { label: '两段交叉', hint: '换曲时旧曲淡出与新曲淡入重叠的时长。' },
1119
+ h(Range, {
1120
+ min: 0, max: 5000, step: 100, value: draft.bgmCrossfade == null ? 1200 : draft.bgmCrossfade,
1121
+ display: (draft.bgmCrossfade == null ? 1200 : draft.bgmCrossfade) + ' ms',
1122
+ onChange: (v) => set('bgmCrossfade', v)
1123
+ })) : null
1124
+ ),
1125
+ h('div', { className: '__ss_row' },
1126
+ (hasSecondAct && musicLoop) ? h(Check, { checked: draft.bgm2Loop === true, onChange: (v) => set('bgm2Loop', v), label: '第二段循环' }) : null,
1127
+ h(Check, { checked: draft.bgmDuck !== false, onChange: (v) => set('bgmDuck', v), label: '配音时自动闪避音乐' })
1128
+ ),
1129
+ h('div', { className: '__ss_hint' }, '提示:切歌/删歌后请点「保存设置」;冷启动生效,也可用「立即预览」试听。')
1130
+ ),
1131
+
786
1132
  /* 模板:第一幕 / 第二幕 分开选 */
787
1133
  h('div', { className: '__ss_group' },
788
1134
  h('div', { className: '__ss_groupTitle' }, '模板'),
@@ -800,8 +1146,16 @@ window.__ModuleLoader__.load({
800
1146
  className: '__ss_btn',
801
1147
  type: 'button',
802
1148
  disabled: busy || !(draft.template || ''),
803
- title: !draft.template ? '没有选择模板' : (isUserTemplate(draft.template) ? '删除这个本地动画' : '隐藏内置模板(可恢复)'),
1149
+ title: isTestTemplate(draft.template, 'template') ? '把这个动画放回正式模板列表' : '把这个动画归到测试类模板',
804
1150
  style: { marginLeft: 'auto', minWidth: '112px' },
1151
+ onClick: function () { toggleTest(draft.template, 'template') }
1152
+ }, isTestTemplate(draft.template, 'template') ? '移出测试类' : '归类该动画'),
1153
+ h('button', {
1154
+ className: '__ss_btn',
1155
+ type: 'button',
1156
+ disabled: busy || !(draft.template || ''),
1157
+ title: !draft.template ? '没有选择模板' : (isUserTemplate(draft.template) ? '删除这个本地动画' : '隐藏内置模板(可恢复)'),
1158
+ style: { marginLeft: '8px', minWidth: '112px' },
805
1159
  onClick: function () { deleteTemplate(draft.template) }
806
1160
  }, '删除该动画')
807
1161
  )
@@ -820,8 +1174,16 @@ window.__ModuleLoader__.load({
820
1174
  className: '__ss_btn',
821
1175
  type: 'button',
822
1176
  disabled: busy || !(draft.act2 || ''),
823
- title: !draft.act2 ? '没有选择模板' : (isUserTemplate(draft.act2) ? '删除这个本地动画' : '隐藏内置模板(可恢复)'),
1177
+ title: isTestTemplate(draft.act2, 'act2') ? '把这个动画放回正式模板列表' : '把这个动画归到测试类模板',
824
1178
  style: { marginLeft: 'auto', minWidth: '112px' },
1179
+ onClick: function () { toggleTest(draft.act2, 'act2') }
1180
+ }, isTestTemplate(draft.act2, 'act2') ? '移出测试类' : '归类该动画'),
1181
+ h('button', {
1182
+ className: '__ss_btn',
1183
+ type: 'button',
1184
+ disabled: busy || !(draft.act2 || ''),
1185
+ title: !draft.act2 ? '没有选择模板' : (isUserTemplate(draft.act2) ? '删除这个本地动画' : '隐藏内置模板(可恢复)'),
1186
+ style: { marginLeft: '8px', minWidth: '112px' },
825
1187
  onClick: function () { deleteTemplate(draft.act2) }
826
1188
  }, '删除该动画')
827
1189
  )
@@ -835,12 +1197,30 @@ window.__ModuleLoader__.load({
835
1197
  h('span', { className: '__ss_hint' }, '在网页里改文案/配色/分幕,保存后回到这里选它')
836
1198
  ),
837
1199
  h('div', { className: '__ss_hint' }, '每一幕右侧的「删除该动画」:本地动画会删除文件;内置模板只是隐藏,可随时恢复。'),
838
- hiddenTemplates.length
839
- ? h('div', { className: '__ss_row' },
840
- h('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { restoreHiddenTemplates() } }, '恢复已隐藏的内置模板(' + hiddenTemplates.length + ')'),
841
- h('span', { className: '__ss_hint' }, '恢复后会重新出现在上面的下拉里')
842
- )
843
- : null
1200
+ h('div', { className: '__ss_group', style: { marginTop: '18px' } },
1201
+ h('div', { className: '__ss_groupTitle' }, '测试类模板'),
1202
+ h('div', { className: '__ss_hint' }, '正式下拉只保留常用模板;其余模板归在这里。每幕右侧的「归类该动画 / 移出测试类」可以随时调整分类。'),
1203
+ h(Field, { label: '第一幕(测试类)', hint: '选中即切换第一幕;不会影响正式模板列表。' },
1204
+ h(Select, {
1205
+ value: draft.template && isTestTemplate(draft.template, 'template') ? draft.template : '',
1206
+ options: [['', testOptions1.length ? '选择测试类模板…' : '(暂无测试类模板)']].concat(testOptions1),
1207
+ onChange: function (v) { if (v) set('template', v) }
1208
+ })
1209
+ ),
1210
+ h(Field, { label: '第二幕(测试类)', hint: '选中即切换第二幕;选「不播放」请用上面的正式下拉。' },
1211
+ h(Select, {
1212
+ value: draft.act2 && isTestTemplate(draft.act2, 'act2') ? draft.act2 : '',
1213
+ options: [['', testOptions2.length ? '选择测试类模板…' : '(暂无测试类模板)']].concat(testOptions2),
1214
+ onChange: function (v) { if (v) set('act2', v) }
1215
+ })
1216
+ ),
1217
+ hiddenTemplates.length
1218
+ ? h('div', { className: '__ss_row' },
1219
+ h('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { restoreHiddenTemplates() } }, '恢复已隐藏的内置模板(' + hiddenTemplates.length + ')'),
1220
+ h('span', { className: '__ss_hint' }, '恢复后会重新出现在上面的下拉里')
1221
+ )
1222
+ : null
1223
+ )
844
1224
  ),
845
1225
 
846
1226
  /* 三段转场(v2.12.0):第一幕入场 / 两幕衔接 / 第二幕退场 */
@@ -868,6 +1248,48 @@ window.__ModuleLoader__.load({
868
1248
  )
869
1249
  ),
870
1250
 
1251
+ /* v2.15.0:iframe 启动模板「全境大肥鱼」的专属文案(只在选中它时出现) */
1252
+ draft.template === 'division-bigfish-codex'
1253
+ ? h('div', { className: '__ss_group' },
1254
+ h('div', { className: '__ss_groupTitle' }, '全景大肥鱼-含彩蛋版 · 专属文案'),
1255
+ h('div', { className: '__ss_hint' }, '只对第一幕「全景大肥鱼-含彩蛋版」生效;留空则用模板自带默认值。'),
1256
+ h(Field, { label: '连接用户名', hint: '动画提示条右上角的连接用户名。' },
1257
+ h(TextInput, { value: draft.bigfishUsername || '', maxLength: 40, onChange: (v) => set('bigfishUsername', v) })
1258
+ ),
1259
+ h(Field, { label: '提示条标题' },
1260
+ h(TextInput, { value: draft.bigfishTitle || '', maxLength: 60, onChange: (v) => set('bigfishTitle', v) })
1261
+ ),
1262
+ h(Field, { label: '提示条正文' },
1263
+ h(TextInput, { value: draft.bigfishDesc || '', maxLength: 120, onChange: (v) => set('bigfishDesc', v) })
1264
+ ),
1265
+ h(Field, { label: '强制掉线彩蛋', hint: '只影响这一次启动,方便测试;测试完请关掉。' },
1266
+ h(Check, { checked: draft.bigfishForceEgg === true, onChange: (v) => set('bigfishForceEgg', v) })
1267
+ ),
1268
+ h(Field, { label: '配音效果', hint: '与全局「声音效果」同一套参数,只作用于这个模板自带的五句配音。' },
1269
+ h(Select, {
1270
+ value: draft.bigfishVoiceFx || 'none',
1271
+ options: [
1272
+ ['none', '无(原声)'],
1273
+ ['ai-mech', 'AI 机械音'],
1274
+ ['robot', '机器人'],
1275
+ ['radio', '无线电'],
1276
+ ['oldtv', '老电视'],
1277
+ ['dark', '低沉']
1278
+ ],
1279
+ onChange: (v) => set('bigfishVoiceFx', v)
1280
+ })
1281
+ ),
1282
+ h(Field, { label: '配音音量', hint: '只作用于这个模板自带的五句配音;默认 50%,可随时调。' },
1283
+ h(Range, {
1284
+ min: 0, max: 100, step: 5,
1285
+ value: draft.bigfishVoiceVolume == null ? 50 : draft.bigfishVoiceVolume,
1286
+ display: (draft.bigfishVoiceVolume == null ? 50 : draft.bigfishVoiceVolume) + '%',
1287
+ onChange: (v) => set('bigfishVoiceVolume', Number(v))
1288
+ })
1289
+ )
1290
+ )
1291
+ : null,
1292
+
871
1293
  /* 聊天背景引导(另一个窗口的设置) */
872
1294
  h('div', { className: '__ss_group' },
873
1295
  h('div', { className: '__ss_groupTitle' }, '聊天背景'),
@@ -880,7 +1302,7 @@ window.__ModuleLoader__.load({
880
1302
  ),
881
1303
 
882
1304
  /* 身份 */
883
- h('div', { className: '__ss_group' },
1305
+ h('div', { className: '__ss_group', style: isArchiveWarm ? null : { display: 'none' } },
884
1306
  h('div', { className: '__ss_groupTitle' }, '身份资料'),
885
1307
  h(Field, {
886
1308
  label: '身份名称',
@@ -895,7 +1317,7 @@ window.__ModuleLoader__.load({
895
1317
  ),
896
1318
 
897
1319
  /* 交互 */
898
- h('div', { className: '__ss_group' },
1320
+ h('div', { className: '__ss_group', style: isArchiveWarm ? null : { display: 'none' } },
899
1321
  h('div', { className: '__ss_groupTitle' }, '交互'),
900
1322
  h(Check, {
901
1323
  label: '需要人工确认',
@@ -924,7 +1346,7 @@ window.__ModuleLoader__.load({
924
1346
  ),
925
1347
 
926
1348
  /* 语音与音效 */
927
- h('div', { className: '__ss_group' },
1349
+ h('div', { className: '__ss_group', style: isArchiveWarm ? null : { display: 'none' } },
928
1350
  h('div', { className: '__ss_groupTitle' }, '语音与音效'),
929
1351
  h(Check, {
930
1352
  label: '启用配音播报',
@@ -996,7 +1418,7 @@ window.__ModuleLoader__.load({
996
1418
  ),
997
1419
 
998
1420
  /* 视觉 */
999
- h('div', { className: '__ss_group' },
1421
+ h('div', { className: '__ss_group', style: isArchiveWarm ? null : { display: 'none' } },
1000
1422
  h('div', { className: '__ss_groupTitle' }, '视觉'),
1001
1423
  h(Field, { label: '主题' },
1002
1424
  h(Select, {
@@ -1022,7 +1444,7 @@ window.__ModuleLoader__.load({
1022
1444
  ),
1023
1445
 
1024
1446
  /* 文案 */
1025
- h('div', { className: '__ss_group' },
1447
+ h('div', { className: '__ss_group', style: isArchiveWarm ? null : { display: 'none' } },
1026
1448
  h('div', { className: '__ss_groupTitle' }, '文案'),
1027
1449
  h('div', { className: '__ss_two' },
1028
1450
  h(Field, { label: '左上 · 主标题' }, h(TextInput, { value: draft.brand1, onChange: (v) => set('brand1', v) })),
@@ -1600,7 +2022,7 @@ window.__ModuleLoader__.load({
1600
2022
  /* 每次改客户端就把这个号 +1。
1601
2023
  有了它在控制台一眼就能判断"浏览器里跑的是不是最新那份" ——
1602
2024
  排查问题时,分不清是代码问题还是旧包,会白猜一轮。 */
1603
- var BUILD = 'cw-44'
2025
+ var BUILD = 'cw-63'
1604
2026
 
1605
2027
  function log() {
1606
2028
  try {