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.
- package/lib/client.js +245 -36
- package/lib/index.js +171 -13
- 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
|
-
|
|
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
|
-
|
|
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))
|
|
17089
|
-
backdrop-filter: blur(var(--mpw-hdr-blur, 24px))
|
|
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-
|
|
17375
|
-
|
|
17376
|
-
|
|
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:
|
|
17421
|
-
backdrop-filter:
|
|
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:
|
|
17430
|
-
-webkit-backdrop-filter:
|
|
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
|
-
|
|
17468
|
-
|
|
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)
|
|
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
|
-
|
|
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-
|
|
17540
|
-
html body[data-mpw-rsblur="on"] [data-dockkit-pane],
|
|
17541
|
-
html body[data-mpw-rsblur="on"] [data-dockkit-strip]
|
|
17542
|
-
|
|
17543
|
-
|
|
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-
|
|
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:
|
|
17788
|
-
backdrop-filter:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 <
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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