dsh-mpkg-wallpaper 3.7.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +76 -39
  2. package/lib/index.js +169 -18
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1483,6 +1483,50 @@ window.__ModuleLoader__.load({
1483
1483
  // ⑤(新) Edge 兼容:Edge 上画到 canvas(视频元素不可见,避开悬浮工具栏)
1484
1484
  if (IS_EDGE) { try { showVideoEdge(playUrl, bgElements().video); } catch { showVideoEl(playUrl); } }
1485
1485
  else showVideoEl(playUrl);
1486
+ // ②(fix) 转码加载超时兜底(用户实测:60fps 源 + fpsCap=60 → host 白转码
1487
+ // 几分钟 → video 一直 loading 白屏;切回无限制才加载)。现在 host 端
1488
+ // 已做"无需转码直返原片",但真需要转码(如 4K→1080p)时仍可能慢——
1489
+ // 10s 内未进入可播放状态 → 切回原片播放(host 转码继续,完成缓存后
1490
+ // 自动切回转码产物)。只对 useTranscode 生效且只回退一次。
1491
+ if (useTranscode && vid && !vid.__mpwTransLoadingTimeout) {
1492
+ const clearTransTimeout = () => { try { clearTimeout(vid.__mpwTransLoadingTimeout); vid.__mpwTransLoadingTimeout = null; } catch {} };
1493
+ vid.addEventListener("canplay", clearTransTimeout, { once: true });
1494
+ vid.addEventListener("playing", clearTransTimeout, { once: true });
1495
+ vid.__mpwTransLoadingTimeout = setTimeout(() => {
1496
+ if (gen !== bgGen) return;
1497
+ if (vid.__mpwTransFallback) return;
1498
+ if (vid.readyState >= 2) return; // 已能播放,不切
1499
+ try {
1500
+ console.warn("[dsh-mpkg-wallpaper] 转码 10s 未就绪 → 回退原片", String(hostUrl).slice(0, 100));
1501
+ vid.__mpwTransFallback = true;
1502
+ try { setHint(t("transcode.fallback")); } catch {}
1503
+ vid.removeAttribute("src");
1504
+ vid.src = hostUrl;
1505
+ vid.load();
1506
+ try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
1507
+ // ②(fix) 转码完成自动切换:回退后轮询 host 转码进度,
1508
+ // done → 重新 apply(此时 /transcode 命中缓存,秒开转码产物)。
1509
+ // 只轮询到转码完成或超时(上限 3 分钟,避免无限轮询)。
1510
+ let pollTries = 0;
1511
+ const poll = setInterval(() => {
1512
+ if (gen !== bgGen) { clearInterval(poll); return; }
1513
+ if (++pollTries > 18) { clearInterval(poll); return; } // 3min
1514
+ try {
1515
+ fetch(HOST_BASE + "/transcode-progress?src=" + encodeURIComponent(image) + "&fps=" + transcodeFps + (resMaxN > 0 ? "&maxW=" + resMaxN : ""))
1516
+ .then((r) => r.ok ? r.json() : null)
1517
+ .then((j) => {
1518
+ if (gen !== bgGen) return;
1519
+ if (j && j.phase === "done") {
1520
+ clearInterval(poll);
1521
+ console.log("[dsh-mpkg-wallpaper] 转码完成 → 重新应用(命中缓存)");
1522
+ try { applyFromStorage(); } catch {}
1523
+ }
1524
+ }).catch(() => {});
1525
+ } catch {}
1526
+ }, 10000);
1527
+ } catch {}
1528
+ }, 10000);
1529
+ }
1486
1530
  } else {
1487
1531
  // ①(修正) 重启竞态兜底:dsh 重启后浏览器可能先于 host 就绪请求 media → 404;
1488
1532
  // 挂 onerror 自动重试(最多 5 次、间隔递增),host 恢复后壁纸自动回来。
@@ -1608,8 +1652,10 @@ window.__ModuleLoader__.load({
1608
1652
  try { setupHeaderBlurWatch(); } catch {}
1609
1653
  try { setupAquaThemeWatch(); } catch {}
1610
1654
  // ①(新) 主题颜色:body 门控 + CSS 变量(控制侧边栏/标题栏/新会话/设置弹窗底色)
1655
+ // ①(Qoder 审查 R3) 总开关关闭时还原默认外观:themeColor/accent 的
1656
+ // body 属性与 CSS 变量也受 enabled 门控(关 = 移除 tint,DSH 原色)。
1611
1657
  try {
1612
- const tc = section.themeColor || "";
1658
+ const tc = enabled && section.themeColor || "";
1613
1659
  if (tc && /^#[0-9a-fA-F]{6}$/.test(tc)) {
1614
1660
  document.body.setAttribute("data-mpw-theme", "");
1615
1661
  document.documentElement.style.setProperty("--mpw-theme-color", tc);
@@ -1618,7 +1664,7 @@ window.__ModuleLoader__.load({
1618
1664
  document.documentElement.style.removeProperty("--mpw-theme-color");
1619
1665
  }
1620
1666
  // ①(新) 配色(accent):品牌交互元素(按钮/滑条/选中/链接/发送键)
1621
- const ac = section.accent || "";
1667
+ const ac = enabled && section.accent || "";
1622
1668
  if (ac && /^#[0-9a-fA-F]{6}$/.test(ac)) {
1623
1669
  document.body.setAttribute("data-mpw-accent", "");
1624
1670
  document.documentElement.style.setProperty("--mpw-accent-color", ac);
@@ -1637,10 +1683,6 @@ window.__ModuleLoader__.load({
1637
1683
  || (section.aquaTint !== void 0 ? !!section.aquaTint : DEFAULT_AQUA_TINT);
1638
1684
  if (headerTint) document.body.setAttribute("data-mpw-aqua-header", "");
1639
1685
  else document.body.removeAttribute("data-mpw-aqua-header");
1640
- // ①(新) 配色(accent,品牌交互元素):与主题颜色分工
1641
- if (section.accent && /^#[0-9a-fA-F]{6}$/.test(section.accent))
1642
- document.body.setAttribute("data-mpw-accent", "");
1643
- else document.body.removeAttribute("data-mpw-accent");
1644
1686
  if (section.aquaTextEnhance !== void 0 ? !!section.aquaTextEnhance : DEFAULT_AQUA_TEXT_ENHANCE)
1645
1687
  document.body.setAttribute("data-mpw-aqua-text", "");
1646
1688
  else document.body.removeAttribute("data-mpw-aqua-text");
@@ -2284,6 +2326,27 @@ window.__ModuleLoader__.load({
2284
2326
  // 侧边栏/聊天区的根元素上(否则设置弹窗会被"关"在侧边栏里,①),
2285
2327
  // 只加在内部滚动容器上(regionArea / scrollBody / details body)。
2286
2328
  // ═══════════════════════════════════════════════════════════════════
2329
+ // ①(Qoder 审查 R1) themeColor 独立块提取为单函数,两分支共用:
2330
+ // 不依赖「标题栏透出/侧边栏透出」开关,只要设置了主题颜色就 tint
2331
+ // 侧边栏/标题栏/新会话按钮/设置弹窗。此前两处逐字重复,第4项再改
2332
+ // 时漏改一处(fa2e0e6 就是补漏 commit)——集中到一处避免再犯。
2333
+ function buildThemeColorCss() {
2334
+ return `
2335
+ body[data-mpw-theme] .pI_x6G_sidebarCol {
2336
+ background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2337
+ }
2338
+ body[data-mpw-theme] .wSkVaW_header {
2339
+ background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2340
+ }
2341
+ body[data-mpw-theme] button[class*="newSession"] {
2342
+ background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2343
+ }
2344
+ body[data-mpw-theme] [class*="settingsArea"] [role="dialog"],
2345
+ body[data-mpw-theme] [class*="settingsArea"] [role="alertdialog"] {
2346
+ background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2347
+ }
2348
+ `;
2349
+ }
2287
2350
  function buildCss(section) {
2288
2351
  // ③(修正) 总开关关闭时统一视为无背景:所有路径(applyFromStorage/previewCss)
2289
2352
  // 都走同一逻辑,避免拖动滑块时 previewCss 绕过 enabled 检查把壁纸又显示出来
@@ -2436,21 +2499,7 @@ body:not([data-ds-dark-theme]) .mpw_dialog {
2436
2499
  `;
2437
2500
  // ①(修正) 主题颜色(themeColor) 独立块(第4项):无壁纸分支也输出——
2438
2501
  // 不依赖透出开关,只要设置了主题颜色就 tint 侧边栏/标题栏/新会话/设置弹窗。
2439
- css += `
2440
- body[data-mpw-theme] .pI_x6G_sidebarCol {
2441
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2442
- }
2443
- body[data-mpw-theme] .wSkVaW_header {
2444
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2445
- }
2446
- body[data-mpw-theme] button[class*="newSession"] {
2447
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2448
- }
2449
- body[data-mpw-theme] [class*="settingsArea"] [role="dialog"],
2450
- body[data-mpw-theme] [class*="settingsArea"] [role="alertdialog"] {
2451
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
2452
- }
2453
- `;
2502
+ css += buildThemeColorCss();
2454
2503
  return css + buildUiCss(section, false);
2455
2504
  }
2456
2505
  css += `
@@ -3155,23 +3204,8 @@ body[data-ds-dark-theme] [data-mpw-lg-css]:not([class*="sidebarCol"]):not([class
3155
3204
  // 「侧边栏透出」开关,只要设置了主题颜色就 tint 侧边栏/标题栏/新会话按钮/
3156
3205
  // 设置弹窗。放在 buildCss 最末尾(最后追加 → 覆盖前面所有同特异性规则,
3157
3206
  // 包括透出开关的纯色/透明分支),由 body[data-mpw-theme] 门控。
3158
- css += `
3159
- /* 主题颜色(themeColor):整体底色 tint(独立于透出开关,第4项) */
3160
- body[data-mpw-theme] .pI_x6G_sidebarCol {
3161
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
3162
- }
3163
- body[data-mpw-theme] .wSkVaW_header {
3164
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
3165
- }
3166
- body[data-mpw-theme] button[class*="newSession"] {
3167
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
3168
- }
3169
- /* 设置弹窗:覆盖不透明兜底(同特异性后定义者胜 → 生效) */
3170
- body[data-mpw-theme] [class*="settingsArea"] [role="dialog"],
3171
- body[data-mpw-theme] [class*="settingsArea"] [role="alertdialog"] {
3172
- background-color: color-mix(in srgb, var(--mpw-theme-color) 30%, transparent) !important;
3173
- }
3174
- `;
3207
+ // ①(Qoder 审查 R1) CSS 内容统一走 buildThemeColorCss()(两分支共用)。
3208
+ css += buildThemeColorCss();
3175
3209
  return css + buildUiCss(section, dlgFrosted);
3176
3210
  }
3177
3211
 
@@ -4117,7 +4151,10 @@ body[data-mpw-modal] [role="dialog"] {
4117
4151
  const skelKey = (d.SettingModel && d.SettingModel.skel) || "setting";
4118
4152
  const raw = ls.getItem(skelKey);
4119
4153
  if (raw) {
4120
- const stored = JSON.parse(raw);
4154
+ // ①(Qoder 审查) JSON.parse 单独保护:损坏的 localStorage
4155
+ // 只丢弃读回(回退 loadJson 默认值),不影响后续 setWebCfg。
4156
+ let stored = null;
4157
+ try { stored = JSON.parse(raw); } catch { stored = null; }
4121
4158
  if (stored && stored.SettingModel) {
4122
4159
  d.SettingModel = Object.assign({}, d.SettingModel, stored.SettingModel);
4123
4160
  }
package/lib/index.js CHANGED
@@ -402,6 +402,62 @@ let ffmpegDownloadPromise = null; // 下载单飞(防并发重复下载)
402
402
  let ffmpegDownloadProgress = null; // 下载进度(bytes/total)
403
403
  let lastFfmpegDownloadError = null;
404
404
 
405
+ /** ②(fix) 源视频探测缓存:srcId|mtime → { fps, width, height } | null。
406
+ * 避免每次 /transcode 都跑一次 ffprobe(4K 源探测也要几秒)。 */
407
+ const TRANSCODE_PROBE_CACHE = new Map();
408
+ const TRANSCODE_PROBE_CACHE_MAX = 64;
409
+
410
+ /** ②(fix) 用 ffprobe 探测视频流的帧率/分辨率(失败返回 null,不抛错)。
411
+ * 用于"源帧率≤上限 且 源宽≤上限 → 无需转码直接原片"的判断——
412
+ * 用户实测:60fps 源 + fpsCap=60 仍走重编码 → 白屏几分钟(Android 单线程
413
+ * 转 4K 100s 极慢),切回无限制时后台 ffmpeg 还在跑 → 卡。 */
414
+ function probeVideoInfo(filePath) {
415
+ try {
416
+ const out = execFileSync('ffprobe', [
417
+ '-v', 'error', '-select_streams', 'v:0',
418
+ '-show_entries', 'stream=r_frame_rate,avg_frame_rate,width,height',
419
+ '-of', 'csv=p=0', filePath,
420
+ ], { encoding: 'utf8', timeout: 15000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
421
+ const line = String(out).trim().split(/\r?\n/)[0] || '';
422
+ const parts = line.split(',').map((s) => s.trim());
423
+ // 实测 ffprobe 4.4.2 csv 输出顺序: <width>,<height>,<r_frame_rate>[,<avg_frame_rate>]
424
+ // (如 "3840,2160,60/1" 或 "1920,1080,30000/1001")——不是文档常见的
425
+ // r_frame_rate,width,height 顺序,按 show_entries 声明顺序也实测为 width 在前。
426
+ const width = Number(parts[0]);
427
+ const height = Number(parts[1]);
428
+ const parseRate = (s) => { const r = String(s || '').split('/'); const n = Number(r[0]), d = Number(r[1]); return d > 0 && Number.isFinite(n) ? n / d : 0; };
429
+ // ①(fix) VFR 视频 r_frame_rate 可能是 0/0 → 用 avg_frame_rate 兜底
430
+ let fps = parseRate(parts[2]);
431
+ if (!(fps > 0)) fps = parseRate(parts[3]);
432
+ if (!(fps > 0) || !(width > 0)) return null;
433
+ return { fps, width, height };
434
+ } catch { return null; }
435
+ }
436
+
437
+ /** ②(fix) 带缓存的探测(src 统一输入:mpkg 或 file)。 */
438
+ function probeVideoSource(src) {
439
+ try {
440
+ const srcPath = src.type === 'file' ? src.path : src.rec.path;
441
+ const st = statSync(srcPath);
442
+ const srcId = src.type === 'mpkg' ? 'mpkg:' + src.rec.path + ':' + src.entry.index : srcPath;
443
+ const ck = srcId + '|' + Math.round(st.mtimeMs);
444
+ if (TRANSCODE_PROBE_CACHE.has(ck)) return TRANSCODE_PROBE_CACHE.get(ck);
445
+ let info = null;
446
+ if (src.type === 'file') {
447
+ info = probeVideoInfo(srcPath);
448
+ } else {
449
+ // mpkg 容器内条目:materialize 提取临时文件后探测,探完即删
450
+ try {
451
+ const mat = materializeSource(src, transcodeCacheDir());
452
+ try { info = probeVideoInfo(mat.file); } finally { if (mat.cleanup) mat.cleanup(); }
453
+ } catch { info = null; }
454
+ }
455
+ if (TRANSCODE_PROBE_CACHE.size >= TRANSCODE_PROBE_CACHE_MAX) TRANSCODE_PROBE_CACHE.clear();
456
+ TRANSCODE_PROBE_CACHE.set(ck, info);
457
+ return info;
458
+ } catch { return null; }
459
+ }
460
+
405
461
  function ffmpegDataDir() {
406
462
  const dir = join(DATA_DIR, 'ffmpeg');
407
463
  try { mkdirSync(dir, { recursive: true }); } catch { /* 忽略 */ }
@@ -430,6 +486,9 @@ let TRANSCODE_FUTEX_FALLBACK = false;
430
486
  const TRANSCODE_MAX_ACTIVE = Math.max(1, Number(process.env.DSH_WE_TRANSCODE_MAX_ACTIVE) || 1);
431
487
  let TRANSCODE_ACTIVE = 0;
432
488
  const TRANSCODE_WAITERS = [];
489
+ /** ②(Qoder 审查 M7) 转码排队等待上限:超过即抛错让客户端回退原片(防浏览器已
490
+ * 放弃、服务端仍白跑长队列)。默认 30s。 */
491
+ const TRANSCODE_QUEUE_TIMEOUT_MS = Math.max(5000, Number(process.env.DSH_WE_TRANSCODE_QUEUE_TIMEOUT_MS) || 30000);
433
492
  function pruneTranscodeCache() {
434
493
  try {
435
494
  const dir = transcodeCacheDir();
@@ -460,14 +519,26 @@ function pruneTranscodeCache() {
460
519
  // ①(修正) src_*.bin 残留清理:materializeSource 提取的临时源文件,正常路径在
461
520
  // transcodeToFps 的 finally 里 cleanup;进程被杀/崩溃时残留(实测 20 个 × 130MB
462
521
  // ≈ 3GB 堆积)。转码单任务硬超时 15 分钟,mtime 超过 1 小时必是残留,直接删。
522
+ // ②(Qoder 审查) 一并清理 tc_*.mp4.tmp* 残留(同源泄漏:renameSync 未执行时残留)。
463
523
  try {
464
524
  const dir = transcodeCacheDir();
465
525
  const cutoff = Date.now() - 60 * 60 * 1000;
466
526
  for (const n of readdirSync(dir)) {
467
- if (!n.startsWith('src_') || !n.endsWith('.bin')) continue;
468
- try {
469
- if (statSync(join(dir, n)).mtimeMs < cutoff) unlinkSync(join(dir, n));
470
- } catch { /* 忽略 */ }
527
+ if (n.startsWith('src_') && n.endsWith('.bin')) {
528
+ try {
529
+ if (statSync(join(dir, n)).mtimeMs < cutoff) unlinkSync(join(dir, n));
530
+ } catch { /* 忽略 */ }
531
+ }
532
+ }
533
+ // ②(Qoder 审查) tc_*.mp4.tmp<PID>:转码中间文件,进程被杀时 renameSync
534
+ // 未执行 → 永久残留。mtime>1h 必是残留(单任务硬超时 15min),直接删。
535
+ // 正在写的 tmp 由当前任务持有(<15min),不会被误删。
536
+ for (const n of readdirSync(dir)) {
537
+ if (n.startsWith('tc_') && /\.tmp(\d+)?$/.test(n)) {
538
+ try {
539
+ if (statSync(join(dir, n)).mtimeMs < cutoff) unlinkSync(join(dir, n));
540
+ } catch { /* 忽略 */ }
541
+ }
471
542
  }
472
543
  } catch { /* 忽略 */ }
473
544
  }
@@ -661,8 +732,16 @@ function resolveTranscodeSource(fileOrToken) {
661
732
  return entry ? { type: 'mpkg', rec, entry } : null;
662
733
  }
663
734
  if (isAbsolute(fileOrToken)) {
735
+ // ①(Qoder 审查 S1) 路径穿越:绝对路径只允许 DATA_DIR / customDir 白名单内,
736
+ // 否则 /transcode?file=/etc/passwd 可探测/流出任意系统文件。
737
+ // 用 resolve 归一后前缀匹配(防 .. 绕过)。
664
738
  const p = resolve(fileOrToken);
665
- if (existsSync(p) && statSync(p).isFile()) return { type: 'file', path: p };
739
+ const allowed = [DATA_DIR, customDir].filter((d) => d && typeof d === 'string');
740
+ const ok = allowed.some((d) => {
741
+ const base = resolve(d);
742
+ return p === base || p.startsWith(base + sep);
743
+ });
744
+ if (ok && existsSync(p) && statSync(p).isFile()) return { type: 'file', path: p };
666
745
  return null;
667
746
  }
668
747
  if (customDir && !fileOrToken.includes('..') && !fileOrToken.includes('/') && !fileOrToken.includes('\\')) {
@@ -766,12 +845,17 @@ function spawnFfmpeg(ff, args) {
766
845
  }
767
846
  const a = attempts[idx++];
768
847
  let errFd = null;
769
- try { errFd = openSync(errLog, 'w'); } catch { /* 忽略 */ }
848
+ // ①(Qoder 审查 M3) 'a' 追加而非 'w' 截断:多 attempt 时保留第一个
849
+ // attempt 的 stderr('w' 会让第二个 attempt 清空第一个的失败原因)。
850
+ try { errFd = openSync(errLog, 'a'); } catch { /* 忽略 */ }
770
851
  let proc = null;
771
852
  try {
772
853
  proc = spawn(ff, args, { ...a.opts, cwd, stdio: errFd ? ['ignore', 'ignore', errFd] : 'ignore' });
773
854
  } catch (err) {
774
855
  if (errFd) { try { closeSync(errFd); } catch { /* 忽略 */ } }
856
+ // ①(Qoder 审查 M4) spawn 阶段 throw(EPERM/ENOENT):刚创建的空 errLog
857
+ // 无内容无价值,直接删(失败保留语义只对**真实运行过**的 ffmpeg 有意义)。
858
+ try { unlinkSync(errLog); } catch { /* 忽略 */ }
775
859
  errors.push(a.name + ' spawn throw ' + (err && err.code ? err.code : err));
776
860
  runNext();
777
861
  return;
@@ -857,8 +941,9 @@ async function runTranscode(srcFile, out, fps, maxW, job) {
857
941
  { name: 'libx264', tail: ['-c:v', 'libx264', '-crf', '23', '-preset', 'veryfast'] },
858
942
  { name: 'libsvtav1', tail: ['-c:v', 'libsvtav1', '-crf', '32', '-preset', '6'] },
859
943
  ];
860
- // ①(修正) 线程模式:多线程 → (futex 失败时) 单线程。单线程时 -threads 1 同时放
861
- // 输入侧(解码)与输出侧(编码),并加 -filter_threads 1 限制滤镜线程。
944
+ // ①(修正) 线程模式:多线程 → (futex 失败时) 单线程。单线程时限制解码
945
+ // 线程(-threads 1 输入侧)+滤镜线程(-filter_threads 1),编码线程由输出侧
946
+ // -threads 1 控制(见下方 full 拼接)。
862
947
  const threadModes = [
863
948
  { label: 'mt', extra: [] },
864
949
  { label: 'st', extra: ['-threads', '1', '-filter_threads', '1'] },
@@ -872,6 +957,8 @@ async function runTranscode(srcFile, out, fps, maxW, job) {
872
957
  for (const enc of encoders) {
873
958
  job.encoder = enc.name + (mode.label === 'st' ? ' (单线程)' : '');
874
959
  // -f mp4 显式指定:临时输出路径不是 .mp4 后缀,ffmpeg 无法按扩展名选 muxer
960
+ // ①(Qoder 审查 M5) 单线程时输出侧再加 -threads 1 控制编码线程
961
+ // (输入侧 -threads 只管解码;mode.extra 已含输入侧与 filter_threads)。
875
962
  try {
876
963
  const full = [...mode.extra, ...base, ...enc.tail, ...(mode.label === 'st' ? ['-threads', '1'] : []), '-movflags', '+faststart', '-an', '-f', 'mp4', out];
877
964
  await spawnFfmpeg(ff.path, full);
@@ -879,7 +966,10 @@ async function runTranscode(srcFile, out, fps, maxW, job) {
879
966
  } catch (err) {
880
967
  lastErr = err;
881
968
  try { unlinkSync(out); } catch { /* 忽略 */ }
882
- if (/futex/i.test(String(err && err.message || ''))) {
969
+ // (Qoder 审查) futex 判定用精确报错原文匹配,避免误伤:文件名/路径
970
+ // 含 "futex"(如用户 mpkg 名 futex_test.mpkg)的打开错误也会被宽松
971
+ // /futex/i 命中 → 误置 TRANSCODE_FUTEX_FALLBACK 永久单线程(性能损失)。
972
+ if (/futex facility returned an unexpected error/i.test(String(err && err.message || ''))) {
883
973
  futexSeen = true;
884
974
  TRANSCODE_FUTEX_FALLBACK = true; // 记忆:本进程后续直接单线程
885
975
  break; // 多线程 futex 崩 → 不再试本模式其他编码器,直接单线程
@@ -922,8 +1012,27 @@ async function transcodeToFps(src, fps, maxW) {
922
1012
  const p = (async () => {
923
1013
  // ①(修正) 并发闸门:Android 内存小,多个 4K 转码并发会 OOM/futex 崩溃
924
1014
  // (实测用户环境 3 个并发同时崩)。超过 TRANSCODE_MAX_ACTIVE 则排队等待。
1015
+ // ②(Qoder 审查 M7) 排队加超时:MAX=1 时排在长任务(4K 15min)后面的请求
1016
+ // 会挂到浏览器放弃,服务端仍白跑。30s 等不到就抛错 → 客户端回退原片。
925
1017
  if (TRANSCODE_ACTIVE >= TRANSCODE_MAX_ACTIVE) {
926
- await new Promise((resolve) => TRANSCODE_WAITERS.push(resolve));
1018
+ const got = await new Promise((resolve) => {
1019
+ let settled = false;
1020
+ const timer = setTimeout(() => {
1021
+ if (settled) return;
1022
+ settled = true;
1023
+ const i = TRANSCODE_WAITERS.indexOf(wake);
1024
+ if (i >= 0) TRANSCODE_WAITERS.splice(i, 1);
1025
+ resolve(false);
1026
+ }, TRANSCODE_QUEUE_TIMEOUT_MS);
1027
+ const wake = () => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ clearTimeout(timer);
1031
+ resolve(true);
1032
+ };
1033
+ TRANSCODE_WAITERS.push(wake);
1034
+ });
1035
+ if (!got) throw new Error('转码队列繁忙(等待超过 ' + Math.round(TRANSCODE_QUEUE_TIMEOUT_MS / 1000) + 's),请稍后重试');
927
1036
  }
928
1037
  TRANSCODE_ACTIVE++;
929
1038
  try {
@@ -950,9 +1059,14 @@ async function transcodeToFps(src, fps, maxW) {
950
1059
  throw err;
951
1060
  } finally {
952
1061
  if (mat.cleanup) mat.cleanup();
953
- TRANSCODE_INFLIGHT.delete(cachePath);
954
1062
  }
955
1063
  } finally {
1064
+ // ①(修正) Qoder 审查发现:TRANSCODE_INFLIGHT.delete 原先在内层 finally ——
1065
+ // ensureFfmpeg 失败 / materializeSource 抛错时(内层 try 未进入)不执行 →
1066
+ // cachePath 永久残留在 inflight Map → 该源后续所有转码请求 return 已 reject
1067
+ // 的 promise → 永久转码失败(用户实测"装了 ffmpeg 也没用/黑屏")。
1068
+ // 移到外层 finally:任何路径都必定清理。
1069
+ TRANSCODE_INFLIGHT.delete(cachePath);
956
1070
  TRANSCODE_ACTIVE--;
957
1071
  const next = TRANSCODE_WAITERS.shift();
958
1072
  if (next) next();
@@ -967,15 +1081,20 @@ function handleTranscodeProgress(req, res) {
967
1081
  try {
968
1082
  const url = new URL(req.url || '', 'http://localhost');
969
1083
  const file = (url.searchParams.get('file') || '').trim();
1084
+ const srcParam = (url.searchParams.get('src') || '').trim();
970
1085
  const fps = Number(url.searchParams.get('fps')) || 0;
1086
+ const maxWRaw = Number(url.searchParams.get('maxW'));
1087
+ const maxW = Number.isFinite(maxWRaw) && maxWRaw > 0 ? Math.min(3840, Math.round(maxWRaw)) : 0;
971
1088
  let job = null;
972
- if (file) {
973
- const src = resolveTranscodeSource(file);
974
- if (src) {
975
- const srcPath = src.type === 'file' ? src.path : src.rec.path;
976
- const srcId = src.type === 'mpkg' ? 'mpkg:' + src.rec.path + ':' + src.entry.index : srcPath;
977
- job = transcodeJobs.get(srcId + '|' + fps) || null;
978
- }
1089
+ // (fix) src 优先(与 /transcode 同款):客户端回退原片后轮询转码完成,
1090
+ // 用原始 image 串查询(旧式 file 兼容保留)。
1091
+ const src = srcParam ? resolveTranscodeByImage(srcParam) : resolveTranscodeSource(file);
1092
+ if (src) {
1093
+ const srcPath = src.type === 'file' ? src.path : src.rec.path;
1094
+ const srcId = src.type === 'mpkg' ? 'mpkg:' + src.rec.path + ':' + src.entry.index : srcPath;
1095
+ // ①(Qoder 审查 R7) 查询 key 与写入一致(含 maxW)——此前写入
1096
+ // srcId|fps|maxW、查询 srcId|fps,设了 maxW 的任务进度永远查不到(idle)。
1097
+ job = transcodeJobs.get(srcId + '|' + fps + '|' + maxW) || transcodeJobs.get(srcId + '|' + fps) || null;
979
1098
  } else if (ffmpegDownloadProgress) {
980
1099
  job = { phase: 'downloading', percent: ffmpegDownloadProgress.total > 0 ? Math.min(99, Math.round(ffmpegDownloadProgress.downloaded / ffmpegDownloadProgress.total * 100)) : 0, updatedAt: ffmpegDownloadProgress.updatedAt };
981
1100
  } else {
@@ -2051,9 +2170,41 @@ function apply(ctx) {
2051
2170
  // ①(新) src 优先(client 播放路径直接传原 image 串);旧式 file/token 兼容保留。
2052
2171
  const src = srcParam ? resolveTranscodeByImage(srcParam) : resolveTranscodeSource(file || token);
2053
2172
  if (!src) { json(res, 404, { ok: false, error: 'unknown source' }); return; }
2173
+ // ②(fix) 用户实测:60fps 源 + fpsCap=60 仍走重编码 → 白屏(Android 单线程
2174
+ // 转 4K 100s 极慢),切回无限制时后台 ffmpeg 还在跑 → 卡。
2175
+ // 先探测源 fps/分辨率:`fps >= 源fps 且 maxW(或 0=不限制) >= 源宽` →
2176
+ // 上限没低于源规格 → **无需转码,直接 serveRange 原片**(零延迟)。
2177
+ const probe = probeVideoSource(src);
2178
+ if (probe && fps >= probe.fps && (maxW === 0 || maxW >= probe.width)) {
2179
+ if (src.type === 'file') {
2180
+ serveRange(req, res, src.path, 'video/mp4');
2181
+ return;
2182
+ }
2183
+ // mpkg 容器内条目:materialize 提取临时文件 serveRange,流结束后清理
2184
+ try {
2185
+ const mat = materializeSource(src, transcodeCacheDir());
2186
+ const orig = mat.file;
2187
+ const origCleanup = mat.cleanup;
2188
+ const origRes = res;
2189
+ origRes.on('close', () => { try { if (origCleanup) origCleanup(); } catch {} });
2190
+ serveRange(req, res, orig, 'video/mp4');
2191
+ return;
2192
+ } catch (err) {
2193
+ // materialize 失败 → 落回转码路径(或 404)
2194
+ }
2195
+ }
2054
2196
  let out = null;
2055
2197
  let transcodeErr = null;
2198
+ // ②(fix) 客户端断开(切回无限制/换壁纸/关页)→ 取消进行中的转码:
2199
+ // 否则后台 ffmpeg 继续跑满 CPU(用户实测"60 帧停留后切无限制特别卡")。
2200
+ // 默认 MAX_ACTIVE=1 时同时只有一个 ffmpeg,kill 全部活动进程安全;
2201
+ // MAX_ACTIVE>1 时仅 kill 本请求启动的(通过任务级 proc 标记,见下)。
2202
+ let reqAborted = false;
2203
+ const onAbort = () => { reqAborted = true; try { for (const proc of ACTIVE_FFMPEG) { try { proc.kill(); } catch {} } } catch {} };
2204
+ res.on('close', () => { if (!out) onAbort(); });
2205
+ req.on('aborted', onAbort);
2056
2206
  try { out = await transcodeToFps(src, fps, maxW); } catch (err) { transcodeErr = err; } /* 转码失败 → 回退原片 */
2207
+ if (reqAborted) return; // 客户端已断开,不再写响应
2057
2208
  if (!out) {
2058
2209
  json(res, 502, { ok: false, error: String(transcodeErr && transcodeErr.message || transcodeErr || 'transcode failed') });
2059
2210
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",