dsh-mpkg-wallpaper 3.15.3 → 3.15.5

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 +245 -36
  2. package/lib/index.js +171 -13
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1455,6 +1455,41 @@ function sectionSigNow() {
1455
1455
  * 真机现场:`converted="mp4"` + `image` 缺 + `webUrl` 是上一个 web 档的残留 ⇒ 旧判据只数
1456
1456
  * `image/webUrl/sceneKey` 三兄弟里有没有"任何一个非空",`webUrl` 一非空就判"不是半残"⇒
1457
1457
  * 自愈**不介入**,而挂载又按 webUrl 走 ⇒ 黑屏且永不自愈。 */
1458
+ /* ②(2026-10-04 真机:"刷新页面偶尔壁纸加载不出来 —— 类型变成 image、名字与图标是空的、
1459
+ 预览框里是破图,重新在自定义目录里点一次『使用』又好了")
1460
+ 根因:档里**只剩 `image` 的 host token**,身份字段(mpkgKey/mpkgName)与 `converted` 却空了
1461
+ (两处存储按字段合并 + 换档时 `converted` 被清、`image` 被粘性护栏留下 ⇒ 半残档)。
1462
+ 旧判据要求"有 mpkgKey/source 线索"才算半残档 ⇒ 这条档**既不进自愈、也判不出类型**:
1463
+ 挂载走 image 分支、把**整段视频当图片**塞给 `<img>` ⇒ 破图图标 + 类型显示 image + 名字空。
1464
+ 修法:token 里本来就带着身份(mpkg 的 token 名就是包名、ltoken 就是库条目)⇒ 从它反推一个
1465
+ 临时 key 交给既有自愈链(`mpwDeriveSourceFromKey()` 会按包/目录元数据把全套源字段重算出来)。 */
1466
+ function mpwKeyFromImageToken(img) {
1467
+ try {
1468
+ const u = String(img || "");
1469
+ if (!u) return "";
1470
+ let m = /[?&]token=([^&#]+)/.exec(u);
1471
+ if (m) {
1472
+ const name = decodeURIComponent(m[1]);
1473
+ return "custommpkg|" + name + (/\.(mpkg|pkg)$/i.test(name) ? "" : ".mpkg");
1474
+ }
1475
+ m = /[?&]ltoken=([^&#]+)/.exec(u);
1476
+ if (m) return "library|" + decodeURIComponent(m[1]);
1477
+ m = /[?&]folder=([^&#]+)/.exec(u);
1478
+ if (m) return "custom|" + decodeURIComponent(m[1]);
1479
+ } catch (e) {}
1480
+ return "";
1481
+ }
1482
+ /** 档里是不是"只剩 image token、身份/类型全空"(真机那条半残档的形状)。 */
1483
+ function mpwIdentityMissing(s) {
1484
+ try {
1485
+ const img = String(mpwVal(s, "image") || "");
1486
+ if (!/[?&](token|ltoken|custom)=/.test(img)) return false;
1487
+ const name = String(mpwVal(s, "mpkgName") || "");
1488
+ const key = String(mpwVal(s, "mpkgKey") || "");
1489
+ const conv = String(mpwVal(s, "converted") || "");
1490
+ return (!name && !key) || !conv;
1491
+ } catch (e) { return false }
1492
+ }
1458
1493
  function mpwSrcMissing(s) {
1459
1494
  const src = MPW_SRC_KEYS.some((k) => typeof mpwVal(s, k) === "string" && mpwVal(s, k));
1460
1495
  const clue = (typeof mpwVal(s, "mpkgKey") === "string" && mpwVal(s, "mpkgKey")) ||
@@ -1464,6 +1499,9 @@ function sectionSigNow() {
1464
1499
  const decl = typeof mpwVal(s, "converted") === "string" ? String(mpwVal(s, "converted")) : "";
1465
1500
  const need = decl === "web" ? "webUrl" : decl === "scene" ? "sceneKey" : (decl === "mp4" || decl === "gif" || decl === "video" || decl === "image") ? "image" : null;
1466
1501
  if (need && !mpwVal(s, need)) return true;
1502
+ /* ②(2026-10-04) 第三种半残形状:来源只剩 image 的 host token,类型/身份全空
1503
+ (见 mpwIdentityMissing 的注释;真机表现为"刷新后破图 + 类型 image + 名字空")。 */
1504
+ if (mpwIdentityMissing(s) && !!(clue || src)) return true;
1467
1505
  return false;
1468
1506
  }
1469
1507
  /* ══════════════════════════════════════════════════════════════════════════════════
@@ -2504,7 +2542,9 @@ function mpwThumbFail(el, why) {
2504
2542
  try {
2505
2543
  const cur = readSection();
2506
2544
  if (!cur || !mpwSrcMissing(cur)) { mpwHealSig = ""; return false }
2507
- const key = String(cur.mpkgKey || "");
2545
+ /* ②(2026-10-04) 身份 key 空但 image 是 host token ⇒ 用 token 反推(否则自愈链直接放弃,
2546
+ 真机就是卡在这一句:`mpkgKey` 空 ⇒ 既不重挂也不自愈 ⇒ 永久破图)。 */
2547
+ const key = String(cur.mpkgKey || "") || mpwKeyFromImageToken(cur.image);
2508
2548
  if (!key) return false;
2509
2549
  const sig = key + "|" + mpwHealSigOf(cur);
2510
2550
  if (mpwHealSig === sig && MPW_HEAL.attempted > 0) return false; // 同一状态只试一次
@@ -7667,7 +7707,9 @@ function mpwThumbFail(el, why) {
7667
7707
  el.style.cssText = "position:absolute;inset:0;z-index:0;pointer-events:none;border-radius:inherit;";
7668
7708
  hdr.insertBefore(el, hdr.firstChild);
7669
7709
  }
7670
- const bf = blurPx > 0 ? ("blur(" + blurPx + "px) saturate(140%)") : "none";
7710
+ /* ②(2026-10-04) 与侧栏同一条纪律:chrome 表面只做**模糊**,不再叠 saturate(140%)。
7711
+ 壁纸层自己已经带了 contrast(1.06) saturate(1.12),再乘 1.4 就是"标题栏比屏幕别处更粉"。 */
7712
+ const bf = blurPx > 0 ? ("blur(" + blurPx + "px)") : "none";
7671
7713
  el.style.backdropFilter = bf;
7672
7714
  el.style.webkitBackdropFilter = bf;
7673
7715
  // header 需要定位上下文;已有就不覆盖(不改变宿主布局)
@@ -7775,6 +7817,11 @@ function mpwThumbFail(el, why) {
7775
7817
  透明度 100% 时还留一层模糊(用户 ④⑤⑦)。现在:**透明度 = 0(完全不透明)⇒ 什么都不注入**
7776
7818
  (层移除 + 属性撤掉,宿主自己的样子);否则底色/半径都走表面模型。 */
7777
7819
  if (planHdr.header.opacityPct >= 100) { cleanup("透明度=0(完全不透明)⇒ 标题栏交还宿主,不注入磨砂层"); return; }
7820
+ /* ②(2026-10-04 与侧栏同一条铁律) 表面**不透明度 ≤ 0**(界面透明度 100%)且不是用户
7821
+ 单独配置的档(`indep`)⇒ 我们一个像素都不画,标题栏也**不许留 backdrop-filter**:
7822
+ 否则那块壁纸被糊成平均色(用户真机读到的"一层粉色滤镜")。他自己开过标题栏磨砂
7823
+ 闸门(indep)时照旧注入 —— 那是他点名要的。 */
7824
+ if (!planHdr.header.indep && planHdr.header.opacityPct <= 0) { cleanup("透明度=100%(无雾层)⇒ 标题栏交还宿主,不注入磨砂层(我们一个像素都不画时不留滤镜)"); return; }
7778
7825
  const px = !wanted ? 0
7779
7826
  : (un ? Math.max(0, Math.round(planHdr.header.blur || 0))
7780
7827
  : (own ? pxFloor(ownAmt, 8)
@@ -11760,6 +11807,10 @@ function mpwThumbFail(el, why) {
11760
11807
  sticky: (base, next) => mpwStickySource(base, next),
11761
11808
  reconcile: (local, host) => mpwReconcileStores(local || {}, host || {}),
11762
11809
  srcMissing: (s) => mpwSrcMissing(s || readSection()),
11810
+ /* ①(2026-10-04) "只剩 image token、身份/类型全空"那条半残档的纯函数(判据用) */
11811
+ keyFromImageToken: (img) => mpwKeyFromImageToken(img),
11812
+ healState: () => { try { return Object.assign({}, MPW_HEAL) } catch (e) { return {} } },
11813
+ identityMissing: (s) => mpwIdentityMissing(s || readSection()),
11763
11814
  deriveFromKey: (key) => mpwDeriveSourceFromKey(key || readSection().mpkgKey),
11764
11815
  heal: () => { try { return mpwHealHalfArchive() } catch (e) { return String(e && e.message || e) } },
11765
11816
  healState: () => Object.assign({}, MPW_HEAL, { last: MPW_HEAL.last ? Object.assign({}, MPW_HEAL.last) : null, sig: mpwHealSig }),
@@ -13561,14 +13612,32 @@ body[data-mpw-theme] [class*="settingsArea"] [role="alertdialog"] {
13561
13612
  wide: true = `?sbfill=wide` 回退开关(全局覆盖,仅排障用) */
13562
13613
  function buildSidebarFillCss(mode, wide) {
13563
13614
  const FILL = '--dsw-specific-sidebar-fill';
13615
+ /* ②(2026-10-04 用户真机:"整屏虚化 30 + 界面透明度 65% 时,右侧边栏比标题栏/左侧边栏**更白**")
13616
+ 根因:这份覆盖只打在**左侧**白名单上 ⇒ 左栏内层的 `--dsw-specific-sidebar-fill` 被置透明、
13617
+ 整栏只由我们容器那一层半透明承担;而**右栏/dock 的内层**仍拿宿主的不透明填充色
13618
+ (#f9fafb 一族)⇒ 右栏看上去更白、像是不跟透明度滑条。
13619
+ 现在把右栏/dock 容器也纳入同一份白名单(同一个 token、同一个语义:内层透明,颜色只由
13620
+ `--mpw-surface-rs-dock` / `--mpw-unify-surface` 那一层给)——用户口径是"右栏与左栏同色同透明"。 */
13564
13621
  const NARROW = `html body .pI_x6G_sidebarCol,
13565
13622
  html body [class*="sidebarCol"]:not([class*="_tab"]),
13566
13623
  html body .hHd-Xa_root,
13567
- html body [data-slot="sidebar"]`;
13624
+ html body [data-slot="sidebar"],
13625
+ html body [data-sidebar-right-panel],
13626
+ html body [class*="P3OORG_panel"],
13627
+ html body [data-dockkit-pane],
13628
+ html body [data-dockkit-strip],
13629
+ html body [data-dockkit-surface],
13630
+ html body [data-dockkit-float]`;
13568
13631
  const NARROW_DARK = `html body[data-ds-dark-theme] .pI_x6G_sidebarCol,
13569
13632
  html body[data-ds-dark-theme] [class*="sidebarCol"]:not([class*="_tab"]),
13570
13633
  html body[data-ds-dark-theme] .hHd-Xa_root,
13571
- html body[data-ds-dark-theme] [data-slot="sidebar"]`;
13634
+ html body[data-ds-dark-theme] [data-slot="sidebar"],
13635
+ html body[data-ds-dark-theme] [data-sidebar-right-panel],
13636
+ html body[data-ds-dark-theme] [class*="P3OORG_panel"],
13637
+ html body[data-ds-dark-theme] [data-dockkit-pane],
13638
+ html body[data-ds-dark-theme] [data-dockkit-strip],
13639
+ html body[data-ds-dark-theme] [data-dockkit-surface],
13640
+ html body[data-ds-dark-theme] [data-dockkit-float]`;
13572
13641
  const sel = wide ? 'html body' : NARROW;
13573
13642
  const selDark = wide ? 'html body[data-ds-dark-theme]' : NARROW_DARK;
13574
13643
  if (mode === 'translucent') {
@@ -16536,6 +16605,23 @@ module.exports = {
16536
16605
  uAlpha = 生效**不透明度** = 100 - 「界面透明度」(0 = 不透明、100 = 全透明)。
16537
16606
  与模糊彻底解耦:模糊 0 时也照样按透明度透出(背后壁纸是清晰的)。 */
16538
16607
  const surfacePlan = mpwSurfacePlan(section);
16608
+ /* ②(2026-10-04) 标题栏半透明底 token **在唯一入口无条件写**(亮/暗都走同一枚宿主底色):
16609
+ 旧写法只在 `syncHeaderFrost()` 里、且要求"这一刻找得到 header"才写 ⇒ 一旦那次没写,
16610
+ CSS 就掉回写死的白(用户真机读数:标题栏 `rgba(255,255,255,0.38)` 完全不吃透明度滑条)。
16611
+ ⇒ 落到与左栏/右栏**同一套模型**:宿主底色 × 表面不透明度。 */
16612
+ try {
16613
+ if (enabled && hasImage) document.documentElement.style.setProperty("--mpw-hdr-frost-bg", mpwSurfaceColor(surfacePlan.header.base, surfacePlan.header.opacityPct / 100));
16614
+ else document.documentElement.style.removeProperty("--mpw-hdr-frost-bg");
16615
+ } catch (e) {}
16616
+ /* ②(2026-10-04 用户真机:"整屏虚化程度调大 ⇒ 左/右侧边栏被叠上一层粉色滤镜;
16617
+ 调到 0 就是完全透明的状态")**铁律:我们一个像素都不画时,也不给宿主叠 backdrop-filter。**
16618
+ `backdrop-filter` 不是"我们的表面",而是对宿主内容施加的滤镜 —— 完全没有雾层时它只剩失真:
16619
+ 真机档(界面透明度 100% + chatFollow 关 + 整屏虚化 40)下侧栏只剩 blur(40px)+(旧)
16620
+ saturate(140%),那块壁纸被糊成一片平均色(他的壁纸在那里正是粉的)⇒ 读成"被注入粉色滤镜"。
16621
+ 判定 = 表面不透明度 ≤ 0 **且** 该项不是用户单独配置的(`indep`:他自己开过那条磨砂闸门
16622
+ ⇒ 那是点名要的效果,照给)。 */
16623
+ const leftNoFog = surfacePlan.left.opacityPct <= 0 && !surfacePlan.left.indep;
16624
+ const rightNoFog = surfacePlan.right.opacityPct <= 0 && !surfacePlan.right.indep;
16539
16625
  /* ①(2026-10-02 用户拍板 + 真机读数) **透明度 = 0(完全不透明)⇒ 该表面完全不覆盖宿主**:
16540
16626
  不写背景色、不写 backdrop-filter、也不透明化宿主的 sidebar-fill token —— 就是"没装插件"的样子。
16541
16627
  为什么必须"不写"而不是"写一个 100% 的宿主色":真机读数证实宿主在**打开设置面板**时会把左栏
@@ -17085,8 +17171,8 @@ html body:not([data-mpw-hdr-blur-element]):not([data-mpw-hdr-frost-el]) .wSkVaW_
17085
17171
  z-index: -1 !important;
17086
17172
  pointer-events: none !important;
17087
17173
  border-radius: inherit !important;
17088
- -webkit-backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) saturate(140%) !important;
17089
- backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) saturate(140%) !important;
17174
+ -webkit-backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) !important;
17175
+ backdrop-filter: blur(var(--mpw-hdr-blur, 24px)) !important;
17090
17176
  }
17091
17177
  `;
17092
17178
  // ①(修正) 标题栏内部白框:unify(透明标题栏)时 session logo 按钮 /
@@ -17371,13 +17457,9 @@ html body[data-mpw-sblur][data-mpw-sblur-off] [class*="sidebarCol"] {
17371
17457
  const rsBg = `rgba(255,255,255,${Math.max(0, Math.min(1, rsAlpha / 100)) * 0.55})`;
17372
17458
  css += `
17373
17459
  /* ── F. 右侧边栏 / dock 虚化(独立开关+程度+透明度)── */
17374
- html body[data-sidebar-right-panel],
17375
- html body[data-sidebar-right-float-host],
17376
- html body[data-mpw-rs],
17377
- html body[data-dockkit-strip],
17378
- html body[data-dockkit-float] {
17379
- -webkit-backdrop-filter: blur(${rsAmount}px) saturate(140%);
17380
- backdrop-filter: blur(${rsAmount}px) saturate(140%);
17460
+ html body[data-mpw-rs-surface] {
17461
+ -webkit-backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17462
+ backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17381
17463
  background-color: ${rsBg} !important;
17382
17464
  }
17383
17465
  /* ④(2026-09-11 一行修复) 原为 body:not([data-mpw-rsblur]) 兜底——body 从未设置该属性 → 恒真
@@ -17399,6 +17481,9 @@ body[data-mpw-rsblur="off"] [data-dockkit-strip], body[data-mpw-rsblur="off"] [d
17399
17481
  // 它们的 containing block → 面板定位错乱、z-index 失效。展开面板出现时
17400
17482
  // JS 打 data-mpw-header-blur-off 摘除(见 setupHeaderBlurWatch)。
17401
17483
  // 弹窗打开时 body[data-mpw-sblur-off] 同样摘除(复用侧边栏磨砂机制)。
17484
+ // ②(2026-10-04) 半径也走同一个门:透明 100%(无雾)时这块写 none(= 一个滤镜都不给宿主)。
17485
+ const unifyChromePx = leftNoFog ? 0 : unifyAmount;
17486
+ const unifyChromeCss = unifyChromePx > 0 ? `blur(${unifyChromePx}px)` : "none";
17402
17487
  if (unifyAmount > 0 && bdSupported && !chromeInert) { // ③(2026-09-11 一行修复) 去掉 unifyTint/chatFollow 前置条件——默认 chatFollow=true 会让该块永不输出,导致栏/标题栏磨砂在有壁纸时也缺失(用户实测)
17403
17488
  css += `
17404
17489
  /* ── 统一虚化 + 聊天区不跟随:侧边栏/标题栏背后叠加整屏虚化度(标题栏展开面板
@@ -17417,8 +17502,8 @@ body[data-mpw-rsblur="off"] [data-dockkit-strip], body[data-mpw-rsblur="off"] [d
17417
17502
  (见上方 QsffPG_root 规则,z-index:500)→ 浮层在 header 的层叠上下文内部也能压过标题文字。
17418
17503
  ?hdrblur=pseudo 可切到"仅伪元素"方案(对照排障用)。 */
17419
17504
  html body[data-mpw-hdr-blur-element]:not([data-mpw-sblur-off]) .wSkVaW_header {
17420
- -webkit-backdrop-filter: blur(${unifyAmount}px) saturate(140%) !important;
17421
- backdrop-filter: blur(${unifyAmount}px) saturate(140%) !important;
17505
+ -webkit-backdrop-filter: ${unifyChromeCss} !important;
17506
+ backdrop-filter: ${unifyChromeCss} !important;
17422
17507
  }
17423
17508
  /* ⑥(2026-09-20) :not([data-mpw-holds-layer]):**该节点是弹层的祖先时绝不给 backdrop-filter**
17424
17509
  —— 真机实测这条规则一旦落在 pI_x6G_sidebarCol 上(设置 overlay 的后代祖先链里有它),
@@ -17426,8 +17511,8 @@ html body[data-mpw-hdr-blur-element]:not([data-mpw-sblur-off]) .wSkVaW_header {
17426
17511
  按**结构**维护(不依赖 :has()、不依赖弹层可见性/动画进度)。 */
17427
17512
  html body:not([data-mpw-sblur-off]).pI_x6G_sidebarCol:not([data-mpw-holds-layer]),
17428
17513
  html body:not([data-mpw-sblur-off])[class*="sidebarCol"]:not([data-sidebar-right-panel]):not([data-mpw-holds-layer]) {
17429
- backdrop-filter: blur(${unifyAmount}px) !important;
17430
- -webkit-backdrop-filter: blur(${unifyAmount}px) !important;
17514
+ backdrop-filter: ${unifyChromeCss} !important;
17515
+ -webkit-backdrop-filter: ${unifyChromeCss} !important;
17431
17516
  }
17432
17517
  html body:has([class*="_overlay"]), html body:has([class*="_modal"]), html body:has([class*="_portal"]) {
17433
17518
  --mpw-overlay-open: 1;
@@ -17464,10 +17549,22 @@ html body:has([class*="_modal"]) [data-mpw-rs] {
17464
17549
  · 基色 = 宿主的侧栏填充色(不再是我们的纯白 —— 白底/暗色错色都是那条引起的);
17465
17550
  · 不透明度 = 100 - 界面透明度(0 = 不透明、100 = 全透明);
17466
17551
  · 半径 = 该项独立档用自己那条,否则用统一虚化的(plan 里已算好)。 */
17467
- const chromeBlur = Math.max(0, Math.round(surfacePlan.left.blur || 0));
17468
- const chromeBlurCss = chromeBlur > 0 ? `blur(${chromeBlur}px) saturate(140%)` : "none";
17552
+ /* ②(2026-10-04 用户真机:"整屏虚化程度调大时左/右侧边栏被叠上一层**粉色滤镜**;
17553
+ 调到 0 时就是完全透明的状态")
17554
+ 铁律:**我们一个像素都不画(表面不透明度 = 0)时,也不许给宿主叠 backdrop-filter**。
17555
+ 为什么:`backdrop-filter` 不是"我们的表面",而是对**宿主内容**施加的滤镜 ——
17556
+ 完全没有雾层时它只剩下失真。用户真机档(界面透明度 100% + chatFollow 关 +
17557
+ 整屏虚化 40)下,侧栏只剩 `blur(40px) saturate(140%)`:那块壁纸被糊成一片平均色
17558
+ (他的壁纸在那里是粉的)⇒ 读成"被注入一层粉色滤镜"。透明 100% 时他明确要的是
17559
+ "看到壁纸本身",所以:**跟随档下透明 100% ⇒ 半径归 0(写 none)**;
17560
+ 用户显式开过该项自己的磨砂(`indep`)时照旧给 —— 那是他点名要的效果。
17561
+ 同时把 chrome 表面上的 `saturate(140%)` 一并去掉:壁纸层自己已经带了
17562
+ `contrast(1.06) saturate(1.12)`,再叠 1.4 倍饱和 = 侧栏比屏幕别处**明显更粉**。 */
17563
+ const chromeBlur = leftNoFog ? 0 : Math.max(0, Math.round(surfacePlan.left.blur || 0));
17564
+ const chromeBlurCss = chromeBlur > 0 ? `blur(${chromeBlur}px)` : "none";
17469
17565
  const chromeAlpha = Math.max(0, Math.min(1, surfacePlan.left.opacityPct / 100));
17470
- const rsAmt2 = Math.max(0, Math.round(surfacePlan.right.blur || 0));
17566
+ const rsAmt2 = rightNoFog ? 0 : Math.max(0, Math.round(surfacePlan.right.blur || 0));
17567
+ const rsBlurCss = rsAmt2 > 0 ? `blur(${rsAmt2}px)` : "none";
17471
17568
  const rsAlpha2 = Math.max(0, Math.min(1, surfacePlan.right.opacityPct / 100));
17472
17569
  /* ①(2026-09-18 §5 第1项) 这几枚原来是这里自建的一个 :root 块 —— 现在统一登记进
17473
17570
  SSOT(emitSurfaceTokens 输出的那个 :root),值逐字不变;原来是"本块自己的
@@ -17489,14 +17586,22 @@ html body:has([class*="_modal"]) [data-mpw-rs] {
17489
17586
  三枚 token 只在接管时被下面那块 CSS 引用;不接管时它们不生效(无引用)。
17490
17587
  厚度直接取雾模型(= 左侧边栏/标题栏透明度),0 ⇒ rgba(...,0) = 完全透明,
17491
17588
  所以"统一虚化程度/透明度都拉到 0"时右栏/dock 也是干净的透出,不留采样色。 */
17589
+ /* ②(2026-10-04 用户真机:"右侧边栏比左栏/标题栏更白")根因:右栏/dock 家族**互相嵌套**
17590
+ (面板 > dockkit 面板/标签条/表面,真机读数里两个元素的 rect 只差 1px),旧选择器把
17591
+ 两层都刷上同一枚表面色 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;
17592
+ 左栏只有一个匹配元素 ⇒ 单层 ⇒ 看起来"灰"。⇒ 只给**最外层**那个打标记(JS 结构判定,
17593
+ 与 `data-mpw-holds-layer` 同一手法),CSS 只认标记。 */
17594
+ try { mpwTagSurfaceOuter(); } catch (e) {}
17492
17595
  const fogCss = mpwFogModel(section);
17493
17596
  const unifyTakeover = unifyFollowNow && hasImage && !rightInert;
17494
- const unifyBlurCss = fogCss.amountPx > 0 ? `blur(${fogCss.amountPx}px) saturate(140%)` : "none";
17597
+ const unifyBlurCss = (!rightNoFog && fogCss.amountPx > 0) ? `blur(${fogCss.amountPx}px)` : "none";
17495
17598
  /* ⚠️ 三枚 token **只在接管时**输出:不接管时产物里连变量名都不该出现
17496
17599
  (tools/fog-model-test 的 B3/E3 判据:不跟随时 `--mpw-unify-*` 必须为 0 处),
17497
17600
  否则"有一枚没人用的 token"会被当成"接管逻辑还在"。 */
17498
17601
  if (unifyTakeover) {
17499
- tok("--mpw-unify-blur", Math.max(0, Math.round(surfacePlan.right.blur || 0)) + "px");
17602
+ /* ②(2026-10-04) 半径同样走"无雾 ⇒ 0"的生效值(与 unifyBlurCss 同源):
17603
+ 否则诊断/探针会读到"接管档 40px"而 CSS 实际写 none,两处口径不一致。 */
17604
+ tok("--mpw-unify-blur", (rightNoFog ? 0 : Math.max(0, Math.round(surfacePlan.right.blur || 0))) + "px");
17500
17605
  tok("--mpw-unify-surface", mpwSurfaceColor(surfacePlan.right.base, surfacePlan.right.opacityPct / 100));
17501
17606
  tok("--mpw-unify-surface-dark", mpwSurfaceColor(surfacePlan.right.base, surfacePlan.right.opacityPct / 100));
17502
17607
  }
@@ -17536,12 +17641,11 @@ ${chromeInert ? "" : `html body:not([data-mpw-sblur-off]) [class*="sidebarCol"]:
17536
17641
  background-color: var(--mpw-surface-side-frost) !important;
17537
17642
  }`}
17538
17643
  /* 右侧栏 / dockkit(V 形:面板 + 标签条 + 表面) */
17539
- ${rightInert ? "" : `html body[data-mpw-rsblur="on"] [data-sidebar-right-panel],
17540
- html body[data-mpw-rsblur="on"] [data-dockkit-pane],
17541
- html body[data-mpw-rsblur="on"] [data-dockkit-strip],
17542
- html body[data-mpw-rsblur="on"] [data-dockkit-surface] {
17543
- -webkit-backdrop-filter: blur(var(--mpw-rs-blur)) saturate(140%) !important;
17544
- backdrop-filter: blur(var(--mpw-rs-blur)) saturate(140%) !important;
17644
+ ${rightInert ? "" : `html body[data-mpw-rsblur="on"] [data-mpw-rs-surface],
17645
+ html body[data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-pane],
17646
+ html body[data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-strip] {
17647
+ -webkit-backdrop-filter: ${rsBlurCss} !important;
17648
+ backdrop-filter: ${rsBlurCss} !important;
17545
17649
  background-color: var(--mpw-surface-rs-dock) !important;
17546
17650
  }`}
17547
17651
  /* 抑制:弹窗/设置面板打开时(backdrop-filter 会成为 fixed 弹窗的 containing block → 必须摘除) */
@@ -17583,10 +17687,10 @@ html body[data-dragging] [data-dockkit-pane] {
17583
17687
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) header[class*="_header_"],
17584
17688
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) header.wSkVaW_header,
17585
17689
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) [class*="sidebarCol"]:not([data-sidebar-right-panel]):not([data-dockkit-strip]),
17586
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-sidebar-right-panel],
17587
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-pane],
17588
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-strip],
17589
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-surface] {
17690
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface],
17691
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-pane],
17692
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-strip],
17693
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-surface] {
17590
17694
  background-color: var(--mpw-surface-rs-dock-dark) !important;
17591
17695
  color: var(--dsw-alias-label-primary, var(--dsw-alias-text-primary, inherit));
17592
17696
  }
@@ -17784,8 +17888,8 @@ html body [data-rightbar-fullscreen] [data-sidebar-right-panel],
17784
17888
  html body .pI_x6G_frame[data-rightbar-fullscreen] [class*="P3OORG_panel"],
17785
17889
  html body [data-rightbar-fullscreen] [class*="P3OORG_panel"] {
17786
17890
  z-index: 40 !important;
17787
- -webkit-backdrop-filter: blur(var(--mpw-rs-blur, 14px)) saturate(140%) !important;
17788
- backdrop-filter: blur(var(--mpw-rs-blur, 14px)) saturate(140%) !important;
17891
+ -webkit-backdrop-filter: ${rsBlurCss} !important;
17892
+ backdrop-filter: ${rsBlurCss} !important;
17789
17893
  background-color: var(--mpw-surface-rs-full) !important;
17790
17894
  }
17791
17895
  /* 全屏时把聊天输入框区域压到右栏之下(即便它有自己的 stacking context 也不会盖住卡片圆角) */
@@ -18742,11 +18846,16 @@ svg.mpw_navIconImg { background: none; border: 0; border-radius: 0; color: inher
18742
18846
  描边一律交还宿主(下方 hover/悬浮块里的 border-bottom-color 兜底保持不变)。 */
18743
18847
  html body .wSkVaW_header[data-mpw-hdr-translucent],
18744
18848
  html body header[class*="_header_"][data-mpw-hdr-translucent] {
18745
- background-color: var(--mpw-hdr-frost-bg, rgba(255, 255, 255, 0.38)) !important;
18849
+ /* ②(2026-10-04 真机:"标题栏是白色底,左右栏却跟滑条走")不许有写死的白回退:
18850
+ --mpw-hdr-frost-bg 一旦没写到(历史路径/时序竞态),旧写法就掉回 rgba(255,255,255,.38)
18851
+ ⇒ 标题栏看起来永远是白的、与「界面透明度」无关。回退到同一枚表面 token
18852
+ (= 宿主底色 × 表面不透明度,与左栏/右栏同一套模型)⇒ 三块构造逐字一致。
18853
+ 注意:CSS 模板里的注释不许出现反引号(会截断模板串,本仓踩过三次)。 */
18854
+ background-color: var(--mpw-hdr-frost-bg, var(--mpw-surface-frost-top)) !important;
18746
18855
  }
18747
18856
  html body[data-ds-dark-theme] .wSkVaW_header[data-mpw-hdr-translucent],
18748
18857
  html body[data-ds-dark-theme] header[class*="_header_"][data-mpw-hdr-translucent] {
18749
- background-color: var(--mpw-hdr-frost-bg, rgba(18, 22, 30, 0.45)) !important;
18858
+ background-color: var(--mpw-hdr-frost-bg, var(--mpw-surface-frost-top)) !important;
18750
18859
  }
18751
18860
  /* ②(2026-09-16 视觉回归修复) 标题栏磨砂"元素层"(.mpw-hdrFrost,由 JS 注入)的**层叠与托底**规则。
18752
18861
  为什么必须有这三条(真根因链):
@@ -24661,6 +24770,101 @@ body[data-mpw-modal] [role="dialog"] {
24661
24770
  } catch (e) {}
24662
24771
  }
24663
24772
  }
24773
+ /* ②(2026-10-04 真机:"右侧边栏比左栏/标题栏更白")**嵌套表面只画最外层**:
24774
+ 右栏/dock 家族是互相嵌套的(真机读数:两个命中元素的 rect 只差 1px),把同一枚半透明表面色
24775
+ 刷在两层上 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;左栏只有一个匹配元素,
24776
+ 于是"左栏灰、右栏白"。这里按**结构**(有没有同样命中的祖先)给最外层打
24777
+ `data-mpw-rs-surface`,CSS 只认这个标记(与 `data-mpw-holds-layer` 同一手法)。
24778
+ 幂等、无布局读取(只用 matches/parentElement),可随突变跑。 */
24779
+ const MPW_RS_FAMILY = '[data-sidebar-right-panel], [data-dockkit-pane], [data-dockkit-strip], [data-dockkit-surface], [data-dockkit-float], [data-sidebar-right-float-host], [class*="P3OORG_panel"]';
24780
+ function mpwTagSurfaceOuter() {
24781
+ try {
24782
+ const all = Array.from(document.querySelectorAll(MPW_RS_FAMILY));
24783
+ if (!all.length) return 0;
24784
+ const set = new Set(all);
24785
+ const isDock = (el) => { try { return el.matches('[data-dockkit-pane], [data-dockkit-strip], [data-dockkit-surface], [data-dockkit-float]') } catch (e) { return false } };
24786
+ const hasMatchedAncestor = (el) => {
24787
+ for (let p = el.parentElement, i = 0; p && i < 40; p = p.parentElement, i++) { if (set.has(p)) return true }
24788
+ return false;
24789
+ };
24790
+ /* 同一片视觉区域**只许留一个**被刷表面的元素。优先级(真机结构):
24791
+ ① 命中的 DSH 右栏面板(`[data-sidebar-right-panel]` / `[class*="P3OORG_panel"]`)——它是用户看到的那块;
24792
+ ② 没有面板时,dock 家族里"没有命中祖先"的那些(better-sidebar 独立 dock 布局);
24793
+ ③ 兜底:全部"没有命中祖先"的(老布局)。
24794
+ 为什么不能只按"有没有命中祖先":真机里右栏面板与它的内层 `_surface_*` **同时带**
24795
+ `data-sidebar-right-panel` / `data-dockkit-surface`,两层 55% 叠起来 ≈ 80% ⇒ 用户看到的"更白"。 */
24796
+ const outermost = all.filter((el) => !hasMatchedAncestor(el));
24797
+ const panelsOuter = outermost.filter((el) => { try { return el.matches('[data-sidebar-right-panel], [class*="P3OORG_panel"]') } catch (e) { return false } });
24798
+ let want = panelsOuter.length ? panelsOuter : outermost.filter((el) => !isDock(el));
24799
+ if (!want.length) want = outermost;
24800
+ const wantSet = new Set(want);
24801
+ let changed = 0;
24802
+ for (const el of all) {
24803
+ const should = wantSet.has(el);
24804
+ const has = el.hasAttribute("data-mpw-rs-surface");
24805
+ if (should && !has) { try { el.setAttribute("data-mpw-rs-surface", ""); changed++ } catch {} }
24806
+ else if (!should && has) { try { el.removeAttribute("data-mpw-rs-surface"); changed++ } catch {} }
24807
+ }
24808
+ return changed;
24809
+ } catch (e) { return 0 }
24810
+ }
24811
+
24812
+ function mpwTagPopoverBg() {
24813
+ let list = null;
24814
+ try { list = document.querySelectorAll(MPW_POP_TAG_SEL); } catch (e) { return; }
24815
+ if (!list) return;
24816
+ for (let i = 0; i < list.length; i++) {
24817
+ const el = list[i];
24818
+ let need = false;
24819
+ try {
24820
+ const cls = (typeof el.className === "string") ? el.className : "";
24821
+ if (!MPW_POP_TAG_NOT.test(cls)) {
24822
+ const cs = getComputedStyle(el);
24823
+ if (cs && cs.display !== "none" && cs.visibility !== "hidden"
24824
+ && /blur\(/.test(String(cs.backdropFilter || cs.webkitBackdropFilter || ""))) {
24825
+ const a = mpwColorAlpha(cs.backgroundColor);
24826
+ if (a < 0.05) need = !mpwPopHasPaintedAncestor(el);
24827
+ else if (a >= 0.999) need = mpwPopIsFloating(el);
24828
+ }
24829
+ }
24830
+ } catch (e) { need = false; }
24831
+ try {
24832
+ if (need) { if (!el.hasAttribute("data-mpw-pop-bg")) el.setAttribute("data-mpw-pop-bg", ""); }
24833
+ else if (el.hasAttribute("data-mpw-pop-bg")) el.removeAttribute("data-mpw-pop-bg");
24834
+ } catch (e) {}
24835
+ }
24836
+ }
24837
+ /* ②(2026-10-04 真机:"右侧边栏比左栏/标题栏更白")**嵌套表面只画最外层**:
24838
+ 右栏/dock 家族是互相嵌套的(真机读数:两个命中元素的 rect 只差 1px),把同一枚半透明表面色
24839
+ 刷在两层上 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;左栏只有一个匹配元素,
24840
+ 于是"左栏灰、右栏白"。这里按**结构**(有没有同样命中的祖先)给最外层打
24841
+ `data-mpw-rs-surface`,CSS 只认这个标记(与 `data-mpw-holds-layer` 同一手法)。
24842
+ 幂等、无布局读取(只用 matches/parentElement),可随突变跑。 */
24843
+ function mpwTagSurfaceOuter() {
24844
+ try {
24845
+ const all = Array.from(document.querySelectorAll(MPW_RS_FAMILY));
24846
+ if (!all.length) return 0;
24847
+ const set = new Set(all);
24848
+ /* 真机读数:DSH 右栏面板与 dockkit 面板/标签条**互相重叠**(rect 只差 1~2px)却不在同一条
24849
+ 祖先链上 ⇒ 只按"有没有命中祖先"判不出重叠,两层 55% 会合成 ~80%(用户看到的"更白")。
24850
+ 判据:DSH 自己的右栏面板在场时,dock 子件一律不刷(它们显示的就是那块面板的表面);
24851
+ 只有"没有右栏面板、只有 dock"的布局(better-sidebar 底部工作台等)才由 dock 自己承担。 */
24852
+ const panel = document.querySelector('[data-sidebar-right-panel]');
24853
+ const isDock = (el) => { try { return el.matches('[data-dockkit-pane], [data-dockkit-strip], [data-dockkit-surface], [data-dockkit-float]') } catch (e) { return false } };
24854
+ let changed = 0;
24855
+ for (const el of all) {
24856
+ let inner = false;
24857
+ for (let p = el.parentElement, i = 0; p && i < 40; p = p.parentElement, i++) { if (set.has(p)) { inner = true; break } }
24858
+ const coveredByPanel = !!(panel && panel !== el && isDock(el));
24859
+ const want = !inner && !coveredByPanel;
24860
+ const has = el.hasAttribute("data-mpw-rs-surface");
24861
+ if (want && !has) { try { el.setAttribute("data-mpw-rs-surface", ""); changed++ } catch {} }
24862
+ else if (!want && has) { try { el.removeAttribute("data-mpw-rs-surface"); changed++ } catch {} }
24863
+ }
24864
+ return changed;
24865
+ } catch (e) { return 0 }
24866
+ }
24867
+
24664
24868
  function setupPopTagWatch() {
24665
24869
  if (popTagWatch !== null) return;
24666
24870
  let praf = 0;
@@ -24671,6 +24875,11 @@ body[data-mpw-modal] [role="dialog"] {
24671
24875
  if (document.body && document.body.hasAttribute
24672
24876
  && (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
24673
24877
  mpwTagPopoverBg();
24878
+ /* ②(2026-10-04) 右栏/dock 的"只留最外层"标记必须**跟着 DOM 走**:右栏面板是
24879
+ 用户打开后才挂载的,apply 那一刻它还不存在 ⇒ 只按一次会把 dock 错标成表面,
24880
+ 之后面板出现就变成两层(真机读数:dockPane/dockStrip 仍是 0.55)。
24881
+ 这里与弹层标记共用同一个 rAF 合并的入口(要素很少,代价可忽略)。 */
24882
+ try { mpwTagSurfaceOuter(); } catch (e) {}
24674
24883
  } catch (e) {}
24675
24884
  };
24676
24885
  try {
package/lib/index.js CHANGED
@@ -791,6 +791,12 @@ const FFMPEG_STATIC_SHA256 = {
791
791
  'ffmpeg-darwin-x64': 'cfe20936c83ecf5d68e424b87e8cc45b24dd6be81787810123bb964a0df686f9',
792
792
  'ffmpeg-darwin-arm64': 'a90e3db6a3fd35f6074b013f948b1aa45b31c6375489d39e572bea3f18336584',
793
793
  };
794
+ // ①(F5 2026-10-03) 同源 ffmpeg-static b6.0 的 **ffprobe-\*** 资产(每平台一个,与 ffmpeg 资产同 tag 同命名规则)。
795
+ // 逐平台**核实一个钉一个**(下载实测 sha256 后填入);未钉平台 = ELF/PE/Mach-O 魔数 + 长度下限 +
796
+ // 双镜像同源 + 下载时计算的 sha256(记进结果与台账)兜底。已核实:linux-arm64 49,946,640 B(本机实测可执行)。
797
+ const FFPROBE_STATIC_SHA256 = {
798
+ 'ffprobe-linux-arm64': '9c741c0dedaf503106732d819d7f9ecf1d991ca923cc688d3d52aa4d6d51222d',
799
+ };
794
800
  // 单个转码任务的硬超时(覆盖编码全部尝试),可用 DSH_WE_TRANSCODE_TIMEOUT_MS 覆盖。
795
801
  const TRANSCODE_TIMEOUT_MS = Number(process.env.DSH_WE_TRANSCODE_TIMEOUT_MS) || 15 * 60 * 1000;
796
802
  const ALLOWED_FPS = [24, 30, 48, 60];
@@ -1221,7 +1227,72 @@ function probeVideoInfo(filePath) {
1221
1227
  pixFmt: video.pix_fmt, colorRange: video.color_range, colorSpace: video.color_space,
1222
1228
  colorPrimaries: video.color_primaries, colorTransfer: video.color_transfer,
1223
1229
  }, headBytes(filePath, 64));
1224
- return (info.width > 0 && info.fps > 0) ? info : null;
1230
+ if (info.width > 0 && info.fps > 0) { info.probeSource = 'ffprobe'; return info; }
1231
+ return null;
1232
+ } catch { return null; }
1233
+ }
1234
+
1235
+ /** ①(F5 次选) `ffmpeg -hide_banner -i <file>` 的 stderr 解析(**纯函数**,测试可直接喂真实 stderr)。
1236
+ * 背景:ffprobe 缺席的机器(自带下载只装 ffmpeg 本体、系统只装了 ffmpeg)上可播性闸门整体失效
1237
+ * (probeVideoInfo → null ⇒ playable=null ⇒ 永远走"能转就转")。ffmpeg 对 `-i` 无输出文件会
1238
+ * 立即退出 1 并把流信息打到 stderr —— 不解码、毫秒级。解析自真实输出形状:
1239
+ * Input #0, matroska,webm, from 'x.mkv':
1240
+ * Duration: 00:01:30.00, start: 0.000000, bitrate: 1000 kb/s
1241
+ * Stream #0:0 ... : Video: h264 (High), yuv420p(progressive), 3840x2160 [SAR 1:1 DAR 16:9], 60 fps, 60 tbr
1242
+ * Stream #0:1 ... : Audio: opus, 48000 Hz, stereo, fltp
1243
+ * 色彩元数据(color_range/space/primaries/transfer)这条路径拿不到 ⇒ 留空(= 不知道,不猜)。
1244
+ * 返回 buildProbeInfo 的 meta 形状;解析不出视频流 ⇒ { video:'' }(调用方按失败处理)。 */
1245
+ function parseFfmpegStderrMeta(text) {
1246
+ const s = String(text || '');
1247
+ const meta = { container: '', duration: 0, size: 0, video: '', videoProfile: '', audio: '', audioProfile: '', width: 0, height: 0, rFrameRate: '', avgFrameRate: '', pixFmt: '', colorRange: '', colorSpace: '', colorPrimaries: '', colorTransfer: '' };
1248
+ const inC = /Input #\d+,\s*([^,\n]+(?:,[^,\n]+)*?),\s*from/.exec(s);
1249
+ if (inC) meta.container = inC[1].trim();
1250
+ const dur = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(s);
1251
+ if (dur) meta.duration = (Number(dur[1]) * 3600 + Number(dur[2]) * 60 + Number(dur[3])) || 0;
1252
+ const vSeg = /: Video: ([^\n]+)/.exec(s);
1253
+ if (vSeg) {
1254
+ const line = vSeg[1];
1255
+ const head = /^\s*([a-z0-9_]+)(?:\s*\(([^)]*)\))?/i.exec(line);
1256
+ if (head) {
1257
+ meta.video = head[1];
1258
+ // `(High)` 是 profile;`(built-in)`/`(unspecified)` 这类不是
1259
+ const prof = head[2] && !/built-in|unspecified|none/i.test(head[2]) ? head[2] : '';
1260
+ if (prof) meta.videoProfile = prof;
1261
+ }
1262
+ const pix = /\b(yu[via][a-z0-9_]*|nv1[26]|nv24|p01[016]|gbrp[a-z0-9_]*|gray[0-9a-z_]*|rgb[a-z0-9_]*)(?:le|be)?\b/i.exec(line);
1263
+ if (pix) meta.pixFmt = pix[1];
1264
+ const dim = /\b(\d{2,5})x(\d{1,5})\b/.exec(line);
1265
+ if (dim) { meta.width = Number(dim[1]) || 0; meta.height = Number(dim[2]) || 0; }
1266
+ const fps = /([\d.]+)\s*fps/.exec(line) || /([\d.]+)\s*tbr/.exec(line);
1267
+ if (fps) { meta.rFrameRate = fps[1] + '/1'; meta.avgFrameRate = fps[1] + '/1'; }
1268
+ }
1269
+ const aSeg = /: Audio: ([^\n]+)/.exec(s);
1270
+ if (aSeg) {
1271
+ const head = /^\s*([a-z0-9_]+)(?:\s*\(([^)]*)\))?/i.exec(aSeg[1]);
1272
+ if (head) { meta.audio = head[1]; if (head[2]) meta.audioProfile = head[2]; }
1273
+ }
1274
+ return meta;
1275
+ }
1276
+ /** ①(F5 次选) ffprobe 探测失败/缺席时用 `ffmpeg -hide_banner -i` 兜底探测(失败返回 null,不抛错)。
1277
+ * 成功的 info 带 `probeSource:'ffmpeg-i'`(ffprobe 路径成功 = `'ffprobe'`)——台账字段,
1278
+ * 客户端/判据不读它、只用于诊断上报区分"这个答案是哪条探测路给的"。 */
1279
+ function probeVideoInfoFfmpegI(filePath) {
1280
+ try {
1281
+ const ff = (resolveFfmpeg() || {}).path || 'ffmpeg';
1282
+ let err = '';
1283
+ try {
1284
+ execFileSync(ff, ['-hide_banner', '-i', filePath], { encoding: 'utf8', timeout: 15000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
1285
+ return null; // 有输出文件才不会走到这;真走到了说明行为和预期不符,不猜
1286
+ } catch (e) {
1287
+ err = String((e && (e.stderr || e.stdout)) || '');
1288
+ if (!err) return null;
1289
+ }
1290
+ const meta = parseFfmpegStderrMeta(err);
1291
+ if (!meta.video) return null;
1292
+ const info = buildProbeInfo(meta, headBytes(filePath, 64));
1293
+ if (!(info.width > 0 && info.fps > 0)) return null;
1294
+ info.probeSource = 'ffmpeg-i';
1295
+ return info;
1225
1296
  } catch { return null; }
1226
1297
  }
1227
1298
 
@@ -1243,10 +1314,15 @@ function getVideoProbe(src) {
1243
1314
  let info = null;
1244
1315
  if (src.type === 'file') {
1245
1316
  info = probeVideoInfo(srcPath);
1317
+ // ①(F5) ffprobe 缺席/失败的兜底:ffmpeg -i 的 stderr 解析(playable=null ⇒ 闸门失效的机器上补一条活路)
1318
+ if (!info) info = probeVideoInfoFfmpegI(srcPath);
1246
1319
  } else {
1247
1320
  try {
1248
1321
  const mat = materializeSource(src, transcodeCacheDir());
1249
- try { info = probeVideoInfo(mat.file); } finally { if (mat.cleanup) mat.cleanup(); }
1322
+ try {
1323
+ info = probeVideoInfo(mat.file);
1324
+ if (!info) info = probeVideoInfoFfmpegI(mat.file);
1325
+ } finally { if (mat.cleanup) mat.cleanup(); }
1250
1326
  } catch { info = null; }
1251
1327
  }
1252
1328
  if (TRANSCODE_PROBE_CACHE.size >= TRANSCODE_PROBE_CACHE_MAX) TRANSCODE_PROBE_CACHE.clear();
@@ -1263,6 +1339,15 @@ function ffmpegDataDir() {
1263
1339
  function ffmpegExeName() {
1264
1340
  return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
1265
1341
  }
1342
+ /** ①(F5) ffprobe 的落盘文件名与静态资产名(与 ffmpeg 同源同版本:ffmpeg-static b6.0 每平台
1343
+ * 除 ffmpeg-\* 外还发布 ffprobe-\*)。没有 ⇒ 下载链跳过 ffprobe(不阻塞 ffmpeg 本体)。 */
1344
+ function ffprobeExeName() {
1345
+ return process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe';
1346
+ }
1347
+ function ffprobeAssetName() {
1348
+ const m = ffmpegAssetName();
1349
+ return m ? m.replace(/^ffmpeg-/, 'ffprobe-') : null;
1350
+ }
1266
1351
  function transcodeCacheDir() {
1267
1352
  const dir = join(DATA_DIR, 'transcodes');
1268
1353
  try { mkdirSync(dir, { recursive: true }); } catch { /* 忽略 */ }
@@ -1808,9 +1893,10 @@ function resolveFfmpegSource() {
1808
1893
  return null;
1809
1894
  }
1810
1895
 
1811
- /** 流式下载单个 URL 到 .part 文件:校验 大小>20MB + 魔数(MZ/ELF/Mach-O) + sha256。
1812
- * 返回 { total, sha256 };任一校验失败抛错。 */
1813
- async function downloadFfmpegToFile(url, tmp, signal) {
1896
+ /** 流式下载单个 URL 到 .part 文件:校验 大小>minBytes(默认20MB) + 魔数(MZ/ELF/Mach-O) + sha256。
1897
+ * 返回 { total, sha256 };任一校验失败抛错。①(F5) ffprobe 比 ffmpeg 小 ⇒ minBytes 可传小一些。 */
1898
+ async function downloadFfmpegToFile(url, tmp, signal, opts) {
1899
+ const minBytes = (opts && opts.minBytes) || 20 * 1024 * 1024;
1814
1900
  const res = await fetch(url, { redirect: 'follow', signal, headers: { 'User-Agent': 'dsh-mpkg-wallpaper' } });
1815
1901
  if (!res.ok) throw new Error('HTTP ' + res.status + ' @ ' + url);
1816
1902
  if (!res.body) throw new Error('no response body @ ' + url);
@@ -1835,7 +1921,7 @@ async function downloadFfmpegToFile(url, tmp, signal) {
1835
1921
  } finally {
1836
1922
  closeSync(fd);
1837
1923
  }
1838
- if (total < 20 * 1024 * 1024) throw new Error('implausible size ' + total + ' @ ' + url);
1924
+ if (total < minBytes) throw new Error('implausible size ' + total + ' @ ' + url);
1839
1925
  const head = Buffer.alloc(8);
1840
1926
  try {
1841
1927
  const rfd = openSync(tmp, 'r');
@@ -1849,10 +1935,17 @@ async function downloadFfmpegToFile(url, tmp, signal) {
1849
1935
  }
1850
1936
 
1851
1937
  /** 下载当前平台的 ffmpeg-static 单文件到 DATA_DIR/ffmpeg/ffmpeg[.exe](用户显式触发)。
1852
- * 单飞防并发;校验通过后原子 rename。成功返回 { path, size };失败抛错。 */
1938
+ * 单飞防并发;校验通过后原子 rename。成功返回 { path, size };失败抛错。
1939
+ * ①(F5) 附带 **ffprobe**:同源同版本同平台(ffmpeg-static 同 tag 的 ffprobe-\* 资产),
1940
+ * sha256(钉值命中则强校验)+ 长度下限 + 魔数逐条校验后落 `DATA_DIR/ffmpeg/ffprobe[.exe]`。
1941
+ * **ffprobe 失败不影响 ffmpeg 本体**(只记 lastFfprobeDownloadError;resolveFfprobe 的
1942
+ * sibling 目录会因这次落盘开始命中 ⇒ 可播性闸门在这些机器上真的生效)。 */
1853
1943
  async function downloadFfmpeg() {
1854
1944
  const target = join(ffmpegDataDir(), ffmpegExeName());
1855
- if (existsSync(target)) return { path: target, size: statSync(target).size };
1945
+ if (existsSync(target)) {
1946
+ const fp = await downloadFfprobe().catch((e) => ({ ok: false, error: String(e && e.message || e) }));
1947
+ return { path: target, size: statSync(target).size, ffprobe: fp };
1948
+ }
1856
1949
  const asset = ffmpegAssetName();
1857
1950
  if (!asset) throw new Error('unsupported platform ' + process.platform + '/' + process.arch);
1858
1951
  if (typeof fetch !== 'function') throw new Error('fetch unavailable (Node < 18?)');
@@ -1873,7 +1966,10 @@ async function downloadFfmpeg() {
1873
1966
  renameSync(tmp, target); // 原子写入:校验全过才就位
1874
1967
  lastFfmpegDownloadError = null;
1875
1968
  ffmpegDownloadProgress = null;
1876
- return { path: target, size: r.total };
1969
+ // ①(F5) ffmpeg 就位后顺手补 ffprobe(失败不影响本次下载的成功语义)
1970
+ let fp = null;
1971
+ try { fp = await downloadFfprobe(); } catch (e) { fp = { ok: false, error: String(e && e.message || e) }; }
1972
+ return { path: target, size: r.total, ffprobe: fp };
1877
1973
  } catch (err) {
1878
1974
  lastErr = err;
1879
1975
  try { unlinkSync(tmp); } catch { /* 忽略 */ }
@@ -1888,6 +1984,49 @@ async function downloadFfmpeg() {
1888
1984
  return ffmpegDownloadPromise;
1889
1985
  }
1890
1986
 
1987
+ let ffprobeDownloadPromise = null; // ①(F5) ffprobe 下载单飞(与 ffmpeg 分开,互不阻塞)
1988
+ let lastFfprobeDownloadError = null;
1989
+ /** ①(F5) 下载当前平台的 ffprobe 静态二进制到 DATA_DIR/ffmpeg/ffprobe[.exe]。
1990
+ * 校验:魔数(MZ/ELF/Mach-O) + 长度下限(20MB,arm64 实测 49.9MB) + sha256(钉值命中则强校验,
1991
+ * 否则把算得的 sha256 记进返回值/台账)。成功后重置 resolveFfprobe 的进程内记忆化
1992
+ * (此前 resolve 过的 null 不该盖住刚落盘的二进制;desktop-compat C2 的"两次调用同对象"语义不变)。
1993
+ * 返回 { ok:true, path, size, sha256, skipped? };失败抛错(调用方决定要不要兜)。 */
1994
+ async function downloadFfprobe() {
1995
+ const target = join(ffmpegDataDir(), ffprobeExeName());
1996
+ if (existsSync(target)) return { ok: true, path: target, size: statSync(target).size, skipped: 'already-present' };
1997
+ const asset = ffprobeAssetName();
1998
+ if (!asset) throw new Error('unsupported platform ' + process.platform + '/' + process.arch + ' (ffprobe)');
1999
+ if (typeof fetch !== 'function') throw new Error('fetch unavailable (Node < 18?)');
2000
+ if (ffprobeDownloadPromise) return ffprobeDownloadPromise;
2001
+ ffprobeDownloadPromise = (async () => {
2002
+ const urls = ffmpegDownloadUrls(asset);
2003
+ let lastErr = null;
2004
+ for (const url of urls) {
2005
+ const tmp = target + '.part' + Date.now();
2006
+ const ctrl = new AbortController();
2007
+ const timer = setTimeout(() => ctrl.abort(), 5 * 60 * 1000);
2008
+ try {
2009
+ const r = await downloadFfmpegToFile(url, tmp, ctrl.signal, { minBytes: 20 * 1024 * 1024 });
2010
+ const want = FFPROBE_STATIC_SHA256[asset];
2011
+ if (want && r.sha256 !== want) throw new Error('sha256 mismatch (want ' + want + ') @ ' + url);
2012
+ if (process.platform !== 'win32') { try { chmodSync(tmp, 0o755); } catch { /* 忽略 */ } }
2013
+ renameSync(tmp, target);
2014
+ lastFfprobeDownloadError = null;
2015
+ __ffprobeResolved = void 0; // 记忆化失效:刚落盘的二进制要能被 resolveFfprobe 找到
2016
+ return { ok: true, path: target, size: r.total, sha256: r.sha256, pinned: !!want };
2017
+ } catch (err) {
2018
+ lastErr = err;
2019
+ try { unlinkSync(tmp); } catch { /* 忽略 */ }
2020
+ } finally {
2021
+ clearTimeout(timer);
2022
+ }
2023
+ }
2024
+ lastFfprobeDownloadError = String(lastErr && lastErr.message || lastErr);
2025
+ throw new Error('ffprobe download failed: ' + lastFfprobeDownloadError);
2026
+ })().finally(() => { ffprobeDownloadPromise = null; });
2027
+ return ffprobeDownloadPromise;
2028
+ }
2029
+
1891
2030
  /** 在 mpkg 容器里挑视频条目:真视频文件(mp4/webm/mov)优先,其次含 ftyp 的视频纹理 tex。 */
1892
2031
  function pickMpkgVideoEntry(entries, filePath, dataStart) {
1893
2032
  const vid = entries.findIndex((e) => /\.(mp4|webm|mov)$/i.test(e.name));
@@ -4756,9 +4895,15 @@ function apply(ctx) {
4756
4895
  try {
4757
4896
  // ①(修正) 区分来源:env 显式 / 系统 PATH / 缓存静态。客户端据此显示「系统已装/缓存已装/未装」。
4758
4897
  const src = resolveFfmpegSource();
4759
- json(res, 200, { ok: true, found: !!src, source: src ? src.source : null, path: src ? src.path : null, version: src ? src.version : null });
4898
+ // ①(F5) ffprobe 的在场读数(可播性闸门"真的生效"的前提;不改变任何既有字段)
4899
+ const fp = (() => { try { return resolveFfprobe() } catch { return null } })();
4900
+ const fpVersion = fp ? (() => { const v = ffmpegVersionOf(fp.path); return v ? v.version : null })() : null;
4901
+ json(res, 200, {
4902
+ ok: true, found: !!src, source: src ? src.source : null, path: src ? src.path : null, version: src ? src.version : null,
4903
+ ffprobe: { found: !!fp, path: fp ? fp.path : null, version: fpVersion },
4904
+ });
4760
4905
  } catch (err) {
4761
- json(res, 200, { ok: false, found: false, source: null, path: null, version: null, error: String(err && err.message || err) });
4906
+ json(res, 200, { ok: false, found: false, source: null, path: null, version: null, ffprobe: { found: false, path: null, version: null }, error: String(err && err.message || err) });
4762
4907
  }
4763
4908
  },
4764
4909
  });
@@ -4774,8 +4919,14 @@ function apply(ctx) {
4774
4919
  if (existsSync(cached)) {
4775
4920
  try { unlinkSync(cached); } catch (e) { json(res, 500, { ok: false, error: String(e && e.message || e) }); return; }
4776
4921
  }
4922
+ // ①(F5) 同目录的 ffprobe 缓存一并卸(下载链成对装 ⇒ 卸载也成对;系统 PATH/env 的不动)
4923
+ const cachedFp = join(ffmpegDataDir(), ffprobeExeName());
4924
+ let removedFp = null;
4925
+ if (existsSync(cachedFp)) {
4926
+ try { unlinkSync(cachedFp); removedFp = cachedFp; __ffprobeResolved = void 0; } catch { /* 不阻塞主二进制的卸载 */ }
4927
+ }
4777
4928
  // 顺便清掉同目录残留(.part 下载碎片不在这里;仅清主二进制)
4778
- json(res, 200, { ok: true, removed: cached });
4929
+ json(res, 200, { ok: true, removed: cached, removedFfprobe: removedFp });
4779
4930
  } catch (err) {
4780
4931
  json(res, 500, { ok: false, error: String(err && err.message || err) });
4781
4932
  }
@@ -4790,7 +4941,8 @@ function apply(ctx) {
4790
4941
  try {
4791
4942
  if ((req.method || 'GET').toUpperCase() !== 'POST') { json(res, 405, { ok: false, error: 'method' }); return; }
4792
4943
  const r = await downloadFfmpeg();
4793
- json(res, 200, { ok: true, path: r.path, size: r.size });
4944
+ // ①(F5) 响应附带 ffprobe 侧下载结果({ok,path,size,sha256?} 或 {ok:false,error});既有字段不变
4945
+ json(res, 200, { ok: true, path: r.path, size: r.size, ffprobe: r.ffprobe || null });
4794
4946
  } catch (err) {
4795
4947
  json(res, 500, { ok: false, error: String(err && err.message || err) });
4796
4948
  }
@@ -5088,6 +5240,12 @@ export const __mpwTest = {
5088
5240
  // ①(2026-10-02 桌面端兼容) ffprobe 探测链的测试出口(tools/desktop-compat-test.mjs 直接调真实现):
5089
5241
  // env 分支只依赖 existsSync+isFile(不 spawn)⇒ 测试可用临时文件断言「env 覆盖生效」。
5090
5242
  resolveFfprobe, resolveFfmpeg,
5243
+ // ①(F5 2026-10-03) ffprobe 资产/次选探测的测试出口(tools/ffprobe-provision-test.mjs 直接调真实现):
5244
+ // 下载走桩 HTTP 端点(DSH_WE_FFMPEG_URL 同款 env 覆盖)⇒ 断言落盘/校验失败不落盘;
5245
+ // 次选探测喂真实 ffmpeg -i 的 stderr 形状;resetFfprobeMemo 给"下载后记忆化失效"做行为断言。
5246
+ downloadFfprobe, downloadFfmpeg, ffprobeAssetName, ffprobeExeName, ffmpegDataDir,
5247
+ parseFfmpegStderrMeta, probeVideoInfoFfmpegI,
5248
+ resetFfprobeMemo: () => { __ffprobeResolved = void 0; },
5091
5249
  // ①(WP-1) 网页壁纸轮(存储 facade / 宿主音量 / CSP / 源级 URL 改写)的纯函数出口:
5092
5250
  // `tools/web-wallpaper-test.mjs` 的 H/I/J 段直接调它们断言语义,K 段再走真路由端到端。
5093
5251
  // **只读/纯函数**,不改变插件在宿主里的注册面(宿主只用 `apply`)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.15.3",
3
+ "version": "3.15.5",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",