dsh-mpkg-wallpaper 3.15.4 → 3.15.6

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.
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; // 同一状态只试一次
@@ -11767,6 +11807,10 @@ function mpwThumbFail(el, why) {
11767
11807
  sticky: (base, next) => mpwStickySource(base, next),
11768
11808
  reconcile: (local, host) => mpwReconcileStores(local || {}, host || {}),
11769
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()),
11770
11814
  deriveFromKey: (key) => mpwDeriveSourceFromKey(key || readSection().mpkgKey),
11771
11815
  heal: () => { try { return mpwHealHalfArchive() } catch (e) { return String(e && e.message || e) } },
11772
11816
  healState: () => Object.assign({}, MPW_HEAL, { last: MPW_HEAL.last ? Object.assign({}, MPW_HEAL.last) : null, sig: mpwHealSig }),
@@ -13568,14 +13612,32 @@ body[data-mpw-theme] [class*="settingsArea"] [role="alertdialog"] {
13568
13612
  wide: true = `?sbfill=wide` 回退开关(全局覆盖,仅排障用) */
13569
13613
  function buildSidebarFillCss(mode, wide) {
13570
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` 那一层给)——用户口径是"右栏与左栏同色同透明"。 */
13571
13621
  const NARROW = `html body .pI_x6G_sidebarCol,
13572
13622
  html body [class*="sidebarCol"]:not([class*="_tab"]),
13573
13623
  html body .hHd-Xa_root,
13574
- 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]`;
13575
13631
  const NARROW_DARK = `html body[data-ds-dark-theme] .pI_x6G_sidebarCol,
13576
13632
  html body[data-ds-dark-theme] [class*="sidebarCol"]:not([class*="_tab"]),
13577
13633
  html body[data-ds-dark-theme] .hHd-Xa_root,
13578
- 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]`;
13579
13641
  const sel = wide ? 'html body' : NARROW;
13580
13642
  const selDark = wide ? 'html body[data-ds-dark-theme]' : NARROW_DARK;
13581
13643
  if (mode === 'translucent') {
@@ -15579,7 +15641,21 @@ function createNowPlaying(deps) {
15579
15641
  /** 一帧判据:写/撤 data-mpw-np-hidden + **让位判据**(①(NP-3))。
15580
15642
  * 让位在**每一次**判据里都跑:挂载前(setEnabled 里单独一次)、挂载后(这里)都要判。
15581
15643
  * 规则:容器里出现非本插件、非宿主自有的占用者 ⇒ 撤下且**不重建**(等它走了再回来)。 */
15644
+ /* ①(P1B) npFit 重排闸门:优先用 client.js 工厂作用域的 mpwHeavyGate(内联后作用域链可见);
15645
+ 独立装载时用本地同语义兜底(动画在跑 ⇒ 推迟 400ms 补跑)。 */
15646
+ const npFitGate = (typeof mpwHeavyGate === "function")
15647
+ ? mpwHeavyGate
15648
+ : function (name, fn) {
15649
+ try { if (document.getAnimations && document.getAnimations().length) { setTimeout(fn, 400); return false } } catch (e) {}
15650
+ return true;
15651
+ };
15582
15652
  function evaluate() {
15653
+ /* ①(P1B 2026-10-03) 收起/展开动画期间跳过重排(occupantNow/measureWidth/npFit 都是
15654
+ getBoundingClientRect 类强制布局,宿主每帧改 DOM ⇒ 旧写法每帧一次);400ms 后补跑
15655
+ 一次,返回上次 known hidden(调用方语义不变)。闸门主体在 client.js 工厂作用域
15656
+ (mpwHeavyGate,内联进生成区后经作用域链可见);本文件被**独立**装载时(now-playing-test
15657
+ 直载)用同语义本地兜底。 */
15658
+ if (!npFitGate("npFit", evaluate)) return hidden;
15583
15659
  if (!container) {
15584
15660
  hidden = false;
15585
15661
  /* 没挂着时三种情况都要在这里补挂:①上一个占用者走了;②宿主侧栏后来才渲染出来;
@@ -16543,6 +16619,14 @@ module.exports = {
16543
16619
  uAlpha = 生效**不透明度** = 100 - 「界面透明度」(0 = 不透明、100 = 全透明)。
16544
16620
  与模糊彻底解耦:模糊 0 时也照样按透明度透出(背后壁纸是清晰的)。 */
16545
16621
  const surfacePlan = mpwSurfacePlan(section);
16622
+ /* ②(2026-10-04) 标题栏半透明底 token **在唯一入口无条件写**(亮/暗都走同一枚宿主底色):
16623
+ 旧写法只在 `syncHeaderFrost()` 里、且要求"这一刻找得到 header"才写 ⇒ 一旦那次没写,
16624
+ CSS 就掉回写死的白(用户真机读数:标题栏 `rgba(255,255,255,0.38)` 完全不吃透明度滑条)。
16625
+ ⇒ 落到与左栏/右栏**同一套模型**:宿主底色 × 表面不透明度。 */
16626
+ try {
16627
+ if (enabled && hasImage) document.documentElement.style.setProperty("--mpw-hdr-frost-bg", mpwSurfaceColor(surfacePlan.header.base, surfacePlan.header.opacityPct / 100));
16628
+ else document.documentElement.style.removeProperty("--mpw-hdr-frost-bg");
16629
+ } catch (e) {}
16546
16630
  /* ②(2026-10-04 用户真机:"整屏虚化程度调大 ⇒ 左/右侧边栏被叠上一层粉色滤镜;
16547
16631
  调到 0 就是完全透明的状态")**铁律:我们一个像素都不画时,也不给宿主叠 backdrop-filter。**
16548
16632
  `backdrop-filter` 不是"我们的表面",而是对宿主内容施加的滤镜 —— 完全没有雾层时它只剩失真:
@@ -17387,11 +17471,7 @@ html body[data-mpw-sblur][data-mpw-sblur-off] [class*="sidebarCol"] {
17387
17471
  const rsBg = `rgba(255,255,255,${Math.max(0, Math.min(1, rsAlpha / 100)) * 0.55})`;
17388
17472
  css += `
