dsh-mpkg-wallpaper 3.15.2 → 3.15.4

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 (3) hide show
  1. package/lib/client.js +226 -34
  2. package/lib/index.js +101 -26
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -338,7 +338,13 @@ function mpwTrace(ev, data) {
338
338
  // ①(新 2026-09-11) DSH 0.1.5 自带右侧边栏/dock 浮层虚化(兼容桥纳入旧规则作用域)
339
339
  const DEFAULT_RIGHT_SIDEBAR_BLUR = true; // 右侧边栏/dock 虚化开关
340
340
  const DEFAULT_RIGHT_SIDEBAR_AMOUNT = 14; // 虚化程度 px
341
- const DEFAULT_RIGHT_SIDEBAR_ALPHA = 45; // 表面白雾透明度 %(0=全透明 100=不透明)
341
+ /* ①(2026-10-03 语义翻转后复核) 值仍是 45,但语义已经是**透明度**:
342
+ 45 ⇒ 生效不透明度 55%。为什么不跟着映射改成 55:它是"从没设过右栏那条"的兜底,
343
+ 而右栏默认在**跟随**档(`rightT = 侧栏透明度`),这个常量几乎只在"用户关掉跟随
344
+ 且从没动过右栏滑条"时生效;改它会平移 `--mpw-rs-alpha` 的取值 ⇒
345
+ `token-namespace-test` 的"同设置逐键等价"判据(before↔after)会出现 259 组漂移。
346
+ 存量用户**显式设过**的值由 `mpwAlphaFlipValue()` 按意图搬(45→55)✓,与本常量无关。 */
347
+ const DEFAULT_RIGHT_SIDEBAR_ALPHA = 45; // 表面透明度 %(0=全不透明 100=全透明)
342
348
  const DEFAULT_SIDEBAR_BLUR_AMOUNT = 14; // 侧边栏磨砂程度
343
349
  const DEFAULT_POPOVER_BLUR = true; // 弹层虚化(菜单/提示/遮罩)开关
344
350
  const DEFAULT_POPOVER_AMOUNT = 10; // 弹层虚化程度
@@ -624,6 +630,8 @@ function mpwTrace(ev, data) {
624
630
  // localStorage 持久化(⑤:读写带缓存 + 防抖,滑杆不卡顿)
625
631
  // ═══════════════════════════════════════════════════════════════════
626
632
  let sectionCache = null;
633
+ let sectionView = null; // 用户档视图(去元键/记账键);与 sectionCache 同源时才复用
634
+ let sectionViewSrc = null;
627
635
  let persistTimer = null;
628
636
  /* ①(2026-09-19 设置持久化轮) 落盘收尾闸门:boot 的第一次 applyFromStorage() 跑在宿主
629
637
  `GET /settings` **之前**(旧写法会在那一刻把残缺档同时写进 localStorage + PUT 给宿主,
@@ -774,6 +782,22 @@ function sectionSigNow() {
774
782
  } catch {}
775
783
  return "";
776
784
  }
785
+ /* ②(2026-10-03 真 bug:"左栏还是粉色,现在右栏也被注入了粉色")
786
+ 3.15.0 把「界面透明度」/「表面透明度」两条滑条的**数值语义翻转**了:
787
+ 旧:数值 = 白雾**厚度/不透明**程度(0 = 全透明、100 = 全不透明)
788
+ 新:数值 = **透明度**(0 = 全不透明、100 = 全透明) —— 用户拍板的口径
789
+ 默认值照映射搬了(新 65 ≡ 旧 35),但**存量档里的用户值没搬**:用户当年为了"求实心"
790
+ 把旧滑条拖到 100(= 全不透明),翻转后同一个 100 变成"全透明" ⇒ 左栏整块透出壁纸,
791
+ 跟随档下右栏也一起透 ⇒ 真机现象就是"被注入粉色"(探针三次读数都指向"那层就是壁纸本身",
792
+ 因为确实没刷任何采样色 —— 病根在这个没搬的值,不在渲染)。
793
+ 处置:一次性按 `新 = 100 - 旧` 搬值(观感与旧版逐字一致),写幂等标记 `alphaSemantics = 2`
794
+ (登记进 MPW_META_KEYS:只随档持久化,用户档/面板/导出/诊断看不到它)。 */
795
+ const MPW_ALPHA_SEM_KEY = "alphaSemantics";
796
+ const MPW_ALPHA_SEM = 2;
797
+ function mpwAlphaFlipValue(v) {
798
+ const n = Number(v);
799
+ return Number.isFinite(n) ? Math.max(0, Math.min(100, 100 - n)) : void 0;
800
+ }
777
801
  function mpwNormalizeSection(s0) {
778
802
  if (!s0 || typeof s0 !== "object") return {};
779
803
  let need = false;
@@ -793,10 +817,22 @@ function sectionSigNow() {
793
817
  而不是"值是不是 false";没标记(从没设过)才迁移,动过的用户一字不动。 */
794
818
  const phMigrate = s0.powPauseHiddenUserSet === true ? null : DEFAULT_POW_PAUSE_HIDDEN;
795
819
  const phNeeds = phMigrate !== null && s0.powPauseHidden !== phMigrate;
796
- if (!need && !bsNeeds && !phNeeds) return s0;
820
+ /* ②(2026-10-03) 透明度语义翻转的**搬值**(口径见上面 mpwAlphaFlipValue 的注释):
821
+ 只搬"档里真的有这个字段"的项(从没设过 ⇒ 保持新默认,不动)。 */
822
+ const alphaFlip = s0[MPW_ALPHA_SEM_KEY] !== MPW_ALPHA_SEM;
823
+ const alphaSide = alphaFlip && s0.sidebarAlpha !== void 0 ? mpwAlphaFlipValue(s0.sidebarAlpha) : void 0;
824
+ const alphaRight = alphaFlip && s0.rightSidebarAlpha !== void 0 ? mpwAlphaFlipValue(s0.rightSidebarAlpha) : void 0;
825
+ if (!need && !bsNeeds && !phNeeds && !alphaFlip) return s0;
797
826
  const o = Object.assign({}, s0);
798
827
  if (bsNeeds) { o.bsCompat = bsMigrate; try { console.log("[dsh-mpkg-wallpaper] bsCompat 迁移:从没显式设过 ⇒ 采用新默认 " + bsMigrate + "(手动关过的用户不受影响)") } catch (e) {} }
799
828
  if (phNeeds) { o.powPauseHidden = phMigrate; try { console.log("[dsh-mpkg-wallpaper] powPauseHidden 迁移:从没显式设过 ⇒ 采用新默认 " + phMigrate + "(切页静音是浏览器基本礼仪;手动关过的用户不受影响)") } catch (e) {} }
829
+ if (alphaFlip) {
830
+ o[MPW_ALPHA_SEM_KEY] = MPW_ALPHA_SEM;
831
+ const moved = [];
832
+ if (alphaSide !== void 0 && alphaSide !== s0.sidebarAlpha) { o.sidebarAlpha = alphaSide; moved.push("sidebarAlpha " + s0.sidebarAlpha + "→" + alphaSide) }
833
+ if (alphaRight !== void 0 && alphaRight !== s0.rightSidebarAlpha) { o.rightSidebarAlpha = alphaRight; moved.push("rightSidebarAlpha " + s0.rightSidebarAlpha + "→" + alphaRight) }
834
+ try { console.log("[dsh-mpkg-wallpaper] 透明度语义搬值(旧=厚度 → 新=透明度,只做一次):" + (moved.length ? moved.join("、") : "档内无这两个字段,保持新默认")) } catch (e) {}
835
+ }
800
836
  const bad = [];
801
837
  for (const k of MPW_STR_KEYS) if (o[k] !== void 0 && typeof o[k] !== "string") { bad.push(k + ":" + typeof o[k]); o[k] = mpwAsStr(o[k]) }
802
838
  try { console.warn("[dsh-mpkg-wallpaper] 已归一化异常字符串字段:", bad.join(", ")); } catch {}
@@ -808,12 +844,18 @@ function sectionSigNow() {
808
844
  // `__mpwLocalAt` 这类元键(时间戳/自愈标记),但"用户档"的读数口(面板/buildCss/
809
845
  // 导出/字段集合比较/测试的逐字段 diff)必须看不到它们 —— 否则元键会被当成
810
846
  // "用户的设置项"参与比较(真机上表现为"莫名多出一个键",测试里表现为假红)。
811
- const clean = mpwStripMeta(sectionCache);
812
- if (clean !== sectionCache) sectionCache = clean;
813
- return sectionCache;
847
+ // ②(2026-10-03 透明度语义搬值) 记账键同理不可见,但它**必须留在 sectionCache 里**
848
+ // (落盘要写、下次开机要认)⇒ 视图单独缓存,**不再回写 sectionCache**。
849
+ // 老写法 `sectionCache = clean` 会把记账键从缓存里抹掉 ⇒ 落盘丢标记 ⇒ 每次开机重搬值
850
+ // (实测:搬值日志每开一次机打一次,值被来回翻)。
851
+ if (sectionViewSrc === sectionCache && sectionView) return sectionView;
852
+ sectionView = mpwStripBook(mpwStripMeta(sectionCache));
853
+ sectionViewSrc = sectionCache;
854
+ return sectionView;
814
855
  }
815
856
  sectionCache = mpwNormalizeSection(loadSection());
816
- return sectionCache;
857
+ sectionView = null; sectionViewSrc = null;
858
+ return readSection();
817
859
  }
818
860
  /** 写入内存缓存 + 防抖落盘。instant=true 立即写(文件选择/复位)。
819
861
  * ⑤(新) host 可用时同时 PUT 到宿主端文件(跨端口/清浏览器数据不丢)。
@@ -1261,6 +1303,10 @@ function sectionSigNow() {
1261
1303
  const MPW_SRC_LOST_KEY = "__mpwSrcLost";
1262
1304
  /** 元键:只管诊断/自愈记账,**永远不进 section**(面板/apply/CSS 都看不到它们)。 */
1263
1305
  const MPW_META_KEYS = [MPW_STAMP_LOCAL, MPW_STAMP_HOST, MPW_SRC_LOST_KEY];
1306
+ /* ②(2026-10-03 透明度语义搬值) **记账键**:与元键的区别是它必须活到 mpwNormalizeSection 那一趟
1307
+ (loadSection 只去元键),否则每次开机都重搬一次值;但用户档视图(面板/导出/CSS)同样不许看到它
1308
+ ⇒ 只多这一层 `mpwStripBook`(见 readSection)。 */
1309
+ const MPW_BOOK_KEYS = [MPW_ALPHA_SEM_KEY];
1264
1310
  /** 壁纸「源」字段:buildCss 的 `hasImage = image || webUrl` 唯一依据这三个。 */
1265
1311
  const MPW_SRC_KEYS = ["image", "webUrl", "sceneKey"];
1266
1312
 
@@ -1285,6 +1331,18 @@ function sectionSigNow() {
1285
1331
  return o;
1286
1332
  } catch { return s0 }
1287
1333
  }
1334
+ /** 去掉**记账键**(用户档视图专用;落盘/归一化那条路不经过它)。 */
1335
+ function mpwStripBook(s0) {
1336
+ if (!s0 || typeof s0 !== "object") return {};
1337
+ try {
1338
+ let need = false;
1339
+ for (const k of MPW_BOOK_KEYS) if (s0[k] !== void 0) { need = true; break }
1340
+ if (!need) return s0;
1341
+ const o = Object.assign({}, s0);
1342
+ for (const k of MPW_BOOK_KEYS) delete o[k];
1343
+ return o;
1344
+ } catch { return s0 }
1345
+ }
1288
1346
  function mpwVal(s, k) {
1289
1347
  try { return s && typeof s === "object" ? s[k] : void 0 } catch { return void 0 }
1290
1348
  }
@@ -7609,7 +7667,9 @@ function mpwThumbFail(el, why) {
7609
7667
  el.style.cssText = "position:absolute;inset:0;z-index:0;pointer-events:none;border-radius:inherit;";
7610
7668
  hdr.insertBefore(el, hdr.firstChild);
7611
7669
  }
7612
- const bf = blurPx > 0 ? ("blur(" + blurPx + "px) saturate(140%)") : "none";
7670
+ /* ②(2026-10-04) 与侧栏同一条纪律:chrome 表面只做**模糊**,不再叠 saturate(140%)。
7671
+ 壁纸层自己已经带了 contrast(1.06) saturate(1.12),再乘 1.4 就是"标题栏比屏幕别处更粉"。 */
7672
+ const bf = blurPx > 0 ? ("blur(" + blurPx + "px)") : "none";
7613
7673
  el.style.backdropFilter = bf;
7614
7674
  el.style.webkitBackdropFilter = bf;
7615
7675
  // header 需要定位上下文;已有就不覆盖(不改变宿主布局)
@@ -7717,6 +7777,11 @@ function mpwThumbFail(el, why) {
7717
7777
  透明度 100% 时还留一层模糊(用户 ④⑤⑦)。现在:**透明度 = 0(完全不透明)⇒ 什么都不注入**
7718
7778
  (层移除 + 属性撤掉,宿主自己的样子);否则底色/半径都走表面模型。 */
7719
7779
  if (planHdr.header.opacityPct >= 100) { cleanup("透明度=0(完全不透明)⇒ 标题栏交还宿主,不注入磨砂层"); return; }
7780
+ /* ②(2026-10-04 与侧栏同一条铁律) 表面**不透明度 ≤ 0**(界面透明度 100%)且不是用户
7781
+ 单独配置的档(`indep`)⇒ 我们一个像素都不画,标题栏也**不许留 backdrop-filter**:
7782
+ 否则那块壁纸被糊成平均色(用户真机读到的"一层粉色滤镜")。他自己开过标题栏磨砂
7783
+ 闸门(indep)时照旧注入 —— 那是他点名要的。 */
7784
+ if (!planHdr.header.indep && planHdr.header.opacityPct <= 0) { cleanup("透明度=100%(无雾层)⇒ 标题栏交还宿主,不注入磨砂层(我们一个像素都不画时不留滤镜)"); return; }
7720
7785
  const px = !wanted ? 0
7721
7786
  : (un ? Math.max(0, Math.round(planHdr.header.blur || 0))
7722
7787
  : (own ? pxFloor(ownAmt, 8)
@@ -8444,6 +8509,7 @@ function mpwThumbFail(el, why) {
8444
8509
  } catch {}
8445
8510
  try { setupSblurObserver(); } catch {}
8446
8511
  try { setupHeaderBlurWatch(); } catch {}
8512
+ try { setupPopTagWatch(); } catch {}
8447
8513
  try { setupAquaThemeWatch(); } catch {}
8448
8514
  // ①(新) 主题颜色:body 门控 + CSS 变量(控制侧边栏/标题栏/新会话/设置弹窗底色)
8449
8515
  // ①(Qoder 审查 R3) 总开关关闭时还原默认外观:themeColor/accent 的
@@ -16186,6 +16252,9 @@ module.exports = {
16186
16252
  }
16187
16253
 
16188
16254
  const MPW_4K_MIN_PX = 3840 * 2160; // ≥4K 的判据(像素面积,与旧提示同口径)
16255
+ /* ①(2026-10-03) 弹层兜底底的默认不透明度:0.58(既有玻璃感、又保证字不糊)。
16256
+ 口径:**只在宿主自己没有底色的弹层上**才刷(见 mpwTagPopoverBg),已有玻璃的弹层不动。 */
16257
+ const MPW_POP_SURFACE_ALPHA = 0.58;
16189
16258
  const MPW_4K_AUTOCAP_RES = 1920; // 自动降档目标 = RES_LEVELS 里的 1080p 档
16190
16259
  /* ①(2026-10-02 用户拍板:**只对当前壁纸临时生效、不写设置、不弹条**)
16191
16260
  上一版把 `resMax` 写进设置 ⇒ 之后每次改外观设置都会重新走一遍转码判断、
@@ -16474,6 +16543,15 @@ module.exports = {
16474
16543
  uAlpha = 生效**不透明度** = 100 - 「界面透明度」(0 = 不透明、100 = 全透明)。
16475
16544
  与模糊彻底解耦:模糊 0 时也照样按透明度透出(背后壁纸是清晰的)。 */
16476
16545
  const surfacePlan = mpwSurfacePlan(section);
16546
+ /* ②(2026-10-04 用户真机:"整屏虚化程度调大 ⇒ 左/右侧边栏被叠上一层粉色滤镜;
16547
+ 调到 0 就是完全透明的状态")**铁律:我们一个像素都不画时,也不给宿主叠 backdrop-filter。**
16548
+ `backdrop-filter` 不是"我们的表面",而是对宿主内容施加的滤镜 —— 完全没有雾层时它只剩失真:
16549
+ 真机档(界面透明度 100% + chatFollow 关 + 整屏虚化 40)下侧栏只剩 blur(40px)+(旧)
16550
+ saturate(140%),那块壁纸被糊成一片平均色(他的壁纸在那里正是粉的)⇒ 读成"被注入粉色滤镜"。
16551
+ 判定 = 表面不透明度 ≤ 0 **且** 该项不是用户单独配置的(`indep`:他自己开过那条磨砂闸门
16552
+ ⇒ 那是点名要的效果,照给)。 */
16553
+ const leftNoFog = surfacePlan.left.opacityPct <= 0 && !surfacePlan.left.indep;
16554
+ const rightNoFog = surfacePlan.right.opacityPct <= 0 && !surfacePlan.right.indep;
16477
16555
  /* ①(2026-10-02 用户拍板 + 真机读数) **透明度 = 0(完全不透明)⇒ 该表面完全不覆盖宿主**:
16478
16556
  不写背景色、不写 backdrop-filter、也不透明化宿主的 sidebar-fill token —— 就是"没装插件"的样子。
16479
16557
  为什么必须"不写"而不是"写一个 100% 的宿主色":真机读数证实宿主在**打开设置面板**时会把左栏
@@ -17023,8 +17101,8 @@ html body:not([data-mpw-hdr-blur-element]):not([data-mpw-hdr-frost-el]) .wSkVaW_
17023
17101
  z-index: -1 !important;
17024
17102
  pointer-events: none !important;
17025
17103
  border-radius: inherit !important;
17026
- -webkit-backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) saturate(140%) !important;
17027
- backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) saturate(140%) !important;
17104
+ -webkit-backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) !important;
17105
+ backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) !important;
17028
17106
  }
17029
17107
  `;
17030
17108
  // ①(修正) 标题栏内部白框:unify(透明标题栏)时 session logo 按钮 /
@@ -17314,8 +17392,8 @@ html body[data-sidebar-right-float-host],
17314
17392
  html body[data-mpw-rs],
17315
17393
  html body[data-dockkit-strip],
17316
17394
  html body[data-dockkit-float] {
17317
- -webkit-backdrop-filter: blur(${rsAmount}px) saturate(140%);
17318
- backdrop-filter: blur(${rsAmount}px) saturate(140%);
17395
+ -webkit-backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17396
+ backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17319
17397
  background-color: ${rsBg} !important;
17320
17398
  }
17321
17399
  /* ④(2026-09-11 一行修复) 原为 body:not([data-mpw-rsblur]) 兜底——body 从未设置该属性 → 恒真
@@ -17337,6 +17415,9 @@ body[data-mpw-rsblur="off"] [data-dockkit-strip], body[data-mpw-rsblur="off"] [d
17337
17415
  // 它们的 containing block → 面板定位错乱、z-index 失效。展开面板出现时
17338
17416
  // JS 打 data-mpw-header-blur-off 摘除(见 setupHeaderBlurWatch)。
17339
17417
  // 弹窗打开时 body[data-mpw-sblur-off] 同样摘除(复用侧边栏磨砂机制)。
17418
+ // ②(2026-10-04) 半径也走同一个门:透明 100%(无雾)时这块写 none(= 一个滤镜都不给宿主)。
17419
+ const unifyChromePx = leftNoFog ? 0 : unifyAmount;
17420
+ const unifyChromeCss = unifyChromePx > 0 ? `blur(${unifyChromePx}px)` : "none";
17340
17421
  if (unifyAmount > 0 && bdSupported && !chromeInert) { // ③(2026-09-11 一行修复) 去掉 unifyTint/chatFollow 前置条件——默认 chatFollow=true 会让该块永不输出,导致栏/标题栏磨砂在有壁纸时也缺失(用户实测)
17341
17422
  css += `
17342
17423
  /* ── 统一虚化 + 聊天区不跟随:侧边栏/标题栏背后叠加整屏虚化度(标题栏展开面板
@@ -17355,8 +17436,8 @@ body[data-mpw-rsblur="off"] [data-dockkit-strip], body[data-mpw-rsblur="off"] [d
17355
17436
  (见上方 QsffPG_root 规则,z-index:500)→ 浮层在 header 的层叠上下文内部也能压过标题文字。
17356
17437
  ?hdrblur=pseudo 可切到"仅伪元素"方案(对照排障用)。 */
17357
17438
  html body[data-mpw-hdr-blur-element]:not([data-mpw-sblur-off]) .wSkVaW_header {
17358
- -webkit-backdrop-filter: blur(${unifyAmount}px) saturate(140%) !important;
17359
- backdrop-filter: blur(${unifyAmount}px) saturate(140%) !important;
17439
+ -webkit-backdrop-filter: ${unifyChromeCss} !important;
17440
+ backdrop-filter: ${unifyChromeCss} !important;
17360
17441
  }
17361
17442
  /* ⑥(2026-09-20) :not([data-mpw-holds-layer]):**该节点是弹层的祖先时绝不给 backdrop-filter**
17362
17443
  —— 真机实测这条规则一旦落在 pI_x6G_sidebarCol 上(设置 overlay 的后代祖先链里有它),
@@ -17364,8 +17445,8 @@ html body[data-mpw-hdr-blur-element]:not([data-mpw-sblur-off]) .wSkVaW_header {
17364
17445
  按**结构**维护(不依赖 :has()、不依赖弹层可见性/动画进度)。 */
17365
17446
  html body:not([data-mpw-sblur-off]).pI_x6G_sidebarCol:not([data-mpw-holds-layer]),
17366
17447
  html body:not([data-mpw-sblur-off])[class*="sidebarCol"]:not([data-sidebar-right-panel]):not([data-mpw-holds-layer]) {
17367
- backdrop-filter: blur(${unifyAmount}px) !important;
17368
- -webkit-backdrop-filter: blur(${unifyAmount}px) !important;
17448
+ backdrop-filter: ${unifyChromeCss} !important;
17449
+ -webkit-backdrop-filter: ${unifyChromeCss} !important;
17369
17450
  }
17370
17451
  html body:has([class*="_overlay"]), html body:has([class*="_modal"]), html body:has([class*="_portal"]) {
17371
17452
  --mpw-overlay-open: 1;
@@ -17402,10 +17483,22 @@ html body:has([class*="_modal"]) [data-mpw-rs] {
17402
17483
  · 基色 = 宿主的侧栏填充色(不再是我们的纯白 —— 白底/暗色错色都是那条引起的);
17403
17484
  · 不透明度 = 100 - 界面透明度(0 = 不透明、100 = 全透明);
17404
17485
  · 半径 = 该项独立档用自己那条,否则用统一虚化的(plan 里已算好)。 */
17405
- const chromeBlur = Math.max(0, Math.round(surfacePlan.left.blur || 0));
17406
- const chromeBlurCss = chromeBlur > 0 ? `blur(${chromeBlur}px) saturate(140%)` : "none";
17486
+ /* ②(2026-10-04 用户真机:"整屏虚化程度调大时左/右侧边栏被叠上一层**粉色滤镜**;
17487
+ 调到 0 时就是完全透明的状态")
17488
+ 铁律:**我们一个像素都不画(表面不透明度 = 0)时,也不许给宿主叠 backdrop-filter**。
17489
+ 为什么:`backdrop-filter` 不是"我们的表面",而是对**宿主内容**施加的滤镜 ——
17490
+ 完全没有雾层时它只剩下失真。用户真机档(界面透明度 100% + chatFollow 关 +
17491
+ 整屏虚化 40)下,侧栏只剩 `blur(40px) saturate(140%)`:那块壁纸被糊成一片平均色
17492
+ (他的壁纸在那里是粉的)⇒ 读成"被注入一层粉色滤镜"。透明 100% 时他明确要的是
17493
+ "看到壁纸本身",所以:**跟随档下透明 100% ⇒ 半径归 0(写 none)**;
17494
+ 用户显式开过该项自己的磨砂(`indep`)时照旧给 —— 那是他点名要的效果。
17495
+ 同时把 chrome 表面上的 `saturate(140%)` 一并去掉:壁纸层自己已经带了
17496
+ `contrast(1.06) saturate(1.12)`,再叠 1.4 倍饱和 = 侧栏比屏幕别处**明显更粉**。 */
17497
+ const chromeBlur = leftNoFog ? 0 : Math.max(0, Math.round(surfacePlan.left.blur || 0));
17498
+ const chromeBlurCss = chromeBlur > 0 ? `blur(${chromeBlur}px)` : "none";
17407
17499
  const chromeAlpha = Math.max(0, Math.min(1, surfacePlan.left.opacityPct / 100));
17408
- const rsAmt2 = Math.max(0, Math.round(surfacePlan.right.blur || 0));
17500
+ const rsAmt2 = rightNoFog ? 0 : Math.max(0, Math.round(surfacePlan.right.blur || 0));
17501
+ const rsBlurCss = rsAmt2 > 0 ? `blur(${rsAmt2}px)` : "none";
17409
17502
  const rsAlpha2 = Math.max(0, Math.min(1, surfacePlan.right.opacityPct / 100));
17410
17503
  /* ①(2026-09-18 §5 第1项) 这几枚原来是这里自建的一个 :root 块 —— 现在统一登记进
17411
17504
  SSOT(emitSurfaceTokens 输出的那个 :root),值逐字不变;原来是"本块自己的
@@ -17429,12 +17522,14 @@ html body:has([class*="_modal"]) [data-mpw-rs] {
17429
17522
  所以"统一虚化程度/透明度都拉到 0"时右栏/dock 也是干净的透出,不留采样色。 */
17430
17523
  const fogCss = mpwFogModel(section);
17431
17524
  const unifyTakeover = unifyFollowNow && hasImage && !rightInert;
17432
- const unifyBlurCss = fogCss.amountPx > 0 ? `blur(${fogCss.amountPx}px) saturate(140%)` : "none";
17525
+ const unifyBlurCss = (!rightNoFog && fogCss.amountPx > 0) ? `blur(${fogCss.amountPx}px)` : "none";
17433
17526
  /* ⚠️ 三枚 token **只在接管时**输出:不接管时产物里连变量名都不该出现
17434
17527
  (tools/fog-model-test 的 B3/E3 判据:不跟随时 `--mpw-unify-*` 必须为 0 处),
17435
17528
  否则"有一枚没人用的 token"会被当成"接管逻辑还在"。 */
17436
17529
  if (unifyTakeover) {
17437
- tok("--mpw-unify-blur", Math.max(0, Math.round(surfacePlan.right.blur || 0)) + "px");
17530
+ /* ②(2026-10-04) 半径同样走"无雾 ⇒ 0"的生效值(与 unifyBlurCss 同源):
17531
+ 否则诊断/探针会读到"接管档 40px"而 CSS 实际写 none,两处口径不一致。 */
17532
+ tok("--mpw-unify-blur", (rightNoFog ? 0 : Math.max(0, Math.round(surfacePlan.right.blur || 0))) + "px");
17438
17533
  tok("--mpw-unify-surface", mpwSurfaceColor(surfacePlan.right.base, surfacePlan.right.opacityPct / 100));
17439
17534
  tok("--mpw-unify-surface-dark", mpwSurfaceColor(surfacePlan.right.base, surfacePlan.right.opacityPct / 100));
17440
17535
  }
@@ -17478,8 +17573,8 @@ ${rightInert ? "" : `html body[data-mpw-rsblur="on"] [data-sidebar-right-panel],
17478
17573
  html body[data-mpw-rsblur="on"] [data-dockkit-pane],
17479
17574
  html body[data-mpw-rsblur="on"] [data-dockkit-strip],
17480
17575
  html body[data-mpw-rsblur="on"] [data-dockkit-surface] {
17481
- -webkit-backdrop-filter: blur(var(--mpw-rs-blur)) saturate(140%) !important;
17482
- backdrop-filter: blur(var(--mpw-rs-blur)) saturate(140%) !important;
17576
+ -webkit-backdrop-filter: ${rsBlurCss} !important;
17577
+ backdrop-filter: ${rsBlurCss} !important;
17483
17578
  background-color: var(--mpw-surface-rs-dock) !important;
17484
17579
  }`}
17485
17580
  /* 抑制:弹窗/设置面板打开时(backdrop-filter 会成为 fixed 弹窗的 containing block → 必须摘除) */
@@ -17722,8 +17817,8 @@ html body [data-rightbar-fullscreen] [data-sidebar-right-panel],
17722
17817
  html body .pI_x6G_frame[data-rightbar-fullscreen] [class*="P3OORG_panel"],
17723
17818
  html body [data-rightbar-fullscreen] [class*="P3OORG_panel"] {
17724
17819
  z-index: 40 !important;
17725
- -webkit-backdrop-filter: blur(var(--mpw-rs-blur, 14px)) saturate(140%) !important;
17726
- backdrop-filter: blur(var(--mpw-rs-blur, 14px)) saturate(140%) !important;
17820
+ -webkit-backdrop-filter: ${rsBlurCss} !important;
17821
+ backdrop-filter: ${rsBlurCss} !important;
17727
17822
  background-color: var(--mpw-surface-rs-full) !important;
17728
17823
  }
17729
17824
  /* 全屏时把聊天输入框区域压到右栏之下(即便它有自己的 stacking context 也不会盖住卡片圆角) */
@@ -17843,6 +17938,11 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
17843
17938
  const n = Number(v);
17844
17939
  return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : DEFAULT_POPOVER_ALPHA;
17845
17940
  })();
17941
+ /* ①(2026-10-03) 「弹层不透明度」滑条是否被用户**显式**调过(值 ≠ 默认 94)。
17942
+ 只有显式调过(或开了主题色遮罩/染色)才允许我们去**覆盖宿主自己画好的底**;
17943
+ 默认状态下宿主自己的玻璃底一律不动 —— 用户第 3 条夸的就是这些。 */
17944
+ const popAlphaUserSet = section.popoverAlpha !== void 0 && Number(section.popoverAlpha) !== DEFAULT_POPOVER_ALPHA;
17945
+ const popFallbackAlpha = popAlphaUserSet ? popSurfaceAlpha / 100 : MPW_POP_SURFACE_ALPHA;
17846
17946
  // 深色主题用深底(浅底 + 浅色文字 = 看不见字),浅色主题用白底;
17847
17947
  // 若用户开了主题色(--mpw-theme-color)则跟随主题色,保持与整屏一致。
17848
17948
  /* ①(2026-10-02 真机读数定案的**真 bug**) 这里原来拼 `rgba(var(--mpw-chrome-bg, 255,255,255), α)`,
@@ -17854,6 +17954,15 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
17854
17954
  /* ①(2026-09-18 §5 第1项) 弹层(面板家族)表面 → 两枚表面 token(不透明度走 --mpw-pop-alpha,
17855
17955
  调用方可临时覆盖;默认值仍是「弹层不透明度」滑条)。 */
17856
17956
  tok("--mpw-surface-pop", mpwSurfaceColor(surfacePlan.left.base, popSurfaceAlpha / 100).replace(/[\d.]+%/, `calc(var(--mpw-pop-alpha, ${(popSurfaceAlpha / 100).toFixed(2)}) * 100%)`));
17957
+ /* ①(2026-10-03 用户:"设置背景的 token 跟那个重掉了,让他独立出来")
17958
+ 弹层(菜单/下拉/选择器)**不再共用面板表面 token** `--mpw-surface-pop`:
17959
+ 上一版把 0.94 的它刷到弹层上 ⇒ 用户夸过的那些"本来就有玻璃感"的弹层(标题栏里的
17960
+ 子代理展开框、VS Code 菜单…)被我们的近不透明底盖住,**玻璃感消失**。
17961
+ 现在独立成一对 token,并且**只在宿主自己没有底色的弹层上兜底**(由 JS 打
17962
+ `data-mpw-pop-bg` 标记,见 mpwTagPopoverBg):有宿主玻璃的弹层一个像素都不动。 */
17963
+ tok("--mpw-pop-surface", mpwSurfaceColor(surfacePlan.left.base, popFallbackAlpha).replace(/[\d.]+%/, `calc(var(--mpw-pop-surface-alpha, ${popFallbackAlpha.toFixed(2)}) * 100%)`));
17964
+ tok("--mpw-pop-surface-alpha", popFallbackAlpha.toFixed(2));
17965
+ tok("--mpw-pop-surface-dark", `rgba(18, 22, 30, var(--mpw-pop-surface-alpha, ${popFallbackAlpha.toFixed(2)}))`);
17857
17966
  tok("--mpw-surface-pop-dark", `rgba(18, 22, 30, var(--mpw-pop-alpha, ${(popSurfaceAlpha / 100).toFixed(2)}))`);
17858
17967
  // 弹层**表面**选择器(背景 + 模糊共用一份,避免两处写法漂移)。
17859
17968
  // ③(关键收窄 2026-09-13) 只用 [class*="_menu"] 会连**非表面**一起命中:
@@ -17876,7 +17985,10 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
17876
17985
  popoverAmount(关掉弹层虚化时为 0 ⇒ 只留底色);圆角沿用宿主自己的(不覆盖 host radius)。 */
17877
17986
  tok("--mpw-pop-blur", ((popoverBlur && Number(popoverAmount) > 0) ? `blur(${Number(popoverAmount)}px)` : "none"));
17878
17987
  tok("--mpw-pop-radius", "var(--dsw-alias-radius-l2, 12px)");
17879
- const popSurfaceCss = (popSurfaceAlpha < 100 || popoverBlur || menuTranslucent)
17988
+ /* ②(2026-10-03 用户:"原本那种玻璃效果被修没了") **默认不再强刷**:只有用户**显式**动过
17989
+ 「弹层不透明度」滑条、或开了主题色遮罩/染色(那时他明确要我们的表面色)才整片覆盖;
17990
+ 默认只给"宿主自己没画底/画的是纯白小浮层"的弹层兜底(`data-mpw-pop-bg`,独立 token)。 */
17991
+ const popSurfaceCss = (popAlphaUserSet || menuTranslucent)
17880
17992
  ? `/* 弹层表面底色兜底(浅色/深色两套;不透明度由「弹层不透明度」控制) */
17881
17993
  html body :is(${popSurfaceSel}) {
17882
17994
  background-color: var(--mpw-surface-pop) !important;
@@ -17899,16 +18011,13 @@ ${popSurfaceSel} {
17899
18011
  /* ①(2026-10-02) 半径/模糊统一读自有 token(--mpw-pop-blur 是唯一来源;关掉弹层虚化时它就是 none) */
17900
18012
  backdrop-filter: var(--mpw-pop-blur, ${popFilter}) !important;
17901
18013
  -webkit-backdrop-filter: var(--mpw-pop-blur, ${popFilter}) !important;
17902
- /* ①(2026-10-02 用户第 3 条:有的弹层"完全透明、文字重叠") **模糊与底色同一条规则**:
17903
- 以前模糊用 popTipSel, popSurfaceSel 两个选择器集、底色只用 popSurfaceSel ⇒
17904
- 命中前者而不命中后者的元素就变成"有模糊、没有底"(真机读数:「+」菜单/权限/模型
17905
- 三处最外层容器 bg 全透明但 bf=blur(11px))。拍平成一个集:**给谁加模糊,就给谁加底**。 */
17906
- background-color: var(--mpw-surface-pop) !important;
17907
- }
17908
- html body[data-ds-dark-theme] ${popTipSel},
17909
- html body[data-ds-dark-theme] ${popSurfaceSel} {
17910
- background-color: var(--mpw-surface-pop-dark) !important;
17911
18014
  }
18015
+ /* ①(2026-10-03 用户:"原本那种玻璃效果被你修没了"、"背景 token 跟那个重掉了,让他独立出来")
18016
+ 兜底底**只刷宿主自己没有底色的弹层**(JS 扫 computed backgroundColor,α=0 才打 data-mpw-pop-bg);
18017
+ 有宿主玻璃的弹层(标题栏子代理展开框 / VS Code 菜单 / 模型选择…)一个像素都不动。
18018
+ 独立 token:--mpw-pop-surface(-dark)(不再共用面板表面 token --mpw-surface-pop)。 */
18019
+ html body [data-mpw-pop-bg] { background-color: var(--mpw-pop-surface) !important; }
18020
+ html body[data-ds-dark-theme] [data-mpw-pop-bg] { background-color: var(--mpw-pop-surface-dark) !important; }
17912
18021
  [role="menu"] [class*="card"],
17913
18022
  [role="tooltip"] [class*="card"] {
17914
18023
  backdrop-filter: none !important;
@@ -24525,6 +24634,89 @@ body[data-mpw-modal] [role="dialog"] {
24525
24634
  check();
24526
24635
  }
24527
24636
 
24637
+ /* ①(2026-10-03 用户:"原本那种玻璃效果被你修没了" / "他那个设置背景的 token 好像跟那个重掉了,让他独立出来")
24638
+ 弹层**兜底底**标记器:宿主自己把底画好了的弹层,我们**一个像素都不动**;只有下面两类才打
24639
+ `data-mpw-pop-bg`,由 CSS(独立 token `--mpw-pop-surface(-dark)` + `--mpw-pop-blur`)补一套玻璃:
24640
+ 甲、宿主完全没画底(computed backgroundColor 的 α < 0.05,且近祖先也没有可见底)
24641
+ —— 用户第 3 条里「完全透明、文字重叠」的那几处(+ 菜单 / 权限 / 模型选择器);
24642
+ 乙、宿主画的是**不透明纯底的小浮层**(α ≥ 0.999 且面积 < 40% 视口)
24643
+ —— 用户第 3⑥ 条「上下文用量圆环弹出来是纯白」那一处,换成玻璃。
24644
+ 硬前提:**背后必须有模糊**(computed backdrop-filter 含 blur)才刷底色 ——
24645
+ 否则半透明底 + 无模糊 = 字叠字,比原来更糟。模糊由我们(虚化弹层)或宿主提供,两者都算。
24646
+ 开销:只在弹层插进 DOM 时(rAF 合并)扫一次,元素级 getComputedStyle 只落在命中项上。 */
24647
+ let popTagWatch = null;
24648
+ const MPW_POP_TAG_SEL = '[role="menu"], [role="listbox"], [role="combobox"], [class*="_menu"], [class*="_popover"], [class*="_dropdown"], [class*="_denseList"], [class*="JObwrW_panel"], [role="tooltip"]';
24649
+ const MPW_POP_TAG_NOT = /(Anchor|anchor|Icon|icon|Status|status|Item|_item|Row|_row|Trigger|_trigger|Card|_card|groupTitle|_viewport)/;
24650
+ /** 近祖先(≤4 层)里已经有可见底 ⇒ 真正的表面在上面,别在里面再叠一层。 */
24651
+ function mpwPopHasPaintedAncestor(el) {
24652
+ try {
24653
+ let p = el.parentElement, n = 0;
24654
+ while (p && n < 4 && p !== document.body && p !== document.documentElement) {
24655
+ let cs = null;
24656
+ try { cs = getComputedStyle(p); } catch (e) { break; }
24657
+ if (cs && cs.display !== "none" && mpwColorAlpha(cs.backgroundColor) >= 0.3) return true;
24658
+ p = p.parentElement; n++;
24659
+ }
24660
+ } catch (e) {}
24661
+ return false;
24662
+ }
24663
+ /** 小浮层(不是铺满视口的弹层/窗口)—— 不透明底的小面板才允许被换成玻璃。 */
24664
+ function mpwPopIsFloating(el) {
24665
+ try {
24666
+ const r = el.getBoundingClientRect();
24667
+ const vw = window.innerWidth || 1, vh = window.innerHeight || 1;
24668
+ if (!r || r.width <= 0 || r.height <= 0) return false;
24669
+ return r.width * r.height <= 0.4 * vw * vh && r.height <= vh * 0.9;
24670
+ } catch (e) { return false; }
24671
+ }
24672
+ function mpwTagPopoverBg() {
24673
+ let list = null;
24674
+ try { list = document.querySelectorAll(MPW_POP_TAG_SEL); } catch (e) { return; }
24675
+ if (!list) return;
24676
+ for (let i = 0; i < list.length; i++) {
24677
+ const el = list[i];
24678
+ let need = false;
24679
+ try {
24680
+ const cls = (typeof el.className === "string") ? el.className : "";
24681
+ if (!MPW_POP_TAG_NOT.test(cls)) {
24682
+ const cs = getComputedStyle(el);
24683
+ if (cs && cs.display !== "none" && cs.visibility !== "hidden"
24684
+ && /blur\(/.test(String(cs.backdropFilter || cs.webkitBackdropFilter || ""))) {
24685
+ const a = mpwColorAlpha(cs.backgroundColor);
24686
+ if (a < 0.05) need = !mpwPopHasPaintedAncestor(el);
24687
+ else if (a >= 0.999) need = mpwPopIsFloating(el);
24688
+ }
24689
+ }
24690
+ } catch (e) { need = false; }
24691
+ try {
24692
+ if (need) { if (!el.hasAttribute("data-mpw-pop-bg")) el.setAttribute("data-mpw-pop-bg", ""); }
24693
+ else if (el.hasAttribute("data-mpw-pop-bg")) el.removeAttribute("data-mpw-pop-bg");
24694
+ } catch (e) {}
24695
+ }
24696
+ }
24697
+ function setupPopTagWatch() {
24698
+ if (popTagWatch !== null) return;
24699
+ let praf = 0;
24700
+ const prun = () => {
24701
+ praf = 0;
24702
+ try {
24703
+ // 左栏收起/展开与拖拽期间宿主每帧改 DOM:跳过(收尾还会再来一次)
24704
+ if (document.body && document.body.hasAttribute
24705
+ && (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
24706
+ mpwTagPopoverBg();
24707
+ } catch (e) {}
24708
+ };
24709
+ try {
24710
+ popTagWatch = new MutationObserver(() => {
24711
+ if (praf) return;
24712
+ try { praf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(prun) : setTimeout(prun, 16); }
24713
+ catch (e) { prun(); }
24714
+ });
24715
+ popTagWatch.observe(document.body, { childList: true, subtree: true });
24716
+ } catch { popTagWatch = null; }
24717
+ mpwTagPopoverBg();
24718
+ }
24719
+
24528
24720
  // ①(新) 标题栏 backdrop blur 摘除:G 块给 header 加 blur(unifyAmount)(聊天区
24529
24721
  // 不跟随时的标题栏虚化),但 header 内有 absolute 展开面板(子代理/后台任务,
24530
24722
  // JObwrW_panel),backdrop-filter 会变成它们的 containing block → 面板定位错乱
package/lib/index.js CHANGED
@@ -809,8 +809,12 @@ function normalizeScaleFlag(raw) {
809
809
  * 顺序仍是"先缩放再抽帧"(编码量更小)。 */
810
810
  function buildScaleFilter(maxW, fps, scaleFlag) {
811
811
  const w = Number(maxW) > 0 ? Math.round(Number(maxW)) : 0;
812
+ /* ①(2026-10-03 ffmpeg 审计"奇数宽"条) `-2` 只保证**高**是偶数:宽是 `iw`(H.264 允许奇数显示宽,
813
+ 靠裁剪位)或用户/屏幕推出来的 `maxW`(`Math.round(w*dpr)` 可以是奇数,如 1366×1.5=2049)。
814
+ 一旦我们显式给了 `-pix_fmt yuv420p`(见 F2),奇数宽会直接**编码失败**;不给则 ffmpeg 可能
815
+ 协商出非 4:2:0 格式。⇒ 宽也取偶数(`trunc(x/2)*2`),与高同一条纪律。 */
812
816
  return w > 0
813
- ? "scale='min(" + w + ",iw)':-2" + (scaleFlag ? ':flags=' + scaleFlag : '') + ',fps=' + fps
817
+ ? "scale='trunc(min(" + w + ",iw)/2)*2':-2" + (scaleFlag ? ':flags=' + scaleFlag : '') + ',fps=' + fps
814
818
  : 'fps=' + fps;
815
819
  }
816
820
  /** ①(B) 转码缓存键:**只含影响播放的字段**(源标识 | mtime | fps | maxW | 缩放 flags)。
@@ -868,6 +872,13 @@ function pixFmtBitDepth(fmt) {
868
872
  }
869
873
  /** 8bit 一族(这些源**不需要**任何色彩参数 —— 实测忠实,且保持与改动前逐字节相同的产物)。 */
870
874
  const TRANSCODE_8BIT_PIXFMT = /^(yuvj?420p|yuvj?422p|yuvj?444p|nv12|nv21|yuva420p|rgb24|bgr24|gray|pal8)$/i;
875
+ /** ①(2026-10-03 ffmpeg 审计 F2) **浏览器安全的白名单只有 4:2:0**(外加无色度的 gray/pal8):
876
+ * 8bit 的 `yuv422p` / `yuv444p` 源走旧计划是 `passthrough`(不加 `-pix_fmt`)⇒ libx264 会**顺着源**
877
+ * 产出 `High 4:2:2 / High 4:4:4`,而 `browserCodecGap()` 恰恰把这两种 profile 判成"浏览器吃不下"
878
+ * ⇒ 我们**为了救它而转码,却转出一个同样放不出的文件**,而且它是个"有效缓存"(≥1KB + ftyp)
879
+ * 永久命中 ⇒ 之后一直是黑屏、也不会再重转(除非删缓存)。所以:非 4:2:0 一律显式降 `yuv420p`。
880
+ * `rgb24/bgr24` 同理(x264 无 RGB 输入)⇒ 一起收进"要降"的一侧。 */
881
+ const TRANSCODE_SAFE_420 = /^(yuvj?420p|nv12|nv21|yuva420p|gray|pal8)$/i;
871
882
  /** HDR 传输函数(PQ / HLG / 2020 系):这些 transfer 在 8bit SDR 显示链上没有正确解释。 */
872
883
  const TRANSCODE_HDR_TRANSFER = /^(smpte2084|arib-std-b67|smpte428|bt2020-10|bt2020-12)$/i;
873
884
  /** 探测结果 → 转码色彩计划(**纯函数**,门禁直接切片断言;不改文件、不起进程)。
@@ -886,8 +897,12 @@ function transcodeColorPlan(info) {
886
897
  if (TRANSCODE_COLOR_MODE === 'force8') return { mode: 'force8', downgrade: true, deep, hdr, from, to, args: downgradeArgs, reason: 'DSH_WE_TRANSCODE_COLOR=force8:强制 8bit bt709(现场对比用)' };
887
898
  // 不知道(探测失败/空 pix_fmt)⇒ 保持旧行为,但**记账**
888
899
  if (!pixFmt) return { mode: 'unknown', downgrade: false, deep: false, hdr, from, to, args: [], reason: '拿不到源 pix_fmt(探测失败)⇒ 不加色彩参数(与改动前一致),台账记 unknown' };
889
- if (!deep && !hdr) return { mode: 'passthrough', downgrade: false, deep, hdr, from, to, args: [], reason: '8bit SDR:实测转码忠实(标签/像素都不变)⇒ 不加参数,产物与改动前逐字节相同' };
890
- return { mode: 'downgrade', downgrade: true, deep, hdr, from, to, args: downgradeArgs, reason: (deep ? ('源 ' + depth + 'bit') : '') + (deep && hdr ? ' + ' : '') + (hdr ? ('HDR transfer ' + transfer) : '') + ' ⇒ 降为 8bit bt709/tv(无 tonemap:ffmpeg-static 无 zscale,偏暗但能播)' };
900
+ /* ①(F2) 只有"8bit + SDR + 4:2:0"才允许 passthrough(与改动前逐字节相同);
901
+ 非 4:2:0 的 8bit 源必须显式降 4:2:0(否则转出 4:2:2/4:4:4 profile = 浏览器仍放不出,
902
+ 却被当作有效缓存永久命中)。 */
903
+ const chromaUnsafe = !!pixFmt && !TRANSCODE_SAFE_420.test(pixFmt);
904
+ if (!deep && !hdr && !chromaUnsafe) return { mode: 'passthrough', downgrade: false, deep, hdr, chroma: false, from, to, args: [], reason: '8bit SDR 4:2:0:实测转码忠实(标签/像素都不变)⇒ 不加参数,产物与改动前逐字节相同' };
905
+ return { mode: 'downgrade', downgrade: true, deep, hdr, chroma: chromaUnsafe, from, to, args: downgradeArgs, reason: (deep ? ('源 ' + depth + 'bit') : '') + (deep && hdr ? ' + ' : '') + (hdr ? ('HDR transfer ' + transfer) : '') + (chromaUnsafe ? ((deep || hdr ? ' + ' : '') + '源 ' + pixFmt + ' 非 4:2:0') : '') + ' ⇒ 降为 8bit 4:2:0 bt709/tv' + ((deep || hdr) ? '(无 tonemap:ffmpeg-static 无 zscale,偏暗但能播)' : '') };
891
906
  }
892
907
  // ═══ MPW-COLOR-PLAN-END ═══
893
908
  /* ═══ MPW-TRANSCODE-GEN-BEGIN(H1 代际/归属/有效性;纯函数 + 三个小状态操作) ═══ */
@@ -991,10 +1006,20 @@ const BROWSER_OK_CONTAINER = /^(mov|mp4|m4a|3gp|3g2|mj2|matroska|webm)/i;
991
1006
  * · h264 + opus 装进 MP4:Chromium 系不支持(Opus 在 MP4 里只有 WebM/ogg 容器被支持);
992
1007
  * · HEVC Main10(10bit):多数平台无 10bit HEVC 解码(8bit Main 在 Safari/部分 Chromium 上可)。
993
1008
  * 只用于"源本身就不该直读"的判断;命中即视为需要转码。 */
994
- function browserCodecGap(video, audio, fmt) {
995
- const afmt = String(fmt || '');
996
- if (video === 'h264' && audio === 'opus' && !/webm|matroska/i.test(afmt)) return 'mp4 容器里的 h264+opus(Chromium 不支持该组合)';
997
- if (video === 'hevc' && /main ?10|main10/i.test(String(fmt || ''))) return 'HEVC Main10(10bit,多数平台无解码器)';
1009
+ function browserCodecGap(video, audio, container, profile) {
1010
+ /* ①(2026-10-03 ffmpeg 审计 F6) **参数拆分**:旧签名 `(video, audio, fmt)` 里 `fmt` 同时被当
1011
+ **容器**(h264+opus 那条)和**profile**(Main10 那条)用,而唯一调用点传的是 `videoProfile`
1012
+ ⇒ 容器永远缺席:MKV/WebM 里的 h264+opus(本该被豁免)被判"mp4 容器里的 h264+opus"
1013
+ ⇒ 白转一次码,`verdict.reason` 也是错的。现在容器与 profile 各是各的参数。
1014
+ 兼容:第三个参数若是 `High 10`/`Main 10` 这类 **profile 串**(老调用点/老测试),
1015
+ 仍按 profile 解释(容器判据退化为"未知",不误判成 mp4)。 */
1016
+ const looksProfile = (v) => /^\s*(high|main|baseline|constrained|profile|simple|advanced|professional)/i.test(String(v || ''));
1017
+ let cont = String(container || '');
1018
+ let prof = String(profile || '');
1019
+ if (prof === '' && looksProfile(cont)) { prof = cont; cont = ''; }
1020
+ const afmt = cont;
1021
+ if (video === 'h264' && audio === 'opus' && afmt !== '' && !/webm|matroska/i.test(afmt)) return 'mp4 容器里的 h264+opus(Chromium 不支持该组合)';
1022
+ if (video === 'hevc' && /main ?10|main10/i.test(prof)) return 'HEVC Main10(10bit,多数平台无解码器)';
998
1023
  /* ①(2026-09-28 H2 色彩轮) **H.264 的高位深 profile**(High 10 / High 4:2:2 / High 4:4:4):
999
1024
  主流浏览器**没有** 10bit H.264 解码器(Chromium/Firefox 只到 High 8bit;Safari/VideoToolbox
1000
1025
  视平台),而本模块此前只认 HEVC Main10 ⇒ 一个 **H.264 High 10** 源会被判"可直读"、
@@ -1003,9 +1028,14 @@ function browserCodecGap(video, audio, fmt) {
1003
1028
  仍是 `High 10 / yuv420p10le` ⇒ 位深策略(`transcodeColorPlan`)必须与这条判据配套。
1004
1029
  置信度与既有 HEVC Main10 一条同级("多数平台无解码器"),不猜;转码若失败客户端仍有
1005
1030
  回退原片的一次性路径(`mpwFallbackToDirect`)。 */
1006
- if (video === 'h264' && /high 10|high 4:2:2|high 4:4:4|high 10 intra|high 422|high 444/i.test(afmt)) {
1007
- return 'H.264 ' + String(fmt || '').trim() + '(10bit/高位深 profile,浏览器多数无解码器)';
1031
+ if (video === 'h264' && /high 10|high 4:2:2|high 4:4:4|high 10 intra|high 422|high 444/i.test(prof)) {
1032
+ return 'H.264 ' + prof.trim() + '(10bit/高位深 profile,浏览器多数无解码器)';
1008
1033
  }
1034
+ /* ①(2026-10-03 ffmpeg 审计 F2 后半) VP9 / AV1 的**高位深/高色度** profile 同样没有浏览器解码器,
1035
+ 旧表只认 h264/hevc ⇒ 这些源被判"可直读"、直接喂浏览器(黑屏),转码这条唯一能救的路没走。
1036
+ ffprobe 对 VP9 报 `Profile 0/1/2/3`、对 AV1 报 `Main/High/Professional`。 */
1037
+ if (video === 'vp9' && /profile\s*(1|3)/i.test(prof)) return 'VP9 Profile 1/3(4:2:2 / 4:4:4,浏览器无解码器)';
1038
+ if (video === 'av1' && /high|professional/i.test(prof)) return 'AV1 High/Professional(4:2:2 / 4:4:4 / 12bit,浏览器无解码器)';
1009
1039
  return '';
1010
1040
  }
1011
1041
  /** 给定探测结果 → { playable: true|false|null, reason }。
@@ -1020,7 +1050,8 @@ function browserPlayable(info) {
1020
1050
  if (!BROWSER_OK_VIDEO.has(v)) return { playable: false, reason: '视频编码 ' + v + ' 不在浏览器白名单(h264/vp8/vp9/av1)' };
1021
1051
  if (a && !BROWSER_OK_AUDIO.has(a)) return { playable: false, reason: '音频编码 ' + a + ' 不在浏览器白名单(aac/mp3/opus/vorbis/flac)' };
1022
1052
  if (!BROWSER_OK_CONTAINER.test(String(fmt))) return { playable: false, reason: '容器 ' + fmt + ' 不是 mp4/webm' };
1023
- const gap = browserCodecGap(v, a, info.videoProfile);
1053
+ /* ①(F6) 容器与 profile 分开传(旧版把 profile 当容器用) */
1054
+ const gap = browserCodecGap(v, a, fmt, info.videoProfile);
1024
1055
  if (gap) return { playable: false, reason: gap };
1025
1056
  return { playable: true, reason: v + (a ? '+' + a : '') + ' / ' + fmt + '(浏览器可直读)' };
1026
1057
  }
@@ -1967,10 +1998,17 @@ function materializeSource(src, cacheDir) {
1967
1998
  while (w < n) w += writeSync(outFd, buf, w, n - w);
1968
1999
  off += n;
1969
2000
  }
1970
- } finally {
1971
- closeSync(fd);
1972
- closeSync(outFd);
2001
+ } catch (e) {
2002
+ /* ①(2026-10-03 ffmpeg 审计 F7) 拷贝中途失败(ENOSPC / 读源出错)时**必须**把半成品删掉:
2003
+ 旧写法只有 `finally` 关句柄 ⇒ 部分写出的 `src_*.bin` 留在缓存目录里等着"1 小时后才回收",
2004
+ 而磁盘正是满的 ⇒ 重试继续失败,用户只看到"转码失败"。 */
2005
+ try { closeSync(fd); } catch { /* 忽略 */ }
2006
+ try { closeSync(outFd); } catch { /* 忽略 */ }
2007
+ try { unlinkSync(tmp); } catch { /* 忽略 */ }
2008
+ throw e;
1973
2009
  }
2010
+ try { closeSync(fd); } catch { /* 忽略 */ }
2011
+ try { closeSync(outFd); } catch { /* 忽略 */ }
1974
2012
  return { file: tmp, cleanup: () => { try { unlinkSync(tmp); } catch { /* 忽略 */ } } };
1975
2013
  }
1976
2014
 
@@ -1979,7 +2017,7 @@ function materializeSource(src, cacheDir) {
1979
2017
  * (实测 0xFFFFFFEA / EPERM),故先试 detached 再试普通 spawn。
1980
2018
  * ①(2026-09-28 H1) `jobKey` = 本子进程所属的产物路径:登记进 `ACTIVE_FFMPEG`(proc→jobKey),
1981
2019
  * 取消时**只杀本任务**的进程(旧实现按"本请求之后启动的全部"过滤,会误杀别的档/别的源)。 */
1982
- function spawnFfmpeg(ff, args, jobKey) {
2020
+ function spawnFfmpeg(ff, args, jobKey, deadline) {
1983
2021
  return new Promise((resolve, reject) => {
1984
2022
  const errLog = join(transcodeCacheDir(), 'ffmpeg-err-' + process.pid + '-' + Date.now() + '.log');
1985
2023
  const attempts = [
@@ -2031,7 +2069,7 @@ function spawnFfmpeg(ff, args, jobKey) {
2031
2069
  clearTimeout(timer);
2032
2070
  ACTIVE_FFMPEG.delete(proc);
2033
2071
  if (errFd) { try { closeSync(errFd); } catch { /* 忽略 */ } }
2034
- let detail = msg || 'ffmpeg failed';
2072
+ let detail = (msg && msg.message) ? msg.message : (msg || 'ffmpeg failed');
2035
2073
  try {
2036
2074
  const t = readFileSync(errLog, 'utf8').trim();
2037
2075
  if (t) detail += ' | stderr: ' + t.split('\n').slice(-4).join(' | ');
@@ -2048,12 +2086,18 @@ function spawnFfmpeg(ff, args, jobKey) {
2048
2086
  if (msg) errors.push(msg);
2049
2087
  runNext();
2050
2088
  };
2089
+ /* ①(2026-10-03 ffmpeg 审计 F4) 超时按**任务剩余时间**算(`deadline` = runTranscode 给的任务级
2090
+ 截止时刻),而不是"每次尝试各给 15 分钟"。旧写法下一次超时会被编码器循环当成"换个编码器
2091
+ 再试"⇒ libx264 15min + libsvtav1 15min + 各自单线程再来一遍 = 最多 1 小时,全程占着
2092
+ `TRANSCODE_MAX_ACTIVE=1` 的唯一名额(与 docs/TRANSCODE-RESOURCE.md 的"单任务硬超时"不符)。 */
2093
+ const budget = Number.isFinite(deadline) ? Math.max(0, deadline - Date.now()) : TRANSCODE_TIMEOUT_MS;
2051
2094
  const timer = setTimeout(() => {
2052
2095
  timedOut = true;
2053
2096
  try { proc.kill(); } catch { /* 忽略 */ }
2054
- // ①(修正) 超时:直接失败,不重试(见 finalReject 注释)
2055
- finalReject(a.name + ' timed out after ' + TRANSCODE_TIMEOUT_MS + 'ms (killed)');
2056
- }, TRANSCODE_TIMEOUT_MS);
2097
+ const err = new Error(a.name + ' timed out after ' + TRANSCODE_TIMEOUT_MS + 'ms (killed)');
2098
+ err.taskTimedOut = true; // ← 任务级超时:runTranscode 拿到它就**不再换编码器/线程模式**
2099
+ finalReject(err);
2100
+ }, budget);
2057
2101
  proc.on('error', (err) => {
2058
2102
  // spawn 阶段的 'error'(如 EPERM)→ 重试下一个 attempt;执行中 error → 直接失败
2059
2103
  if (!done && timedOut) { finalReject(a.name + ' spawn error ' + (err && err.code ? err.code + ' ' + err.message : err)); return; }
@@ -2095,6 +2139,9 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
2095
2139
  throw new Error('ffmpeg not found (set DSH_WE_FFMPEG, install ffmpeg to PATH, or click 下载 ffmpeg → /ffmpeg-download)');
2096
2140
  }
2097
2141
  job.ff = ff.path;
2142
+ /* ①(2026-10-03 ffmpeg 审计 F4) **任务级**截止时刻:整个 runTranscode(含换编码器/换线程模式
2143
+ 的全部尝试)共用这一个预算,超时即终态。 */
2144
+ const deadline = Date.now() + TRANSCODE_TIMEOUT_MS;
2098
2145
  // ①(新) 降分辨率:`scale='min(maxW,iw)':-2` 保持宽高比、宽不超过 maxW(-2=高自动偶数)。
2099
2146
  // 与 fps 合并到一个 -vf 链(先缩放再抽帧,编码量更小)。
2100
2147
  // ①(2026-09-23 B) vf 链构造收敛到 buildScaleFilter(预缩档 = maxW + flags=lanczos;
@@ -2121,6 +2168,11 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
2121
2168
  if (mode.label === 'mt' && futexSeen) continue; // 已知 futex 问题 → 跳过多线程
2122
2169
  for (const enc of encoders) {
2123
2170
  if (cancelled()) throw new Error('cancelled'); // ⓪ 换编码器前也要查
2171
+ if (Date.now() >= deadline) { // ①(F4) 预算已尽 ⇒ 不再起新进程
2172
+ const te = new Error('已到任务级超时(' + TRANSCODE_TIMEOUT_MS + 'ms),停止后续尝试');
2173
+ te.taskTimedOut = true;
2174
+ throw te;
2175
+ }
2124
2176
  job.encoder = enc.name + (mode.label === 'st' ? ' (单线程)' : '');
2125
2177
  // -f mp4 显式指定:临时输出路径不是 .mp4 后缀,ffmpeg 无法按扩展名选 muxer
2126
2178
  // ①(Qoder 审查 M5) 单线程时输出侧再加 -threads 1 控制编码线程
@@ -2129,12 +2181,20 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
2129
2181
  // ⓪(2026-09-17) x264 额外参数(默认空;见 TRANSCODE_X264_PARAMS 注释:省内存但费码率)
2130
2182
  const x264 = (enc.name === 'libx264' && TRANSCODE_X264_PARAMS) ? ['-x264-params', TRANSCODE_X264_PARAMS] : [];
2131
2183
  // ①(2026-09-28 H2) `colorArgs`:>8bit/HDR 源才带(8bit 源为空数组 ⇒ 产物与改动前逐字节相同)
2132
- const full = [...mode.extra, ...base, ...enc.tail, ...x264, ...(colorArgs || []), ...(mode.label === 'st' ? ['-threads', '1'] : []), '-movflags', '+faststart', '-an', '-f', 'mp4', out];
2133
- await spawnFfmpeg(ff.path, full, jobKey);
2184
+ /* ①(2026-10-03 ffmpeg 审计 F3) 旧命令无条件 `-an`("壁纸默认静音更省")——但本插件的
2185
+ **壁纸声音**是一等功能(`applyVideoMute` / NOW PLAYING 音量都落在这个 `<video>` 上):
2186
+ 用户开了声音、又设了 fpsCap/resMax/预缩(或 ≥4K 源触发静默自动降档)⇒ 转码产物**没有音轨**
2187
+ ⇒ 壁纸突然没声,且没有任何提示。现在改为**保留音轨**(aac 128k;源本来没音轨时
2188
+ ffmpeg 的默认流选择不会凭空造一条,`-c:a` 也就用不上)。 */
2189
+ const audioArgs = ['-c:a', 'aac', '-b:a', '128k'];
2190
+ const full = [...mode.extra, ...base, ...enc.tail, ...x264, ...(colorArgs || []), ...(mode.label === 'st' ? ['-threads', '1'] : []), '-movflags', '+faststart', ...audioArgs, '-f', 'mp4', out];
2191
+ await spawnFfmpeg(ff.path, full, jobKey, deadline);
2134
2192
  return;
2135
2193
  } catch (err) {
2136
2194
  lastErr = err;
2137
2195
  try { unlinkSync(out); } catch { /* 忽略 */ }
2196
+ /* ①(F4) 任务级超时 = 终态:不再换编码器、不再换线程模式(旧写法把超时也当成"重试下一个")。 */
2197
+ if (err && err.taskTimedOut) throw err;
2138
2198
  // ①(Qoder 审查) futex 判定用精确报错原文匹配,避免误伤:文件名/路径
2139
2199
  // 含 "futex"(如用户 mpkg 名 futex_test.mpkg)的打开错误也会被宽松
2140
2200
  // /futex/i 命中 → 误置 TRANSCODE_FUTEX_FALLBACK 永久单线程(性能损失)。
@@ -2315,6 +2375,13 @@ async function transcodeToFps(src, fps, maxW, isCancelled, scaleFlag) {
2315
2375
  return p;
2316
2376
  }
2317
2377
 
2378
+ /* ①(2026-10-03 ffmpeg 审计 F1) `maxW` 的**唯一解析点**:`/transcode`(写入键)与
2379
+ `/transcode-progress`(查询键)必须逐字一致 —— 两侧各写一份正是"进度永远 idle"的根因。 */
2380
+ function parseMaxWParam(raw) {
2381
+ const n = Number(raw);
2382
+ return Number.isFinite(n) && n > 0 ? Math.min(3840, Math.round(n)) : 0;
2383
+ }
2384
+
2318
2385
  /** 转码/下载进度 JSON(/transcode-progress 与 /transcode 的 progress 子路径共用)。 */
2319
2386
  function handleTranscodeProgress(req, res) {
2320
2387
  try {
@@ -2322,10 +2389,17 @@ function handleTranscodeProgress(req, res) {
2322
2389
  const file = (url.searchParams.get('file') || '').trim();
2323
2390
  const srcParam = (url.searchParams.get('src') || '').trim();
2324
2391
  const fps = Number(url.searchParams.get('fps')) || 0;
2325
- const maxWRaw = Number(url.searchParams.get('maxW'));
2326
- const maxW = Number.isFinite(maxWRaw) && maxWRaw > 0 ? Math.min(3840, Math.round(maxWRaw)) : 0;
2392
+ const maxW = parseMaxWParam(url.searchParams.get('maxW'));
2327
2393
  // ①(B) 查询键与写入键一致(含缩放 flags);未给 flags ⇒ 与改动前逐字相同的键。
2328
2394
  const scaleFlag = normalizeScaleFlag(url.searchParams.get('scale')) || '';
2395
+ /* ①(2026-10-03 ffmpeg 审计 F1) **查询键必须与写入键同源**:`/transcode` 没拿到 maxW 时会套
2396
+ `TRANSCODE_DEFAULT_MAXW`(写入键 = `srcId|fps|1920`),而这里旧写法直接用 raw maxW=0
2397
+ ⇒ 查 `srcId|fps|0`(永远 idle)。后果有两级:①忙条读到 0% 一直到 2 分钟超时才消失;
2398
+ ②客户端"转码完成后重新 apply"的 10s 轮询等的是 `phase==='done'`,永远等不到 ⇒ 源被判
2399
+ "不可直读"(HEVC Main10 / H.264 High 10 之类)时**卡在回退的原片上黑屏**,而磁盘上其实
2400
+ 早就有转好的产物。⇒ 这里按同一条默认规则算出 effMaxW 作为首选键。 */
2401
+ const reqMaxW = maxW > 0 ? maxW : TRANSCODE_DEFAULT_MAXW;
2402
+ const effMaxW = (TRANSCODE_DEFAULT_MAXW > 0 && reqMaxW > TRANSCODE_DEFAULT_MAXW) ? TRANSCODE_DEFAULT_MAXW : reqMaxW;
2329
2403
  let job = null;
2330
2404
  // ②(fix) src 优先(与 /transcode 同款):客户端回退原片后轮询转码完成,
2331
2405
  // 用原始 image 串查询(旧式 file 兼容保留)。
@@ -2335,8 +2409,10 @@ function handleTranscodeProgress(req, res) {
2335
2409
  const srcId = src.type === 'mpkg' ? 'mpkg:' + src.rec.path + ':' + src.entry.index : srcPath;
2336
2410
  // ①(Qoder 审查 R7) 查询 key 与写入一致(含 maxW)——此前写入
2337
2411
  // srcId|fps|maxW、查询 srcId|fps,设了 maxW 的任务进度永远查不到(idle)。
2338
- const withFlags = srcId + '|' + fps + '|' + maxW + ((maxW > 0 && scaleFlag) ? '|s:' + scaleFlag : '');
2339
- job = transcodeJobs.get(withFlags)
2412
+ const keyOf = (w) => srcId + '|' + fps + '|' + w + ((w > 0 && scaleFlag) ? '|s:' + scaleFlag : '');
2413
+ job = transcodeJobs.get(keyOf(effMaxW)) // ← 与 /transcode 的写入键同源(含默认降采样)
2414
+ || transcodeJobs.get(keyOf(maxW)) // 兼容:显式 maxW 的老键
2415
+ || transcodeJobs.get(srcId + '|' + fps + '|' + effMaxW)
2340
2416
  || transcodeJobs.get(srcId + '|' + fps + '|' + maxW)
2341
2417
  || transcodeJobs.get(srcId + '|' + fps) || null;
2342
2418
  } else if (ffmpegDownloadProgress) {
@@ -4807,8 +4883,7 @@ function apply(ctx) {
4807
4883
  const fpsRaw = Number(url.searchParams.get('fps'));
4808
4884
  const fps = Number.isFinite(fpsRaw) && ALLOWED_FPS.includes(fpsRaw) ? fpsRaw : 0;
4809
4885
  if (!fps) { json(res, 400, { ok: false, error: 'invalid fps (allowed: 24/30/48/60)' }); return; }
4810
- const maxWRaw = Number(url.searchParams.get('maxW'));
4811
- const maxW = Number.isFinite(maxWRaw) && maxWRaw > 0 ? Math.min(3840, Math.round(maxWRaw)) : 0;
4886
+ const maxW = parseMaxWParam(url.searchParams.get('maxW'));
4812
4887
  // ①(2026-09-23 B) 预缩档:`scale=lanczos`(白名单外 ⇒ 400,不把任意串拼进 -vf)。
4813
4888
  // 它只影响**缩放滤波器**;目标宽仍由 maxW 决定(客户端按屏幕物理宽算 maxW)。
4814
4889
  const scaleFlag = normalizeScaleFlag(url.searchParams.get('scale'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.15.2",
3
+ "version": "3.15.4",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",