dsh-mpkg-wallpaper 3.15.2 → 3.15.3
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 +175 -16
- package/lib/index.js +101 -26
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -338,7 +338,13 @@ function mpwTrace(ev, data) {
|
|
|
338
338
|
// ①(新 2026-09-11) DSH 0.1.5 自带右侧边栏/dock 浮层虚化(兼容桥纳入旧规则作用域)
|
|
339
339
|
const DEFAULT_RIGHT_SIDEBAR_BLUR = true; // 右侧边栏/dock 虚化开关
|
|
340
340
|
const DEFAULT_RIGHT_SIDEBAR_AMOUNT = 14; // 虚化程度 px
|
|
341
|
-
|
|
341
|
+
/* ①(2026-10-03 语义翻转后复核) 值仍是 45,但语义已经是**透明度**:
|
|
342
|
+
45 ⇒ 生效不透明度 55%。为什么不跟着映射改成 55:它是"从没设过右栏那条"的兜底,
|
|
343
|
+
而右栏默认在**跟随**档(`rightT = 侧栏透明度`),这个常量几乎只在"用户关掉跟随
|
|
344
|
+
且从没动过右栏滑条"时生效;改它会平移 `--mpw-rs-alpha` 的取值 ⇒
|
|
345
|
+
`token-namespace-test` 的"同设置逐键等价"判据(before↔after)会出现 259 组漂移。
|
|
346
|
+
存量用户**显式设过**的值由 `mpwAlphaFlipValue()` 按意图搬(45→55)✓,与本常量无关。 */
|
|
347
|
+
const DEFAULT_RIGHT_SIDEBAR_ALPHA = 45; // 表面透明度 %(0=全不透明 100=全透明)
|
|
342
348
|
const DEFAULT_SIDEBAR_BLUR_AMOUNT = 14; // 侧边栏磨砂程度
|
|
343
349
|
const DEFAULT_POPOVER_BLUR = true; // 弹层虚化(菜单/提示/遮罩)开关
|
|
344
350
|
const DEFAULT_POPOVER_AMOUNT = 10; // 弹层虚化程度
|
|
@@ -624,6 +630,8 @@ function mpwTrace(ev, data) {
|
|
|
624
630
|
// localStorage 持久化(⑤:读写带缓存 + 防抖,滑杆不卡顿)
|
|
625
631
|
// ═══════════════════════════════════════════════════════════════════
|
|
626
632
|
let sectionCache = null;
|
|
633
|
+
let sectionView = null; // 用户档视图(去元键/记账键);与 sectionCache 同源时才复用
|
|
634
|
+
let sectionViewSrc = null;
|
|
627
635
|
let persistTimer = null;
|
|
628
636
|
/* ①(2026-09-19 设置持久化轮) 落盘收尾闸门:boot 的第一次 applyFromStorage() 跑在宿主
|
|
629
637
|
`GET /settings` **之前**(旧写法会在那一刻把残缺档同时写进 localStorage + PUT 给宿主,
|
|
@@ -774,6 +782,22 @@ function sectionSigNow() {
|
|
|
774
782
|
} catch {}
|
|
775
783
|
return "";
|
|
776
784
|
}
|
|
785
|
+
/* ②(2026-10-03 真 bug:"左栏还是粉色,现在右栏也被注入了粉色")
|
|
786
|
+
3.15.0 把「界面透明度」/「表面透明度」两条滑条的**数值语义翻转**了:
|
|
787
|
+
旧:数值 = 白雾**厚度/不透明**程度(0 = 全透明、100 = 全不透明)
|
|
788
|
+
新:数值 = **透明度**(0 = 全不透明、100 = 全透明) —— 用户拍板的口径
|
|
789
|
+
默认值照映射搬了(新 65 ≡ 旧 35),但**存量档里的用户值没搬**:用户当年为了"求实心"
|
|
790
|
+
把旧滑条拖到 100(= 全不透明),翻转后同一个 100 变成"全透明" ⇒ 左栏整块透出壁纸,
|
|
791
|
+
跟随档下右栏也一起透 ⇒ 真机现象就是"被注入粉色"(探针三次读数都指向"那层就是壁纸本身",
|
|
792
|
+
因为确实没刷任何采样色 —— 病根在这个没搬的值,不在渲染)。
|
|
793
|
+
处置:一次性按 `新 = 100 - 旧` 搬值(观感与旧版逐字一致),写幂等标记 `alphaSemantics = 2`
|
|
794
|
+
(登记进 MPW_META_KEYS:只随档持久化,用户档/面板/导出/诊断看不到它)。 */
|
|
795
|
+
const MPW_ALPHA_SEM_KEY = "alphaSemantics";
|
|
796
|
+
const MPW_ALPHA_SEM = 2;
|
|
797
|
+
function mpwAlphaFlipValue(v) {
|
|
798
|
+
const n = Number(v);
|
|
799
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(100, 100 - n)) : void 0;
|
|
800
|
+
}
|
|
777
801
|
function mpwNormalizeSection(s0) {
|
|
778
802
|
if (!s0 || typeof s0 !== "object") return {};
|
|
779
803
|
let need = false;
|
|
@@ -793,10 +817,22 @@ function sectionSigNow() {
|
|
|
793
817
|
而不是"值是不是 false";没标记(从没设过)才迁移,动过的用户一字不动。 */
|
|
794
818
|
const phMigrate = s0.powPauseHiddenUserSet === true ? null : DEFAULT_POW_PAUSE_HIDDEN;
|
|
795
819
|
const phNeeds = phMigrate !== null && s0.powPauseHidden !== phMigrate;
|
|
796
|
-
|
|
820
|
+
/* ②(2026-10-03) 透明度语义翻转的**搬值**(口径见上面 mpwAlphaFlipValue 的注释):
|
|
821
|
+
只搬"档里真的有这个字段"的项(从没设过 ⇒ 保持新默认,不动)。 */
|
|
822
|
+
const alphaFlip = s0[MPW_ALPHA_SEM_KEY] !== MPW_ALPHA_SEM;
|
|
823
|
+
const alphaSide = alphaFlip && s0.sidebarAlpha !== void 0 ? mpwAlphaFlipValue(s0.sidebarAlpha) : void 0;
|
|
824
|
+
const alphaRight = alphaFlip && s0.rightSidebarAlpha !== void 0 ? mpwAlphaFlipValue(s0.rightSidebarAlpha) : void 0;
|
|
825
|
+
if (!need && !bsNeeds && !phNeeds && !alphaFlip) return s0;
|
|
797
826
|
const o = Object.assign({}, s0);
|
|
798
827
|
if (bsNeeds) { o.bsCompat = bsMigrate; try { console.log("[dsh-mpkg-wallpaper] bsCompat 迁移:从没显式设过 ⇒ 采用新默认 " + bsMigrate + "(手动关过的用户不受影响)") } catch (e) {} }
|
|
799
828
|
if (phNeeds) { o.powPauseHidden = phMigrate; try { console.log("[dsh-mpkg-wallpaper] powPauseHidden 迁移:从没显式设过 ⇒ 采用新默认 " + phMigrate + "(切页静音是浏览器基本礼仪;手动关过的用户不受影响)") } catch (e) {} }
|
|
829
|
+
if (alphaFlip) {
|
|
830
|
+
o[MPW_ALPHA_SEM_KEY] = MPW_ALPHA_SEM;
|
|
831
|
+
const moved = [];
|
|
832
|
+
if (alphaSide !== void 0 && alphaSide !== s0.sidebarAlpha) { o.sidebarAlpha = alphaSide; moved.push("sidebarAlpha " + s0.sidebarAlpha + "→" + alphaSide) }
|
|
833
|
+
if (alphaRight !== void 0 && alphaRight !== s0.rightSidebarAlpha) { o.rightSidebarAlpha = alphaRight; moved.push("rightSidebarAlpha " + s0.rightSidebarAlpha + "→" + alphaRight) }
|
|
834
|
+
try { console.log("[dsh-mpkg-wallpaper] 透明度语义搬值(旧=厚度 → 新=透明度,只做一次):" + (moved.length ? moved.join("、") : "档内无这两个字段,保持新默认")) } catch (e) {}
|
|
835
|
+
}
|
|
800
836
|
const bad = [];
|
|
801
837
|
for (const k of MPW_STR_KEYS) if (o[k] !== void 0 && typeof o[k] !== "string") { bad.push(k + ":" + typeof o[k]); o[k] = mpwAsStr(o[k]) }
|
|
802
838
|
try { console.warn("[dsh-mpkg-wallpaper] 已归一化异常字符串字段:", bad.join(", ")); } catch {}
|
|
@@ -808,12 +844,18 @@ function sectionSigNow() {
|
|
|
808
844
|
// `__mpwLocalAt` 这类元键(时间戳/自愈标记),但"用户档"的读数口(面板/buildCss/
|
|
809
845
|
// 导出/字段集合比较/测试的逐字段 diff)必须看不到它们 —— 否则元键会被当成
|
|
810
846
|
// "用户的设置项"参与比较(真机上表现为"莫名多出一个键",测试里表现为假红)。
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
847
|
+
// ②(2026-10-03 透明度语义搬值) 记账键同理不可见,但它**必须留在 sectionCache 里**
|
|
848
|
+
// (落盘要写、下次开机要认)⇒ 视图单独缓存,**不再回写 sectionCache**。
|
|
849
|
+
// 老写法 `sectionCache = clean` 会把记账键从缓存里抹掉 ⇒ 落盘丢标记 ⇒ 每次开机重搬值
|
|
850
|
+
// (实测:搬值日志每开一次机打一次,值被来回翻)。
|
|
851
|
+
if (sectionViewSrc === sectionCache && sectionView) return sectionView;
|
|
852
|
+
sectionView = mpwStripBook(mpwStripMeta(sectionCache));
|
|
853
|
+
sectionViewSrc = sectionCache;
|
|
854
|
+
return sectionView;
|
|
814
855
|
}
|
|
815
856
|
sectionCache = mpwNormalizeSection(loadSection());
|
|
816
|
-
|
|
857
|
+
sectionView = null; sectionViewSrc = null;
|
|
858
|
+
return readSection();
|
|
817
859
|
}
|
|
818
860
|
/** 写入内存缓存 + 防抖落盘。instant=true 立即写(文件选择/复位)。
|
|
819
861
|
* ⑤(新) host 可用时同时 PUT 到宿主端文件(跨端口/清浏览器数据不丢)。
|
|
@@ -1261,6 +1303,10 @@ function sectionSigNow() {
|
|
|
1261
1303
|
const MPW_SRC_LOST_KEY = "__mpwSrcLost";
|
|
1262
1304
|
/** 元键:只管诊断/自愈记账,**永远不进 section**(面板/apply/CSS 都看不到它们)。 */
|
|
1263
1305
|
const MPW_META_KEYS = [MPW_STAMP_LOCAL, MPW_STAMP_HOST, MPW_SRC_LOST_KEY];
|
|
1306
|
+
/* ②(2026-10-03 透明度语义搬值) **记账键**:与元键的区别是它必须活到 mpwNormalizeSection 那一趟
|
|
1307
|
+
(loadSection 只去元键),否则每次开机都重搬一次值;但用户档视图(面板/导出/CSS)同样不许看到它
|
|
1308
|
+
⇒ 只多这一层 `mpwStripBook`(见 readSection)。 */
|
|
1309
|
+
const MPW_BOOK_KEYS = [MPW_ALPHA_SEM_KEY];
|
|
1264
1310
|
/** 壁纸「源」字段:buildCss 的 `hasImage = image || webUrl` 唯一依据这三个。 */
|
|
1265
1311
|
const MPW_SRC_KEYS = ["image", "webUrl", "sceneKey"];
|
|
1266
1312
|
|
|
@@ -1285,6 +1331,18 @@ function sectionSigNow() {
|
|
|
1285
1331
|
return o;
|
|
1286
1332
|
} catch { return s0 }
|
|
1287
1333
|
}
|
|
1334
|
+
/** 去掉**记账键**(用户档视图专用;落盘/归一化那条路不经过它)。 */
|
|
1335
|
+
function mpwStripBook(s0) {
|
|
1336
|
+
if (!s0 || typeof s0 !== "object") return {};
|
|
1337
|
+
try {
|
|
1338
|
+
let need = false;
|
|
1339
|
+
for (const k of MPW_BOOK_KEYS) if (s0[k] !== void 0) { need = true; break }
|
|
1340
|
+
if (!need) return s0;
|
|
1341
|
+
const o = Object.assign({}, s0);
|
|
1342
|
+
for (const k of MPW_BOOK_KEYS) delete o[k];
|
|
1343
|
+
return o;
|
|
1344
|
+
} catch { return s0 }
|
|
1345
|
+
}
|
|
1288
1346
|
function mpwVal(s, k) {
|
|
1289
1347
|
try { return s && typeof s === "object" ? s[k] : void 0 } catch { return void 0 }
|
|
1290
1348
|
}
|
|
@@ -8444,6 +8502,7 @@ function mpwThumbFail(el, why) {
|
|
|
8444
8502
|
} catch {}
|
|
8445
8503
|
try { setupSblurObserver(); } catch {}
|
|
8446
8504
|
try { setupHeaderBlurWatch(); } catch {}
|
|
8505
|
+
try { setupPopTagWatch(); } catch {}
|
|
8447
8506
|
try { setupAquaThemeWatch(); } catch {}
|
|
8448
8507
|
// ①(新) 主题颜色:body 门控 + CSS 变量(控制侧边栏/标题栏/新会话/设置弹窗底色)
|
|
8449
8508
|
// ①(Qoder 审查 R3) 总开关关闭时还原默认外观:themeColor/accent 的
|
|
@@ -16186,6 +16245,9 @@ module.exports = {
|
|
|
16186
16245
|
}
|
|
16187
16246
|
|
|
16188
16247
|
const MPW_4K_MIN_PX = 3840 * 2160; // ≥4K 的判据(像素面积,与旧提示同口径)
|
|
16248
|
+
/* ①(2026-10-03) 弹层兜底底的默认不透明度:0.58(既有玻璃感、又保证字不糊)。
|
|
16249
|
+
口径:**只在宿主自己没有底色的弹层上**才刷(见 mpwTagPopoverBg),已有玻璃的弹层不动。 */
|
|
16250
|
+
const MPW_POP_SURFACE_ALPHA = 0.58;
|
|
16189
16251
|
const MPW_4K_AUTOCAP_RES = 1920; // 自动降档目标 = RES_LEVELS 里的 1080p 档
|
|
16190
16252
|
/* ①(2026-10-02 用户拍板:**只对当前壁纸临时生效、不写设置、不弹条**)
|
|
16191
16253
|
上一版把 `resMax` 写进设置 ⇒ 之后每次改外观设置都会重新走一遍转码判断、
|
|
@@ -17843,6 +17905,11 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
|
|
|
17843
17905
|
const n = Number(v);
|
|
17844
17906
|
return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : DEFAULT_POPOVER_ALPHA;
|
|
17845
17907
|
})();
|
|
17908
|
+
/* ①(2026-10-03) 「弹层不透明度」滑条是否被用户**显式**调过(值 ≠ 默认 94)。
|
|
17909
|
+
只有显式调过(或开了主题色遮罩/染色)才允许我们去**覆盖宿主自己画好的底**;
|
|
17910
|
+
默认状态下宿主自己的玻璃底一律不动 —— 用户第 3 条夸的就是这些。 */
|
|
17911
|
+
const popAlphaUserSet = section.popoverAlpha !== void 0 && Number(section.popoverAlpha) !== DEFAULT_POPOVER_ALPHA;
|
|
17912
|
+
const popFallbackAlpha = popAlphaUserSet ? popSurfaceAlpha / 100 : MPW_POP_SURFACE_ALPHA;
|
|
17846
17913
|
// 深色主题用深底(浅底 + 浅色文字 = 看不见字),浅色主题用白底;
|
|
17847
17914
|
// 若用户开了主题色(--mpw-theme-color)则跟随主题色,保持与整屏一致。
|
|
17848
17915
|
/* ①(2026-10-02 真机读数定案的**真 bug**) 这里原来拼 `rgba(var(--mpw-chrome-bg, 255,255,255), α)`,
|
|
@@ -17854,6 +17921,15 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
|
|
|
17854
17921
|
/* ①(2026-09-18 §5 第1项) 弹层(面板家族)表面 → 两枚表面 token(不透明度走 --mpw-pop-alpha,
|
|
17855
17922
|
调用方可临时覆盖;默认值仍是「弹层不透明度」滑条)。 */
|
|
17856
17923
|
tok("--mpw-surface-pop", mpwSurfaceColor(surfacePlan.left.base, popSurfaceAlpha / 100).replace(/[\d.]+%/, `calc(var(--mpw-pop-alpha, ${(popSurfaceAlpha / 100).toFixed(2)}) * 100%)`));
|
|
17924
|
+
/* ①(2026-10-03 用户:"设置背景的 token 跟那个重掉了,让他独立出来")
|
|
17925
|
+
弹层(菜单/下拉/选择器)**不再共用面板表面 token** `--mpw-surface-pop`:
|
|
17926
|
+
上一版把 0.94 的它刷到弹层上 ⇒ 用户夸过的那些"本来就有玻璃感"的弹层(标题栏里的
|
|
17927
|
+
子代理展开框、VS Code 菜单…)被我们的近不透明底盖住,**玻璃感消失**。
|
|
17928
|
+
现在独立成一对 token,并且**只在宿主自己没有底色的弹层上兜底**(由 JS 打
|
|
17929
|
+
`data-mpw-pop-bg` 标记,见 mpwTagPopoverBg):有宿主玻璃的弹层一个像素都不动。 */
|
|
17930
|
+
tok("--mpw-pop-surface", mpwSurfaceColor(surfacePlan.left.base, popFallbackAlpha).replace(/[\d.]+%/, `calc(var(--mpw-pop-surface-alpha, ${popFallbackAlpha.toFixed(2)}) * 100%)`));
|
|
17931
|
+
tok("--mpw-pop-surface-alpha", popFallbackAlpha.toFixed(2));
|
|
17932
|
+
tok("--mpw-pop-surface-dark", `rgba(18, 22, 30, var(--mpw-pop-surface-alpha, ${popFallbackAlpha.toFixed(2)}))`);
|
|
17857
17933
|
tok("--mpw-surface-pop-dark", `rgba(18, 22, 30, var(--mpw-pop-alpha, ${(popSurfaceAlpha / 100).toFixed(2)}))`);
|
|
17858
17934
|
// 弹层**表面**选择器(背景 + 模糊共用一份,避免两处写法漂移)。
|
|
17859
17935
|
// ③(关键收窄 2026-09-13) 只用 [class*="_menu"] 会连**非表面**一起命中:
|
|
@@ -17876,7 +17952,10 @@ html body[data-mpw-sblur-off] [data-mpw-rs] {
|
|
|
17876
17952
|
popoverAmount(关掉弹层虚化时为 0 ⇒ 只留底色);圆角沿用宿主自己的(不覆盖 host radius)。 */
|
|
17877
17953
|
tok("--mpw-pop-blur", ((popoverBlur && Number(popoverAmount) > 0) ? `blur(${Number(popoverAmount)}px)` : "none"));
|
|
17878
17954
|
tok("--mpw-pop-radius", "var(--dsw-alias-radius-l2, 12px)");
|
|
17879
|
-
|
|
17955
|
+
/* ②(2026-10-03 用户:"原本那种玻璃效果被修没了") **默认不再强刷**:只有用户**显式**动过
|
|
17956
|
+
「弹层不透明度」滑条、或开了主题色遮罩/染色(那时他明确要我们的表面色)才整片覆盖;
|
|
17957
|
+
默认只给"宿主自己没画底/画的是纯白小浮层"的弹层兜底(`data-mpw-pop-bg`,独立 token)。 */
|
|
17958
|
+
const popSurfaceCss = (popAlphaUserSet || menuTranslucent)
|
|
17880
17959
|
? `/* 弹层表面底色兜底(浅色/深色两套;不透明度由「弹层不透明度」控制) */
|
|
17881
17960
|
html body :is(${popSurfaceSel}) {
|
|
17882
17961
|
background-color: var(--mpw-surface-pop) !important;
|
|
@@ -17899,16 +17978,13 @@ ${popSurfaceSel} {
|
|
|
17899
17978
|
/* ①(2026-10-02) 半径/模糊统一读自有 token(--mpw-pop-blur 是唯一来源;关掉弹层虚化时它就是 none) */
|
|
17900
17979
|
backdrop-filter: var(--mpw-pop-blur, ${popFilter}) !important;
|
|
17901
17980
|
-webkit-backdrop-filter: var(--mpw-pop-blur, ${popFilter}) !important;
|
|
17902
|
-
/* ①(2026-10-02 用户第 3 条:有的弹层"完全透明、文字重叠") **模糊与底色同一条规则**:
|
|
17903
|
-
以前模糊用 popTipSel, popSurfaceSel 两个选择器集、底色只用 popSurfaceSel ⇒
|
|
17904
|
-
命中前者而不命中后者的元素就变成"有模糊、没有底"(真机读数:「+」菜单/权限/模型
|
|
17905
|
-
三处最外层容器 bg 全透明但 bf=blur(11px))。拍平成一个集:**给谁加模糊,就给谁加底**。 */
|
|
17906
|
-
background-color: var(--mpw-surface-pop) !important;
|
|
17907
|
-
}
|
|
17908
|
-
html body[data-ds-dark-theme] ${popTipSel},
|
|
17909
|
-
html body[data-ds-dark-theme] ${popSurfaceSel} {
|
|
17910
|
-
background-color: var(--mpw-surface-pop-dark) !important;
|
|
17911
17981
|
}
|
|
17982
|
+
/* ①(2026-10-03 用户:"原本那种玻璃效果被你修没了"、"背景 token 跟那个重掉了,让他独立出来")
|
|
17983
|
+
兜底底**只刷宿主自己没有底色的弹层**(JS 扫 computed backgroundColor,α=0 才打 data-mpw-pop-bg);
|
|
17984
|
+
有宿主玻璃的弹层(标题栏子代理展开框 / VS Code 菜单 / 模型选择…)一个像素都不动。
|
|
17985
|
+
独立 token:--mpw-pop-surface(-dark)(不再共用面板表面 token --mpw-surface-pop)。 */
|
|
17986
|
+
html body [data-mpw-pop-bg] { background-color: var(--mpw-pop-surface) !important; }
|
|
17987
|
+
html body[data-ds-dark-theme] [data-mpw-pop-bg] { background-color: var(--mpw-pop-surface-dark) !important; }
|
|
17912
17988
|
[role="menu"] [class*="card"],
|
|
17913
17989
|
[role="tooltip"] [class*="card"] {
|
|
17914
17990
|
backdrop-filter: none !important;
|
|
@@ -24525,6 +24601,89 @@ body[data-mpw-modal] [role="dialog"] {
|
|
|
24525
24601
|
check();
|
|
24526
24602
|
}
|
|
24527
24603
|
|
|
24604
|
+
/* ①(2026-10-03 用户:"原本那种玻璃效果被你修没了" / "他那个设置背景的 token 好像跟那个重掉了,让他独立出来")
|
|
24605
|
+
弹层**兜底底**标记器:宿主自己把底画好了的弹层,我们**一个像素都不动**;只有下面两类才打
|
|
24606
|
+
`data-mpw-pop-bg`,由 CSS(独立 token `--mpw-pop-surface(-dark)` + `--mpw-pop-blur`)补一套玻璃:
|
|
24607
|
+
甲、宿主完全没画底(computed backgroundColor 的 α < 0.05,且近祖先也没有可见底)
|
|
24608
|
+
—— 用户第 3 条里「完全透明、文字重叠」的那几处(+ 菜单 / 权限 / 模型选择器);
|
|
24609
|
+
乙、宿主画的是**不透明纯底的小浮层**(α ≥ 0.999 且面积 < 40% 视口)
|
|
24610
|
+
—— 用户第 3⑥ 条「上下文用量圆环弹出来是纯白」那一处,换成玻璃。
|
|
24611
|
+
硬前提:**背后必须有模糊**(computed backdrop-filter 含 blur)才刷底色 ——
|
|
24612
|
+
否则半透明底 + 无模糊 = 字叠字,比原来更糟。模糊由我们(虚化弹层)或宿主提供,两者都算。
|
|
24613
|
+
开销:只在弹层插进 DOM 时(rAF 合并)扫一次,元素级 getComputedStyle 只落在命中项上。 */
|
|
24614
|
+
let popTagWatch = null;
|
|
24615
|
+
const MPW_POP_TAG_SEL = '[role="menu"], [role="listbox"], [role="combobox"], [class*="_menu"], [class*="_popover"], [class*="_dropdown"], [class*="_denseList"], [class*="JObwrW_panel"], [role="tooltip"]';
|
|
24616
|
+
const MPW_POP_TAG_NOT = /(Anchor|anchor|Icon|icon|Status|status|Item|_item|Row|_row|Trigger|_trigger|Card|_card|groupTitle|_viewport)/;
|
|
24617
|
+
/** 近祖先(≤4 层)里已经有可见底 ⇒ 真正的表面在上面,别在里面再叠一层。 */
|
|
24618
|
+
function mpwPopHasPaintedAncestor(el) {
|
|
24619
|
+
try {
|
|
24620
|
+
let p = el.parentElement, n = 0;
|
|
24621
|
+
while (p && n < 4 && p !== document.body && p !== document.documentElement) {
|
|
24622
|
+
let cs = null;
|
|
24623
|
+
try { cs = getComputedStyle(p); } catch (e) { break; }
|
|
24624
|
+
if (cs && cs.display !== "none" && mpwColorAlpha(cs.backgroundColor) >= 0.3) return true;
|
|
24625
|
+
p = p.parentElement; n++;
|
|
24626
|
+
}
|
|
24627
|
+
} catch (e) {}
|
|
24628
|
+
return false;
|
|
24629
|
+
}
|
|
24630
|
+
/** 小浮层(不是铺满视口的弹层/窗口)—— 不透明底的小面板才允许被换成玻璃。 */
|
|
24631
|
+
function mpwPopIsFloating(el) {
|
|
24632
|
+
try {
|
|
24633
|
+
const r = el.getBoundingClientRect();
|
|
24634
|
+
const vw = window.innerWidth || 1, vh = window.innerHeight || 1;
|
|
24635
|
+
if (!r || r.width <= 0 || r.height <= 0) return false;
|
|
24636
|
+
return r.width * r.height <= 0.4 * vw * vh && r.height <= vh * 0.9;
|
|
24637
|
+
} catch (e) { return false; }
|
|
24638
|
+
}
|
|
24639
|
+
function mpwTagPopoverBg() {
|
|
24640
|
+
let list = null;
|
|
24641
|
+
try { list = document.querySelectorAll(MPW_POP_TAG_SEL); } catch (e) { return; }
|
|
24642
|
+
if (!list) return;
|
|
24643
|
+
for (let i = 0; i < list.length; i++) {
|
|
24644
|
+
const el = list[i];
|
|
24645
|
+
let need = false;
|
|
24646
|
+
try {
|
|
24647
|
+
const cls = (typeof el.className === "string") ? el.className : "";
|
|
24648
|
+
if (!MPW_POP_TAG_NOT.test(cls)) {
|
|
24649
|
+
const cs = getComputedStyle(el);
|
|
24650
|
+
if (cs && cs.display !== "none" && cs.visibility !== "hidden"
|
|
24651
|
+
&& /blur\(/.test(String(cs.backdropFilter || cs.webkitBackdropFilter || ""))) {
|
|
24652
|
+
const a = mpwColorAlpha(cs.backgroundColor);
|
|
24653
|
+
if (a < 0.05) need = !mpwPopHasPaintedAncestor(el);
|
|
24654
|
+
else if (a >= 0.999) need = mpwPopIsFloating(el);
|
|
24655
|
+
}
|
|
24656
|
+
}
|
|
24657
|
+
} catch (e) { need = false; }
|
|
24658
|
+
try {
|
|
24659
|
+
if (need) { if (!el.hasAttribute("data-mpw-pop-bg")) el.setAttribute("data-mpw-pop-bg", ""); }
|
|
24660
|
+
else if (el.hasAttribute("data-mpw-pop-bg")) el.removeAttribute("data-mpw-pop-bg");
|
|
24661
|
+
} catch (e) {}
|
|
24662
|
+
}
|
|
24663
|
+
}
|
|
24664
|
+
function setupPopTagWatch() {
|
|
24665
|
+
if (popTagWatch !== null) return;
|
|
24666
|
+
let praf = 0;
|
|
24667
|
+
const prun = () => {
|
|
24668
|
+
praf = 0;
|
|
24669
|
+
try {
|
|
24670
|
+
// 左栏收起/展开与拖拽期间宿主每帧改 DOM:跳过(收尾还会再来一次)
|
|
24671
|
+
if (document.body && document.body.hasAttribute
|
|
24672
|
+
&& (document.body.hasAttribute("data-dsh-sidebar-dragging") || document.body.hasAttribute("data-dragging"))) return;
|
|
24673
|
+
mpwTagPopoverBg();
|
|
24674
|
+
} catch (e) {}
|
|
24675
|
+
};
|
|
24676
|
+
try {
|
|
24677
|
+
popTagWatch = new MutationObserver(() => {
|
|
24678
|
+
if (praf) return;
|
|
24679
|
+
try { praf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(prun) : setTimeout(prun, 16); }
|
|
24680
|
+
catch (e) { prun(); }
|
|
24681
|
+
});
|
|
24682
|
+
popTagWatch.observe(document.body, { childList: true, subtree: true });
|
|
24683
|
+
} catch { popTagWatch = null; }
|
|
24684
|
+
mpwTagPopoverBg();
|
|
24685
|
+
}
|
|
24686
|
+
|
|
24528
24687
|
// ①(新) 标题栏 backdrop blur 摘除:G 块给 header 加 blur(unifyAmount)(聊天区
|
|
24529
24688
|
// 不跟随时的标题栏虚化),但 header 内有 absolute 展开面板(子代理/后台任务,
|
|
24530
24689
|
// JObwrW_panel),backdrop-filter 会变成它们的 containing block → 面板定位错乱
|
package/lib/index.js
CHANGED
|
@@ -809,8 +809,12 @@ function normalizeScaleFlag(raw) {
|
|
|
809
809
|
* 顺序仍是"先缩放再抽帧"(编码量更小)。 */
|
|
810
810
|
function buildScaleFilter(maxW, fps, scaleFlag) {
|
|
811
811
|
const w = Number(maxW) > 0 ? Math.round(Number(maxW)) : 0;
|
|
812
|
+
/* ①(2026-10-03 ffmpeg 审计"奇数宽"条) `-2` 只保证**高**是偶数:宽是 `iw`(H.264 允许奇数显示宽,
|
|
813
|
+
靠裁剪位)或用户/屏幕推出来的 `maxW`(`Math.round(w*dpr)` 可以是奇数,如 1366×1.5=2049)。
|
|
814
|
+
一旦我们显式给了 `-pix_fmt yuv420p`(见 F2),奇数宽会直接**编码失败**;不给则 ffmpeg 可能
|
|
815
|
+
协商出非 4:2:0 格式。⇒ 宽也取偶数(`trunc(x/2)*2`),与高同一条纪律。 */
|
|
812
816
|
return w > 0
|
|
813
|
-
? "scale='min(" + w + ",iw)':-2" + (scaleFlag ? ':flags=' + scaleFlag : '') + ',fps=' + fps
|
|
817
|
+
? "scale='trunc(min(" + w + ",iw)/2)*2':-2" + (scaleFlag ? ':flags=' + scaleFlag : '') + ',fps=' + fps
|
|
814
818
|
: 'fps=' + fps;
|
|
815
819
|
}
|
|
816
820
|
/** ①(B) 转码缓存键:**只含影响播放的字段**(源标识 | mtime | fps | maxW | 缩放 flags)。
|
|
@@ -868,6 +872,13 @@ function pixFmtBitDepth(fmt) {
|
|
|
868
872
|
}
|
|
869
873
|
/** 8bit 一族(这些源**不需要**任何色彩参数 —— 实测忠实,且保持与改动前逐字节相同的产物)。 */
|
|
870
874
|
const TRANSCODE_8BIT_PIXFMT = /^(yuvj?420p|yuvj?422p|yuvj?444p|nv12|nv21|yuva420p|rgb24|bgr24|gray|pal8)$/i;
|
|
875
|
+
/** ①(2026-10-03 ffmpeg 审计 F2) **浏览器安全的白名单只有 4:2:0**(外加无色度的 gray/pal8):
|
|
876
|
+
* 8bit 的 `yuv422p` / `yuv444p` 源走旧计划是 `passthrough`(不加 `-pix_fmt`)⇒ libx264 会**顺着源**
|
|
877
|
+
* 产出 `High 4:2:2 / High 4:4:4`,而 `browserCodecGap()` 恰恰把这两种 profile 判成"浏览器吃不下"
|
|
878
|
+
* ⇒ 我们**为了救它而转码,却转出一个同样放不出的文件**,而且它是个"有效缓存"(≥1KB + ftyp)
|
|
879
|
+
* 永久命中 ⇒ 之后一直是黑屏、也不会再重转(除非删缓存)。所以:非 4:2:0 一律显式降 `yuv420p`。
|
|
880
|
+
* `rgb24/bgr24` 同理(x264 无 RGB 输入)⇒ 一起收进"要降"的一侧。 */
|
|
881
|
+
const TRANSCODE_SAFE_420 = /^(yuvj?420p|nv12|nv21|yuva420p|gray|pal8)$/i;
|
|
871
882
|
/** HDR 传输函数(PQ / HLG / 2020 系):这些 transfer 在 8bit SDR 显示链上没有正确解释。 */
|
|
872
883
|
const TRANSCODE_HDR_TRANSFER = /^(smpte2084|arib-std-b67|smpte428|bt2020-10|bt2020-12)$/i;
|
|
873
884
|
/** 探测结果 → 转码色彩计划(**纯函数**,门禁直接切片断言;不改文件、不起进程)。
|
|
@@ -886,8 +897,12 @@ function transcodeColorPlan(info) {
|
|
|
886
897
|
if (TRANSCODE_COLOR_MODE === 'force8') return { mode: 'force8', downgrade: true, deep, hdr, from, to, args: downgradeArgs, reason: 'DSH_WE_TRANSCODE_COLOR=force8:强制 8bit bt709(现场对比用)' };
|
|
887
898
|
// 不知道(探测失败/空 pix_fmt)⇒ 保持旧行为,但**记账**
|
|
888
899
|
if (!pixFmt) return { mode: 'unknown', downgrade: false, deep: false, hdr, from, to, args: [], reason: '拿不到源 pix_fmt(探测失败)⇒ 不加色彩参数(与改动前一致),台账记 unknown' };
|
|
889
|
-
|
|
890
|
-
|
|
900
|
+
/* ①(F2) 只有"8bit + SDR + 4:2:0"才允许 passthrough(与改动前逐字节相同);
|
|
901
|
+
非 4:2:0 的 8bit 源必须显式降 4:2:0(否则转出 4:2:2/4:4:4 profile = 浏览器仍放不出,
|
|
902
|
+
却被当作有效缓存永久命中)。 */
|
|
903
|
+
const chromaUnsafe = !!pixFmt && !TRANSCODE_SAFE_420.test(pixFmt);
|
|
904
|
+
if (!deep && !hdr && !chromaUnsafe) return { mode: 'passthrough', downgrade: false, deep, hdr, chroma: false, from, to, args: [], reason: '8bit SDR 4:2:0:实测转码忠实(标签/像素都不变)⇒ 不加参数,产物与改动前逐字节相同' };
|
|
905
|
+
return { mode: 'downgrade', downgrade: true, deep, hdr, chroma: chromaUnsafe, from, to, args: downgradeArgs, reason: (deep ? ('源 ' + depth + 'bit') : '') + (deep && hdr ? ' + ' : '') + (hdr ? ('HDR transfer ' + transfer) : '') + (chromaUnsafe ? ((deep || hdr ? ' + ' : '') + '源 ' + pixFmt + ' 非 4:2:0') : '') + ' ⇒ 降为 8bit 4:2:0 bt709/tv' + ((deep || hdr) ? '(无 tonemap:ffmpeg-static 无 zscale,偏暗但能播)' : '') };
|
|
891
906
|
}
|
|
892
907
|
// ═══ MPW-COLOR-PLAN-END ═══
|
|
893
908
|
/* ═══ MPW-TRANSCODE-GEN-BEGIN(H1 代际/归属/有效性;纯函数 + 三个小状态操作) ═══ */
|
|
@@ -991,10 +1006,20 @@ const BROWSER_OK_CONTAINER = /^(mov|mp4|m4a|3gp|3g2|mj2|matroska|webm)/i;
|
|
|
991
1006
|
* · h264 + opus 装进 MP4:Chromium 系不支持(Opus 在 MP4 里只有 WebM/ogg 容器被支持);
|
|
992
1007
|
* · HEVC Main10(10bit):多数平台无 10bit HEVC 解码(8bit Main 在 Safari/部分 Chromium 上可)。
|
|
993
1008
|
* 只用于"源本身就不该直读"的判断;命中即视为需要转码。 */
|
|
994
|
-
function browserCodecGap(video, audio,
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1009
|
+
function browserCodecGap(video, audio, container, profile) {
|
|
1010
|
+
/* ①(2026-10-03 ffmpeg 审计 F6) **参数拆分**:旧签名 `(video, audio, fmt)` 里 `fmt` 同时被当
|
|
1011
|
+
**容器**(h264+opus 那条)和**profile**(Main10 那条)用,而唯一调用点传的是 `videoProfile`
|
|
1012
|
+
⇒ 容器永远缺席:MKV/WebM 里的 h264+opus(本该被豁免)被判"mp4 容器里的 h264+opus"
|
|
1013
|
+
⇒ 白转一次码,`verdict.reason` 也是错的。现在容器与 profile 各是各的参数。
|
|
1014
|
+
兼容:第三个参数若是 `High 10`/`Main 10` 这类 **profile 串**(老调用点/老测试),
|
|
1015
|
+
仍按 profile 解释(容器判据退化为"未知",不误判成 mp4)。 */
|
|
1016
|
+
const looksProfile = (v) => /^\s*(high|main|baseline|constrained|profile|simple|advanced|professional)/i.test(String(v || ''));
|
|
1017
|
+
let cont = String(container || '');
|
|
1018
|
+
let prof = String(profile || '');
|
|
1019
|
+
if (prof === '' && looksProfile(cont)) { prof = cont; cont = ''; }
|
|
1020
|
+
const afmt = cont;
|
|
1021
|
+
if (video === 'h264' && audio === 'opus' && afmt !== '' && !/webm|matroska/i.test(afmt)) return 'mp4 容器里的 h264+opus(Chromium 不支持该组合)';
|
|
1022
|
+
if (video === 'hevc' && /main ?10|main10/i.test(prof)) return 'HEVC Main10(10bit,多数平台无解码器)';
|
|
998
1023
|
/* ①(2026-09-28 H2 色彩轮) **H.264 的高位深 profile**(High 10 / High 4:2:2 / High 4:4:4):
|
|
999
1024
|
主流浏览器**没有** 10bit H.264 解码器(Chromium/Firefox 只到 High 8bit;Safari/VideoToolbox
|
|
1000
1025
|
视平台),而本模块此前只认 HEVC Main10 ⇒ 一个 **H.264 High 10** 源会被判"可直读"、
|
|
@@ -1003,9 +1028,14 @@ function browserCodecGap(video, audio, fmt) {
|
|
|
1003
1028
|
仍是 `High 10 / yuv420p10le` ⇒ 位深策略(`transcodeColorPlan`)必须与这条判据配套。
|
|
1004
1029
|
置信度与既有 HEVC Main10 一条同级("多数平台无解码器"),不猜;转码若失败客户端仍有
|
|
1005
1030
|
回退原片的一次性路径(`mpwFallbackToDirect`)。 */
|
|
1006
|
-
if (video === 'h264' && /high 10|high 4:2:2|high 4:4:4|high 10 intra|high 422|high 444/i.test(
|
|
1007
|
-
return 'H.264 ' +
|
|
1031
|
+
if (video === 'h264' && /high 10|high 4:2:2|high 4:4:4|high 10 intra|high 422|high 444/i.test(prof)) {
|
|
1032
|
+
return 'H.264 ' + prof.trim() + '(10bit/高位深 profile,浏览器多数无解码器)';
|
|
1008
1033
|
}
|
|
1034
|
+
/* ①(2026-10-03 ffmpeg 审计 F2 后半) VP9 / AV1 的**高位深/高色度** profile 同样没有浏览器解码器,
|
|
1035
|
+
旧表只认 h264/hevc ⇒ 这些源被判"可直读"、直接喂浏览器(黑屏),转码这条唯一能救的路没走。
|
|
1036
|
+
ffprobe 对 VP9 报 `Profile 0/1/2/3`、对 AV1 报 `Main/High/Professional`。 */
|
|
1037
|
+
if (video === 'vp9' && /profile\s*(1|3)/i.test(prof)) return 'VP9 Profile 1/3(4:2:2 / 4:4:4,浏览器无解码器)';
|
|
1038
|
+
if (video === 'av1' && /high|professional/i.test(prof)) return 'AV1 High/Professional(4:2:2 / 4:4:4 / 12bit,浏览器无解码器)';
|
|
1009
1039
|
return '';
|
|
1010
1040
|
}
|
|
1011
1041
|
/** 给定探测结果 → { playable: true|false|null, reason }。
|
|
@@ -1020,7 +1050,8 @@ function browserPlayable(info) {
|
|
|
1020
1050
|
if (!BROWSER_OK_VIDEO.has(v)) return { playable: false, reason: '视频编码 ' + v + ' 不在浏览器白名单(h264/vp8/vp9/av1)' };
|
|
1021
1051
|
if (a && !BROWSER_OK_AUDIO.has(a)) return { playable: false, reason: '音频编码 ' + a + ' 不在浏览器白名单(aac/mp3/opus/vorbis/flac)' };
|
|
1022
1052
|
if (!BROWSER_OK_CONTAINER.test(String(fmt))) return { playable: false, reason: '容器 ' + fmt + ' 不是 mp4/webm' };
|
|
1023
|
-
|
|
1053
|
+
/* ①(F6) 容器与 profile 分开传(旧版把 profile 当容器用) */
|
|
1054
|
+
const gap = browserCodecGap(v, a, fmt, info.videoProfile);
|
|
1024
1055
|
if (gap) return { playable: false, reason: gap };
|
|
1025
1056
|
return { playable: true, reason: v + (a ? '+' + a : '') + ' / ' + fmt + '(浏览器可直读)' };
|
|
1026
1057
|
}
|
|
@@ -1967,10 +1998,17 @@ function materializeSource(src, cacheDir) {
|
|
|
1967
1998
|
while (w < n) w += writeSync(outFd, buf, w, n - w);
|
|
1968
1999
|
off += n;
|
|
1969
2000
|
}
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
|
|
2001
|
+
} catch (e) {
|
|
2002
|
+
/* ①(2026-10-03 ffmpeg 审计 F7) 拷贝中途失败(ENOSPC / 读源出错)时**必须**把半成品删掉:
|
|
2003
|
+
旧写法只有 `finally` 关句柄 ⇒ 部分写出的 `src_*.bin` 留在缓存目录里等着"1 小时后才回收",
|
|
2004
|
+
而磁盘正是满的 ⇒ 重试继续失败,用户只看到"转码失败"。 */
|
|
2005
|
+
try { closeSync(fd); } catch { /* 忽略 */ }
|
|
2006
|
+
try { closeSync(outFd); } catch { /* 忽略 */ }
|
|
2007
|
+
try { unlinkSync(tmp); } catch { /* 忽略 */ }
|
|
2008
|
+
throw e;
|
|
1973
2009
|
}
|
|
2010
|
+
try { closeSync(fd); } catch { /* 忽略 */ }
|
|
2011
|
+
try { closeSync(outFd); } catch { /* 忽略 */ }
|
|
1974
2012
|
return { file: tmp, cleanup: () => { try { unlinkSync(tmp); } catch { /* 忽略 */ } } };
|
|
1975
2013
|
}
|
|
1976
2014
|
|
|
@@ -1979,7 +2017,7 @@ function materializeSource(src, cacheDir) {
|
|
|
1979
2017
|
* (实测 0xFFFFFFEA / EPERM),故先试 detached 再试普通 spawn。
|
|
1980
2018
|
* ①(2026-09-28 H1) `jobKey` = 本子进程所属的产物路径:登记进 `ACTIVE_FFMPEG`(proc→jobKey),
|
|
1981
2019
|
* 取消时**只杀本任务**的进程(旧实现按"本请求之后启动的全部"过滤,会误杀别的档/别的源)。 */
|
|
1982
|
-
function spawnFfmpeg(ff, args, jobKey) {
|
|
2020
|
+
function spawnFfmpeg(ff, args, jobKey, deadline) {
|
|
1983
2021
|
return new Promise((resolve, reject) => {
|
|
1984
2022
|
const errLog = join(transcodeCacheDir(), 'ffmpeg-err-' + process.pid + '-' + Date.now() + '.log');
|
|
1985
2023
|
const attempts = [
|
|
@@ -2031,7 +2069,7 @@ function spawnFfmpeg(ff, args, jobKey) {
|
|
|
2031
2069
|
clearTimeout(timer);
|
|
2032
2070
|
ACTIVE_FFMPEG.delete(proc);
|
|
2033
2071
|
if (errFd) { try { closeSync(errFd); } catch { /* 忽略 */ } }
|
|
2034
|
-
let detail = msg || 'ffmpeg failed';
|
|
2072
|
+
let detail = (msg && msg.message) ? msg.message : (msg || 'ffmpeg failed');
|
|
2035
2073
|
try {
|
|
2036
2074
|
const t = readFileSync(errLog, 'utf8').trim();
|
|
2037
2075
|
if (t) detail += ' | stderr: ' + t.split('\n').slice(-4).join(' | ');
|
|
@@ -2048,12 +2086,18 @@ function spawnFfmpeg(ff, args, jobKey) {
|
|
|
2048
2086
|
if (msg) errors.push(msg);
|
|
2049
2087
|
runNext();
|
|
2050
2088
|
};
|
|
2089
|
+
/* ①(2026-10-03 ffmpeg 审计 F4) 超时按**任务剩余时间**算(`deadline` = runTranscode 给的任务级
|
|
2090
|
+
截止时刻),而不是"每次尝试各给 15 分钟"。旧写法下一次超时会被编码器循环当成"换个编码器
|
|
2091
|
+
再试"⇒ libx264 15min + libsvtav1 15min + 各自单线程再来一遍 = 最多 1 小时,全程占着
|
|
2092
|
+
`TRANSCODE_MAX_ACTIVE=1` 的唯一名额(与 docs/TRANSCODE-RESOURCE.md 的"单任务硬超时"不符)。 */
|
|
2093
|
+
const budget = Number.isFinite(deadline) ? Math.max(0, deadline - Date.now()) : TRANSCODE_TIMEOUT_MS;
|
|
2051
2094
|
const timer = setTimeout(() => {
|
|
2052
2095
|
timedOut = true;
|
|
2053
2096
|
try { proc.kill(); } catch { /* 忽略 */ }
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2097
|
+
const err = new Error(a.name + ' timed out after ' + TRANSCODE_TIMEOUT_MS + 'ms (killed)');
|
|
2098
|
+
err.taskTimedOut = true; // ← 任务级超时:runTranscode 拿到它就**不再换编码器/线程模式**
|
|
2099
|
+
finalReject(err);
|
|
2100
|
+
}, budget);
|
|
2057
2101
|
proc.on('error', (err) => {
|
|
2058
2102
|
// spawn 阶段的 'error'(如 EPERM)→ 重试下一个 attempt;执行中 error → 直接失败
|
|
2059
2103
|
if (!done && timedOut) { finalReject(a.name + ' spawn error ' + (err && err.code ? err.code + ' ' + err.message : err)); return; }
|
|
@@ -2095,6 +2139,9 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
|
|
|
2095
2139
|
throw new Error('ffmpeg not found (set DSH_WE_FFMPEG, install ffmpeg to PATH, or click 下载 ffmpeg → /ffmpeg-download)');
|
|
2096
2140
|
}
|
|
2097
2141
|
job.ff = ff.path;
|
|
2142
|
+
/* ①(2026-10-03 ffmpeg 审计 F4) **任务级**截止时刻:整个 runTranscode(含换编码器/换线程模式
|
|
2143
|
+
的全部尝试)共用这一个预算,超时即终态。 */
|
|
2144
|
+
const deadline = Date.now() + TRANSCODE_TIMEOUT_MS;
|
|
2098
2145
|
// ①(新) 降分辨率:`scale='min(maxW,iw)':-2` 保持宽高比、宽不超过 maxW(-2=高自动偶数)。
|
|
2099
2146
|
// 与 fps 合并到一个 -vf 链(先缩放再抽帧,编码量更小)。
|
|
2100
2147
|
// ①(2026-09-23 B) vf 链构造收敛到 buildScaleFilter(预缩档 = maxW + flags=lanczos;
|
|
@@ -2121,6 +2168,11 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
|
|
|
2121
2168
|
if (mode.label === 'mt' && futexSeen) continue; // 已知 futex 问题 → 跳过多线程
|
|
2122
2169
|
for (const enc of encoders) {
|
|
2123
2170
|
if (cancelled()) throw new Error('cancelled'); // ⓪ 换编码器前也要查
|
|
2171
|
+
if (Date.now() >= deadline) { // ①(F4) 预算已尽 ⇒ 不再起新进程
|
|
2172
|
+
const te = new Error('已到任务级超时(' + TRANSCODE_TIMEOUT_MS + 'ms),停止后续尝试');
|
|
2173
|
+
te.taskTimedOut = true;
|
|
2174
|
+
throw te;
|
|
2175
|
+
}
|
|
2124
2176
|
job.encoder = enc.name + (mode.label === 'st' ? ' (单线程)' : '');
|
|
2125
2177
|
// -f mp4 显式指定:临时输出路径不是 .mp4 后缀,ffmpeg 无法按扩展名选 muxer
|
|
2126
2178
|
// ①(Qoder 审查 M5) 单线程时输出侧再加 -threads 1 控制编码线程
|
|
@@ -2129,12 +2181,20 @@ async function runTranscode(srcFile, out, fps, maxW, job, isCancelled, scaleFlag
|
|
|
2129
2181
|
// ⓪(2026-09-17) x264 额外参数(默认空;见 TRANSCODE_X264_PARAMS 注释:省内存但费码率)
|
|
2130
2182
|
const x264 = (enc.name === 'libx264' && TRANSCODE_X264_PARAMS) ? ['-x264-params', TRANSCODE_X264_PARAMS] : [];
|
|
2131
2183
|
// ①(2026-09-28 H2) `colorArgs`:>8bit/HDR 源才带(8bit 源为空数组 ⇒ 产物与改动前逐字节相同)
|
|
2132
|
-
|
|
2133
|
-
|
|
2184
|
+
/* ①(2026-10-03 ffmpeg 审计 F3) 旧命令无条件 `-an`("壁纸默认静音更省")——但本插件的
|
|
2185
|
+
**壁纸声音**是一等功能(`applyVideoMute` / NOW PLAYING 音量都落在这个 `<video>` 上):
|
|
2186
|
+
用户开了声音、又设了 fpsCap/resMax/预缩(或 ≥4K 源触发静默自动降档)⇒ 转码产物**没有音轨**
|
|
2187
|
+
⇒ 壁纸突然没声,且没有任何提示。现在改为**保留音轨**(aac 128k;源本来没音轨时
|
|
2188
|
+
ffmpeg 的默认流选择不会凭空造一条,`-c:a` 也就用不上)。 */
|
|
2189
|
+
const audioArgs = ['-c:a', 'aac', '-b:a', '128k'];
|
|
2190
|
+
const full = [...mode.extra, ...base, ...enc.tail, ...x264, ...(colorArgs || []), ...(mode.label === 'st' ? ['-threads', '1'] : []), '-movflags', '+faststart', ...audioArgs, '-f', 'mp4', out];
|
|
2191
|
+
await spawnFfmpeg(ff.path, full, jobKey, deadline);
|
|
2134
2192
|
return;
|
|
2135
2193
|
} catch (err) {
|
|
2136
2194
|
lastErr = err;
|
|
2137
2195
|
try { unlinkSync(out); } catch { /* 忽略 */ }
|
|
2196
|
+
/* ①(F4) 任务级超时 = 终态:不再换编码器、不再换线程模式(旧写法把超时也当成"重试下一个")。 */
|
|
2197
|
+
if (err && err.taskTimedOut) throw err;
|
|
2138
2198
|
// ①(Qoder 审查) futex 判定用精确报错原文匹配,避免误伤:文件名/路径
|
|
2139
2199
|
// 含 "futex"(如用户 mpkg 名 futex_test.mpkg)的打开错误也会被宽松
|
|
2140
2200
|
// /futex/i 命中 → 误置 TRANSCODE_FUTEX_FALLBACK 永久单线程(性能损失)。
|
|
@@ -2315,6 +2375,13 @@ async function transcodeToFps(src, fps, maxW, isCancelled, scaleFlag) {
|
|
|
2315
2375
|
return p;
|
|
2316
2376
|
}
|
|
2317
2377
|
|
|
2378
|
+
/* ①(2026-10-03 ffmpeg 审计 F1) `maxW` 的**唯一解析点**:`/transcode`(写入键)与
|
|
2379
|
+
`/transcode-progress`(查询键)必须逐字一致 —— 两侧各写一份正是"进度永远 idle"的根因。 */
|
|
2380
|
+
function parseMaxWParam(raw) {
|
|
2381
|
+
const n = Number(raw);
|
|
2382
|
+
return Number.isFinite(n) && n > 0 ? Math.min(3840, Math.round(n)) : 0;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2318
2385
|
/** 转码/下载进度 JSON(/transcode-progress 与 /transcode 的 progress 子路径共用)。 */
|
|
2319
2386
|
function handleTranscodeProgress(req, res) {
|
|
2320
2387
|
try {
|
|
@@ -2322,10 +2389,17 @@ function handleTranscodeProgress(req, res) {
|
|
|
2322
2389
|
const file = (url.searchParams.get('file') || '').trim();
|
|
2323
2390
|
const srcParam = (url.searchParams.get('src') || '').trim();
|
|
2324
2391
|
const fps = Number(url.searchParams.get('fps')) || 0;
|
|
2325
|
-
const
|
|
2326
|
-
const maxW = Number.isFinite(maxWRaw) && maxWRaw > 0 ? Math.min(3840, Math.round(maxWRaw)) : 0;
|
|
2392
|
+
const maxW = parseMaxWParam(url.searchParams.get('maxW'));
|
|
2327
2393
|
// ①(B) 查询键与写入键一致(含缩放 flags);未给 flags ⇒ 与改动前逐字相同的键。
|
|
2328
2394
|
const scaleFlag = normalizeScaleFlag(url.searchParams.get('scale')) || '';
|
|
2395
|
+
/* ①(2026-10-03 ffmpeg 审计 F1) **查询键必须与写入键同源**:`/transcode` 没拿到 maxW 时会套
|
|
2396
|
+
`TRANSCODE_DEFAULT_MAXW`(写入键 = `srcId|fps|1920`),而这里旧写法直接用 raw maxW=0
|
|
2397
|
+
⇒ 查 `srcId|fps|0`(永远 idle)。后果有两级:①忙条读到 0% 一直到 2 分钟超时才消失;
|
|
2398
|
+
②客户端"转码完成后重新 apply"的 10s 轮询等的是 `phase==='done'`,永远等不到 ⇒ 源被判
|
|
2399
|
+
"不可直读"(HEVC Main10 / H.264 High 10 之类)时**卡在回退的原片上黑屏**,而磁盘上其实
|
|
2400
|
+
早就有转好的产物。⇒ 这里按同一条默认规则算出 effMaxW 作为首选键。 */
|
|
2401
|
+
const reqMaxW = maxW > 0 ? maxW : TRANSCODE_DEFAULT_MAXW;
|
|
2402
|
+
const effMaxW = (TRANSCODE_DEFAULT_MAXW > 0 && reqMaxW > TRANSCODE_DEFAULT_MAXW) ? TRANSCODE_DEFAULT_MAXW : reqMaxW;
|
|
2329
2403
|
let job = null;
|
|
2330
2404
|
// ②(fix) src 优先(与 /transcode 同款):客户端回退原片后轮询转码完成,
|
|
2331
2405
|
// 用原始 image 串查询(旧式 file 兼容保留)。
|
|
@@ -2335,8 +2409,10 @@ function handleTranscodeProgress(req, res) {
|
|
|
2335
2409
|
const srcId = src.type === 'mpkg' ? 'mpkg:' + src.rec.path + ':' + src.entry.index : srcPath;
|
|
2336
2410
|
// ①(Qoder 审查 R7) 查询 key 与写入一致(含 maxW)——此前写入
|
|
2337
2411
|
// srcId|fps|maxW、查询 srcId|fps,设了 maxW 的任务进度永远查不到(idle)。
|
|
2338
|
-
const
|
|
2339
|
-
job = transcodeJobs.get(
|
|
2412
|
+
const keyOf = (w) => srcId + '|' + fps + '|' + w + ((w > 0 && scaleFlag) ? '|s:' + scaleFlag : '');
|
|
2413
|
+
job = transcodeJobs.get(keyOf(effMaxW)) // ← 与 /transcode 的写入键同源(含默认降采样)
|
|
2414
|
+
|| transcodeJobs.get(keyOf(maxW)) // 兼容:显式 maxW 的老键
|
|
2415
|
+
|| transcodeJobs.get(srcId + '|' + fps + '|' + effMaxW)
|
|
2340
2416
|
|| transcodeJobs.get(srcId + '|' + fps + '|' + maxW)
|
|
2341
2417
|
|| transcodeJobs.get(srcId + '|' + fps) || null;
|
|
2342
2418
|
} else if (ffmpegDownloadProgress) {
|
|
@@ -4807,8 +4883,7 @@ function apply(ctx) {
|
|
|
4807
4883
|
const fpsRaw = Number(url.searchParams.get('fps'));
|
|
4808
4884
|
const fps = Number.isFinite(fpsRaw) && ALLOWED_FPS.includes(fpsRaw) ? fpsRaw : 0;
|
|
4809
4885
|
if (!fps) { json(res, 400, { ok: false, error: 'invalid fps (allowed: 24/30/48/60)' }); return; }
|
|
4810
|
-
const
|
|
4811
|
-
const maxW = Number.isFinite(maxWRaw) && maxWRaw > 0 ? Math.min(3840, Math.round(maxWRaw)) : 0;
|
|
4886
|
+
const maxW = parseMaxWParam(url.searchParams.get('maxW'));
|
|
4812
4887
|
// ①(2026-09-23 B) 预缩档:`scale=lanczos`(白名单外 ⇒ 400,不把任意串拼进 -vf)。
|
|
4813
4888
|
// 它只影响**缩放滤波器**;目标宽仍由 maxW 决定(客户端按屏幕物理宽算 maxW)。
|
|
4814
4889
|
const scaleFlag = normalizeScaleFlag(url.searchParams.get('scale'));
|
package/package.json
CHANGED