17389
17473
  /* ── F. 右侧边栏 / dock 虚化(独立开关+程度+透明度)── */
17390
- html body[data-sidebar-right-panel],
17391
- html body[data-sidebar-right-float-host],
17392
- html body[data-mpw-rs],
17393
- html body[data-dockkit-strip],
17394
- html body[data-dockkit-float] {
17474
+ html body[data-mpw-rs-surface] {
17395
17475
  -webkit-backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17396
17476
  backdrop-filter: ${Math.max(0, Number(rsAmount) || 0) > 0 ? `blur(${rsAmount}px)` : "none"};
17397
17477
  background-color: ${rsBg} !important;
@@ -17520,6 +17600,12 @@ html body:has([class*="_modal"]) [data-mpw-rs] {
17520
17600
  三枚 token 只在接管时被下面那块 CSS 引用;不接管时它们不生效(无引用)。
17521
17601
  厚度直接取雾模型(= 左侧边栏/标题栏透明度),0 ⇒ rgba(...,0) = 完全透明,
17522
17602
  所以"统一虚化程度/透明度都拉到 0"时右栏/dock 也是干净的透出,不留采样色。 */
17603
+ /* ②(2026-10-04 用户真机:"右侧边栏比左栏/标题栏更白")根因:右栏/dock 家族**互相嵌套**
17604
+ (面板 > dockkit 面板/标签条/表面,真机读数里两个元素的 rect 只差 1px),旧选择器把
17605
+ 两层都刷上同一枚表面色 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;
17606
+ 左栏只有一个匹配元素 ⇒ 单层 ⇒ 看起来"灰"。⇒ 只给**最外层**那个打标记(JS 结构判定,
17607
+ 与 `data-mpw-holds-layer` 同一手法),CSS 只认标记。 */
17608
+ try { mpwTagSurfaceOuter(); } catch (e) {}
17523
17609
  const fogCss = mpwFogModel(section);
17524
17610
  const unifyTakeover = unifyFollowNow && hasImage && !rightInert;
17525
17611
  const unifyBlurCss = (!rightNoFog && fogCss.amountPx > 0) ? `blur(${fogCss.amountPx}px)` : "none";
@@ -17569,10 +17655,9 @@ ${chromeInert ? "" : `html body:not([data-mpw-sblur-off]) [class*="sidebarCol"]:
17569
17655
  background-color: var(--mpw-surface-side-frost) !important;
17570
17656
  }`}
17571
17657
  /* 右侧栏 / dockkit(V 形:面板 + 标签条 + 表面) */
17572
- ${rightInert ? "" : `html body[data-mpw-rsblur="on"] [data-sidebar-right-panel],
17573
- html body[data-mpw-rsblur="on"] [data-dockkit-pane],
17574
- html body[data-mpw-rsblur="on"] [data-dockkit-strip],
17575
- html body[data-mpw-rsblur="on"] [data-dockkit-surface] {
17658
+ ${rightInert ? "" : `html body[data-mpw-rsblur="on"] [data-mpw-rs-surface],
17659
+ html body[data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-pane],
17660
+ html body[data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-strip] {
17576
17661
  -webkit-backdrop-filter: ${rsBlurCss} !important;
17577
17662
  backdrop-filter: ${rsBlurCss} !important;
17578
17663
  background-color: var(--mpw-surface-rs-dock) !important;
@@ -17616,10 +17701,10 @@ html body[data-dragging] [data-dockkit-pane] {
17616
17701
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) header[class*="_header_"],
17617
17702
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) header.wSkVaW_header,
17618
17703
  html body[data-ds-dark-theme]:not([data-mpw-sblur-off]) [class*="sidebarCol"]:not([data-sidebar-right-panel]):not([data-dockkit-strip]),
17619
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-sidebar-right-panel],
17620
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-pane],
17621
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-strip],
17622
- html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-dockkit-surface] {
17704
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface],
17705
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-pane],
17706
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-strip],
17707
+ html body[data-ds-dark-theme][data-mpw-rsblur="on"] [data-mpw-rs-surface][data-dockkit-surface] {
17623
17708
  background-color: var(--mpw-surface-rs-dock-dark) !important;
17624
17709
  color: var(--dsw-alias-label-primary, var(--dsw-alias-text-primary, inherit));
17625
17710
  }
@@ -18775,11 +18860,16 @@ svg.mpw_navIconImg { background: none; border: 0; border-radius: 0; color: inher
18775
18860
  描边一律交还宿主(下方 hover/悬浮块里的 border-bottom-color 兜底保持不变)。 */
18776
18861
  html body .wSkVaW_header[data-mpw-hdr-translucent],
18777
18862
  html body header[class*="_header_"][data-mpw-hdr-translucent] {
18778
- background-color: var(--mpw-hdr-frost-bg, rgba(255, 255, 255, 0.38)) !important;
18863
+ /* ②(2026-10-04 真机:"标题栏是白色底,左右栏却跟滑条走")不许有写死的白回退:
18864
+ --mpw-hdr-frost-bg 一旦没写到(历史路径/时序竞态),旧写法就掉回 rgba(255,255,255,.38)
18865
+ ⇒ 标题栏看起来永远是白的、与「界面透明度」无关。回退到同一枚表面 token
18866
+ (= 宿主底色 × 表面不透明度,与左栏/右栏同一套模型)⇒ 三块构造逐字一致。
18867
+ 注意:CSS 模板里的注释不许出现反引号(会截断模板串,本仓踩过三次)。 */
18868
+ background-color: var(--mpw-hdr-frost-bg, var(--mpw-surface-frost-top)) !important;
18779
18869
  }
18780
18870
  html body[data-ds-dark-theme] .wSkVaW_header[data-mpw-hdr-translucent],
18781
18871
  html body[data-ds-dark-theme] header[class*="_header_"][data-mpw-hdr-translucent] {
18782
- background-color: var(--mpw-hdr-frost-bg, rgba(18, 22, 30, 0.45)) !important;
18872
+ background-color: var(--mpw-hdr-frost-bg, var(--mpw-surface-frost-top)) !important;
18783
18873
  }
18784
18874
  /* ②(2026-09-16 视觉回归修复) 标题栏磨砂"元素层"(.mpw-hdrFrost,由 JS 注入)的**层叠与托底**规则。
18785
18875
  为什么必须有这三条(真根因链):
@@ -24547,6 +24637,10 @@ body[data-mpw-modal] [role="dialog"] {
24547
24637
  mpwTrace('apply:start', { hasReact: !!react, hasReactDOM: !!ReactDOM, hasClient: !!ReactDOMClient });
24548
24638
  if (globalThis.__mpwAppliedOnce) { try { console.warn("[dsh-mpkg-wallpaper] apply 重复调用已忽略"); } catch {} ; mpwTrace('apply:skip', { reason: 'already applied' }); return }
24549
24639
  globalThis.__mpwAppliedOnce = 1;
24640
+ /* ①(P1A 对照组 2026-10-03) 测量开关:URL 加 ?mpwperf=off ⇒ 本次**完全不装** CSS/observer/壁纸
24641
+ (apply 直接 return;与探针的"页内中性化"同口径,给动画/渲染开销做 A/B)。
24642
+ 登记:docs/DIAGNOSTICS.md。 */
24643
+ try { if (new URLSearchParams(location.search).get("mpwperf") === "off") { try { console.warn("[dsh-mpkg-wallpaper] ?mpwperf=off ⇒ 本次不装 CSS/observer(测量对照档)") } catch (e) {} ; mpwTrace('apply:skip', { reason: 'mpwperf=off' }); return } } catch (e) {}
24550
24644
  try {
24551
24645
  pluginCtx = ctx;
24552
24646
  applyInner(ctx);
@@ -24565,6 +24659,29 @@ body[data-mpw-modal] [role="dialog"] {
24565
24659
  // role=dialog(如设置面板,它在侧边栏 footArea 里)。
24566
24660
  let sblurObserver = null;
24567
24661
  let sblurRecheckTimer = 0; // ⑥ 延迟重算的合并句柄(动画期间不堆积定时器)
24662
+ /* ①(P1B 2026-10-03) 全文档扫描类重活的**共享闸门**(读数:tools/sidebar-anim-probe 左栏收起动画
24663
+ 期间 插件开 p95=66.5ms / >50ms 空档 6 个 vs 页内中性化 32.6ms / 0 个;MO 回调自身仅 1-6ms
24664
+ ⇒ 卡顿主体在观察器调度的后续全文档扫描 + 强制布局)。口径:document.getAnimations() 有
24665
+ running 就跳过本轮重活,登记补跑;400ms 定时器统一补跑一次,不会漏收尾状态。
24666
+ getAnimations 只读动画表、不触发布局,闸门本身廉价。 */
24667
+ let mpwHeavyRecheckTimer = 0;
24668
+ const mpwHeavyPending = new Map();
24669
+ function mpwHeavyAnimRunning() {
24670
+ try { return !!(document.getAnimations && document.getAnimations().length) } catch (e) { return false }
24671
+ }
24672
+ function mpwHeavyGate(name, fn) {
24673
+ if (!mpwHeavyAnimRunning()) return true;
24674
+ mpwHeavyPending.set(name, fn);
24675
+ if (!mpwHeavyRecheckTimer) {
24676
+ mpwHeavyRecheckTimer = setTimeout(() => {
24677
+ mpwHeavyRecheckTimer = 0;
24678
+ const jobs = Array.from(mpwHeavyPending.values());
24679
+ mpwHeavyPending.clear();
24680
+ for (const j of jobs) { try { j() } catch (e) {} }
24681
+ }, 400);
24682
+ }
24683
+ return false;
24684
+ }
24568
24685
  function setupSblurObserver() {
24569
24686
  if (sblurObserver !== null) return;
24570
24687
  const check = () => {
@@ -24626,7 +24743,24 @@ body[data-mpw-modal] [role="dialog"] {
24626
24743
  sblurRecheckTimer = setTimeout(() => { sblurRecheckTimer = 0; check(); }, 300);
24627
24744
  };
24628
24745
  try {
24629
- sblurObserver = new MutationObserver(() => { check(); recheck(); });
24746
+ /* ①(P1B) 旧写法每批突变**同步**跑 check()(querySelectorAll + getBoundingClientRect
24747
+ 强制布局)⇒ 收起动画每帧一次全量扫描。改 rAF 帧内合并 + 闸门(动画期间跳过、
24748
+ 结束补跑一次)。 */
24749
+ let sbraf = 0;
24750
+ const sbrun = () => {
24751
+ sbraf = 0;
24752
+ try {
24753
+ if (document.body && document.body.hasAttribute
24754
+ && (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
24755
+ if (!mpwHeavyGate("sblur", sbrun)) return;
24756
+ check();
24757
+ } catch (e) {}
24758
+ };
24759
+ sblurObserver = new MutationObserver(() => {
24760
+ if (sbraf) return;
24761
+ try { sbraf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(sbrun) : setTimeout(sbrun, 16); }
24762
+ catch (e) { sbrun(); }
24763
+ });
24630
24764
  sblurObserver.observe(document.body, { childList: true, subtree: true });
24631
24765
  } catch { sblurObserver = null; }
24632
24766
  // 窗口尺寸变化会让"近全屏"阈值与面板 rect 都变(父对话的真机判据就带视口切换)⇒ 一并重算
@@ -24694,6 +24828,155 @@ body[data-mpw-modal] [role="dialog"] {
24694
24828
  } catch (e) {}
24695
24829
  }
24696
24830
  }
24831
+ /* ②(2026-10-04 真机:"右侧边栏比左栏/标题栏更白")**嵌套表面只画最外层**:
24832
+ 右栏/dock 家族是互相嵌套的(真机读数:两个命中元素的 rect 只差 1px),把同一枚半透明表面色
24833
+ 刷在两层上 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;左栏只有一个匹配元素,
24834
+ 于是"左栏灰、右栏白"。这里按**结构**(有没有同样命中的祖先)给最外层打
24835
+ `data-mpw-rs-surface`,CSS 只认这个标记(与 `data-mpw-holds-layer` 同一手法)。
24836
+ 幂等、无布局读取(只用 matches/parentElement),可随突变跑。 */
24837
+ 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"]';
24838
+ function mpwTagSurfaceOuter() {
24839
+ try {
24840
+ const all = Array.from(document.querySelectorAll(MPW_RS_FAMILY));
24841
+ if (!all.length) return 0;
24842
+ const set = new Set(all);
24843
+ const isDock = (el) => { try { return el.matches('[data-dockkit-pane], [data-dockkit-strip], [data-dockkit-surface], [data-dockkit-float]') } catch (e) { return false } };
24844
+ const hasMatchedAncestor = (el) => {
24845
+ for (let p = el.parentElement, i = 0; p && i < 40; p = p.parentElement, i++) { if (set.has(p)) return true }
24846
+ return false;
24847
+ };
24848
+ /* 同一片视觉区域**只许留一个**被刷表面的元素。优先级(真机结构):
24849
+ ① 命中的 DSH 右栏面板(`[data-sidebar-right-panel]` / `[class*="P3OORG_panel"]`)——它是用户看到的那块;
24850
+ ② 没有面板时,dock 家族里"没有命中祖先"的那些(better-sidebar 独立 dock 布局);
24851
+ ③ 兜底:全部"没有命中祖先"的(老布局)。
24852
+ 为什么不能只按"有没有命中祖先":真机里右栏面板与它的内层 `_surface_*` **同时带**
24853
+ `data-sidebar-right-panel` / `data-dockkit-surface`,两层 55% 叠起来 ≈ 80% ⇒ 用户看到的"更白"。 */
24854
+ const outermost = all.filter((el) => !hasMatchedAncestor(el));
24855
+ const panelsOuter = outermost.filter((el) => { try { return el.matches('[data-sidebar-right-panel], [class*="P3OORG_panel"]') } catch (e) { return false } });
24856
+ let want = panelsOuter.length ? panelsOuter : outermost.filter((el) => !isDock(el));
24857
+ if (!want.length) want = outermost;
24858
+ const wantSet = new Set(want);
24859
+ let changed = 0;
24860
+ for (const el of all) {
24861
+ const should = wantSet.has(el);
24862
+ const has = el.hasAttribute("data-mpw-rs-surface");
24863
+ if (should && !has) { try { el.setAttribute("data-mpw-rs-surface", ""); changed++ } catch {} }
24864
+ else if (!should && has) { try { el.removeAttribute("data-mpw-rs-surface"); changed++ } catch {} }
24865
+ }
24866
+ return changed;
24867
+ } catch (e) { return 0 }
24868
+ }
24869
+
24870
+ function mpwTagPopoverBg() {
24871
+ let list = null;
24872
+ try { list = document.querySelectorAll(MPW_POP_TAG_SEL); } catch (e) { return; }
24873
+ if (!list) return;
24874
+ for (let i = 0; i < list.length; i++) {
24875
+ const el = list[i];
24876
+ let need = false;
24877
+ try {
24878
+ const cls = (typeof el.className === "string") ? el.className : "";
24879
+ if (!MPW_POP_TAG_NOT.test(cls)) {
24880
+ const cs = getComputedStyle(el);
24881
+ if (cs && cs.display !== "none" && cs.visibility !== "hidden"
24882
+ && /blur\(/.test(String(cs.backdropFilter || cs.webkitBackdropFilter || ""))) {
24883
+ const a = mpwColorAlpha(cs.backgroundColor);
24884
+ if (a < 0.05) need = !mpwPopHasPaintedAncestor(el);
24885
+ else if (a >= 0.999) need = mpwPopIsFloating(el);
24886
+ }
24887
+ }
24888
+ } catch (e) { need = false; }
24889
+ try {
24890
+ if (need) { if (!el.hasAttribute("data-mpw-pop-bg")) el.setAttribute("data-mpw-pop-bg", ""); }
24891
+ else if (el.hasAttribute("data-mpw-pop-bg")) el.removeAttribute("data-mpw-pop-bg");
24892
+ } catch (e) {}
24893
+ }
24894
+ }
24895
+ /* ②(2026-10-04 真机:"右侧边栏比左栏/标题栏更白")**嵌套表面只画最外层**:
24896
+ 右栏/dock 家族是互相嵌套的(真机读数:两个命中元素的 rect 只差 1px),把同一枚半透明表面色
24897
+ 刷在两层上 ⇒ 合成不透明度 1-(1-a)²(a=0.55 时 0.80)≈ 一块近白板;左栏只有一个匹配元素,
24898
+ 于是"左栏灰、右栏白"。这里按**结构**(有没有同样命中的祖先)给最外层打
24899
+ `data-mpw-rs-surface`,CSS 只认这个标记(与 `data-mpw-holds-layer` 同一手法)。
24900
+ 幂等、无布局读取(只用 matches/parentElement),可随突变跑。 */
24901
+ function mpwTagSurfaceOuter() {
24902
+ try {
24903
+ const all = Array.from(document.querySelectorAll(MPW_RS_FAMILY));
24904
+ if (!all.length) return 0;
24905
+ const set = new Set(all);
24906
+ /* 真机读数:DSH 右栏面板与 dockkit 面板/标签条**互相重叠**(rect 只差 1~2px)却不在同一条
24907
+ 祖先链上 ⇒ 只按"有没有命中祖先"判不出重叠,两层 55% 会合成 ~80%(用户看到的"更白")。
24908
+ 判据:DSH 自己的右栏面板在场时,dock 子件一律不刷(它们显示的就是那块面板的表面);
24909
+ 只有"没有右栏面板、只有 dock"的布局(better-sidebar 底部工作台等)才由 dock 自己承担。 */
24910
+ const panel = document.querySelector('[data-sidebar-right-panel]');
24911
+ const isDock = (el) => { try { return el.matches('[data-dockkit-pane], [data-dockkit-strip], [data-dockkit-surface], [data-dockkit-float]') } catch (e) { return false } };
24912
+ let changed = 0;
24913
+ for (const el of all) {
24914
+ let inner = false;
24915
+ for (let p = el.parentElement, i = 0; p && i < 40; p = p.parentElement, i++) { if (set.has(p)) { inner = true; break } }
24916
+ const coveredByPanel = !!(panel && panel !== el && isDock(el));
24917
+ const want = !inner && !coveredByPanel;
24918
+ const has = el.hasAttribute("data-mpw-rs-surface");
24919
+ if (want && !has) { try { el.setAttribute("data-mpw-rs-surface", ""); changed++ } catch {} }
24920
+ else if (!want && has) { try { el.removeAttribute("data-mpw-rs-surface"); changed++ } catch {} }
24921
+ }
24922
+ return changed;
24923
+ } catch (e) { return 0 }
24924
+ }
24925
+
24926
+ /* ①(P3 修复 2026-10-03) 弹层玻璃的 backdrop root 被截断 ⇒ 有半透明、没模糊。
24927
+ 读数与机制(docs/POPOVER-BLUR.md):挂在宿主输入框卡片(uV2eYG_card,backdrop-filter:
24928
+ blur(14px))子树里的弹层,其 backdrop root 被卡片截断 ⇒ 弹层自己的 blur(11px) 只能采样
24929
+ 卡片内部 = 看不出模糊;挂在 body 下的模型选择器正常。实验定案:把表面移出祖先链即恢复
24930
+ (React 管的 DOM 不许 reparent);宿主规则带 !important ⇒ inline 覆盖无效,**CSSOM 注入
24931
+ 同特异性更晚的 !important 规则**有效且可逆。
24932
+ 修法:只扫已打 `data-mpw-pop-bg` 的弹层表面(我们的玻璃只刷在那里),沿祖先链找**第一个**
24933
+ 带 blur 的截断祖先,给它打 `data-mpw-pop-untrunc` 属性 + 一条静态 !important 规则
24934
+ (`[data-mpw-pop-untrunc] { backdrop-filter: none !important; }`)——只在弹层开着期间生效,
24935
+ 弹层关闭(无已标记表面)即摘属性、撤规则元素,宿主逐字还原。代价:属性表 + 一次
24936
+ querySelectorAll,挂在 prun(已 rAF 合并 + mpwHeavyGate 闸门)里,不开新观察器。 */
24937
+ let popUntruncStyleEl = null;
24938
+ function mpwPopUntruncStyle() {
24939
+ if (!popUntruncStyleEl || popUntruncStyleEl.isConnected === false) {
24940
+ popUntruncStyleEl = document.createElement("style");
24941
+ popUntruncStyleEl.setAttribute("data-plugin", "dsh-mpkg-wallpaper");
24942
+ popUntruncStyleEl.setAttribute("data-mpw-pop-untrunc-style", "");
24943
+ popUntruncStyleEl.textContent = "[data-mpw-pop-untrunc] { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }";
24944
+ (document.head || document.documentElement).appendChild(popUntruncStyleEl);
24945
+ }
24946
+ return popUntruncStyleEl;
24947
+ }
24948
+ function mpwPopUntruncSync() {
24949
+ try {
24950
+ const surfaces = document.querySelectorAll("[data-mpw-pop-bg]");
24951
+ const targets = new Set();
24952
+ for (let i = 0; i < surfaces.length; i++) {
24953
+ const el = surfaces[i];
24954
+ let cs = null;
24955
+ try { cs = getComputedStyle(el) } catch (e) {}
24956
+ /* 硬前提(与 mpwTagPopoverBg 同口径):我们真的给了模糊才值得摘祖先的截断 */
24957
+ if (!cs || !cs.backdropFilter || String(cs.backdropFilter).indexOf("blur") < 0) continue;
24958
+ for (let p = el.parentElement, n = 0; p && p !== document.body && p !== document.documentElement && n < 12; p = p.parentElement, n++) {
24959
+ let acs = null;
24960
+ try { acs = getComputedStyle(p) } catch (e) { break }
24961
+ if (acs && acs.backdropFilter && acs.backdropFilter !== "none" && String(acs.backdropFilter).indexOf("blur") >= 0) { targets.add(p); break }
24962
+ }
24963
+ }
24964
+ const stale = document.querySelectorAll("[data-mpw-pop-untrunc]");
24965
+ for (let i = 0; i < stale.length; i++) { const el = stale[i]; if (!targets.has(el)) { try { el.removeAttribute("data-mpw-pop-untrunc") } catch (e) {} } }
24966
+ for (const el of targets) { if (!el.hasAttribute("data-mpw-pop-untrunc")) { try { el.setAttribute("data-mpw-pop-untrunc", "") } catch (e) {} } }
24967
+ if (targets.size) mpwPopUntruncStyle();
24968
+ else if (popUntruncStyleEl && popUntruncStyleEl.parentElement) { try { popUntruncStyleEl.remove() } catch (e) {} popUntruncStyleEl = null; }
24969
+ return targets.size;
24970
+ } catch (e) { return 0 }
24971
+ }
24972
+ /* ①(P3) 判据出口(tools/popover-untrunc-test.mjs 直接调 sync 断言 打标/还原/硬前提/首截断者)。 */
24973
+ try {
24974
+ globalThis.__mpwPopUntruncTest = {
24975
+ sync: () => mpwPopUntruncSync(),
24976
+ stylePresent: () => !!(popUntruncStyleEl && popUntruncStyleEl.parentElement),
24977
+ };
24978
+ } catch (e) {}
24979
+
24697
24980
  function setupPopTagWatch() {
24698
24981
  if (popTagWatch !== null) return;
24699
24982
  let praf = 0;
@@ -24703,7 +24986,16 @@ body[data-mpw-modal] [role="dialog"] {
24703
24986
  // 左栏收起/展开与拖拽期间宿主每帧改 DOM:跳过(收尾还会再来一次)
24704
24987
  if (document.body && document.body.hasAttribute
24705
24988
  && (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
24989
+ /* ①(P1B) 动画期间跳过全文档弹层扫描(结束补跑一次) */
24990
+ if (!mpwHeavyGate("popTag", prun)) return;
24706
24991
  mpwTagPopoverBg();
24992
+ /* ②(2026-10-04) 右栏/dock 的"只留最外层"标记必须**跟着 DOM 走**:右栏面板是
24993
+ 用户打开后才挂载的,apply 那一刻它还不存在 ⇒ 只按一次会把 dock 错标成表面,
24994
+ 之后面板出现就变成两层(真机读数:dockPane/dockStrip 仍是 0.55)。
24995
+ 这里与弹层标记共用同一个 rAF 合并的入口(要素很少,代价可忽略)。 */
24996
+ try { mpwTagSurfaceOuter(); } catch (e) {}
24997
+ /* ①(P3) 弹层开着 ⇒ 摘截断祖先的 blur;关了 ⇒ 还原(同一入口,随突变重算) */
24998
+ try { mpwPopUntruncSync(); } catch (e) {}
24707
24999
  } catch (e) {}
24708
25000
  };
24709
25001
  try {
@@ -24715,6 +25007,7 @@ body[data-mpw-modal] [role="dialog"] {
24715
25007
  popTagWatch.observe(document.body, { childList: true, subtree: true });
24716
25008
  } catch { popTagWatch = null; }
24717
25009
  mpwTagPopoverBg();
25010
+ try { mpwPopUntruncSync(); } catch (e) {}
24718
25011
  }
24719
25012
 
24720
25013
  // ①(新) 标题栏 backdrop blur 摘除:G 块给 header 加 blur(unifyAmount)(聊天区
@@ -24757,6 +25050,8 @@ body[data-mpw-modal] [role="dialog"] {
24757
25050
  try {
24758
25051
  if (document.body && document.body.hasAttribute
24759
25052
  && (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
25053
+ /* ①(P1B) 动画期间跳过(check 里有 querySelectorAll + getBoundingClientRect 强制布局) */
25054
+ if (!mpwHeavyGate("hdrBlur", hrun)) return;
24760
25055
  check();
24761
25056
  } catch (e) {}
24762
25057
  };
@@ -24770,6 +25065,22 @@ body[data-mpw-modal] [role="dialog"] {
24770
25065
  check();
24771
25066
  }
24772
25067
 
25068
+ /* ①(P1B) 闸门的判据出口(tools/anim-guard-test.mjs 直接断言:动画在跑 ⇒ 拦下 + 登记 + 定时器就位;
25069
+ 动画停 ⇒ 补跑执行、账目清空)。 */
25070
+ try {
25071
+ globalThis.__mpwHeavyTest = {
25072
+ state: () => ({ pending: Array.from(mpwHeavyPending.keys()), timer: !!mpwHeavyRecheckTimer }),
25073
+ gate: (name, fn) => mpwHeavyGate(name, fn),
25074
+ flushNow: () => {
25075
+ if (mpwHeavyRecheckTimer) { try { clearTimeout(mpwHeavyRecheckTimer) } catch (e) {} mpwHeavyRecheckTimer = 0; }
25076
+ const jobs = Array.from(mpwHeavyPending.values());
25077
+ mpwHeavyPending.clear();
25078
+ for (const j of jobs) { try { j() } catch (e) {} }
25079
+ return jobs.length;
25080
+ },
25081
+ };
25082
+ } catch (e) {}
25083
+
24773
25084
  // ⑲(修正) Aqua 主题切换监听:深色/浅色切换时重算遮罩色与自适应文字色
24774
25085
  //(用户实测:aquaInk 开时切换主题,文字色卡在旧主题——黑字黑底/白字白底看不清)。
24775
25086
  let aquaThemeWatch = null;
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`)。
@@ -1293,7 +1293,21 @@ function createNowPlaying(deps) {
1293
1293
  /** 一帧判据:写/撤 data-mpw-np-hidden + **让位判据**(①(NP-3))。
1294
1294
  * 让位在**每一次**判据里都跑:挂载前(setEnabled 里单独一次)、挂载后(这里)都要判。
1295
1295
  * 规则:容器里出现非本插件、非宿主自有的占用者 ⇒ 撤下且**不重建**(等它走了再回来)。 */
1296
+ /* ①(P1B) npFit 重排闸门:优先用 client.js 工厂作用域的 mpwHeavyGate(内联后作用域链可见);
1297
+ 独立装载时用本地同语义兜底(动画在跑 ⇒ 推迟 400ms 补跑)。 */
1298
+ const npFitGate = (typeof mpwHeavyGate === "function")
1299
+ ? mpwHeavyGate
1300
+ : function (name, fn) {
1301
+ try { if (document.getAnimations && document.getAnimations().length) { setTimeout(fn, 400); return false } } catch (e) {}
1302
+ return true;
1303
+ };
1296
1304
  function evaluate() {
1305
+ /* ①(P1B 2026-10-03) 收起/展开动画期间跳过重排(occupantNow/measureWidth/npFit 都是
1306
+ getBoundingClientRect 类强制布局,宿主每帧改 DOM ⇒ 旧写法每帧一次);400ms 后补跑
1307
+ 一次,返回上次 known hidden(调用方语义不变)。闸门主体在 client.js 工厂作用域
1308
+ (mpwHeavyGate,内联进生成区后经作用域链可见);本文件被**独立**装载时(now-playing-test
1309
+ 直载)用同语义本地兜底。 */
1310
+ if (!npFitGate("npFit", evaluate)) return hidden;
1297
1311
  if (!container) {
1298
1312
  hidden = false;
1299
1313
  /* 没挂着时三种情况都要在这里补挂:①上一个占用者走了;②宿主侧栏后来才渲染出来;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.15.4",
3
+ "version": "3.15.6",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",