koishi-plugin-kkk 3.0.1 → 3.1.0

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/CHANGELOG.md CHANGED
@@ -1,6 +1,26 @@
1
1
  # 更新日志
2
2
 
3
- ## 3.0.1
3
+ ## 3.1.0
4
+
5
+ ### 卡片渲染:先量再定倍率,快的那个留下
6
+
7
+ 拿你各个平台的真实卡片在同一台机器上测的(同一份 HTML,各测两遍取平均):
8
+
9
+ | 卡片 | 1 倍渲染 | 2 倍渲染 | 现在固定用 |
10
+ | --- | --- | --- | --- |
11
+ | 抖音评论区 | **1.5 秒** | 4.2 秒 | 1 倍 |
12
+ | B 站评论区 | **2.2 秒** | 6.8 秒 | 1 倍 |
13
+ | 快手评论区 | **0.4 秒** | 1.0 秒 | 1 倍 |
14
+ | 抖音作品卡 | **1.7 秒** | 2.8 秒 | 1 倍 |
15
+ | B 站视频卡 | **1.5 秒** | 2.5 秒 | 1 倍 |
16
+ | 小红书笔记卡 | 15.5 秒 | **5.2 秒** | 2 倍 |
17
+ | 抖音图集卡 | 8.0 秒 | **3.3 秒** | 2 倍 |
18
+
19
+ 两处原因,都修了:
20
+
21
+ - **有些卡片一直在用两倍分辨率渲染,然后被扔掉一半**:卡片是按 1440 宽排版的,长图切片时又会被压回 1440 宽再切 —— 2 倍渲染出来的 2880 宽像素一个都用不上。但**不能一刀切**:内核在不同倍率下走的路径不一样,实测上面这些卡片,评论卡 1 倍快 2.7 倍,小红书笔记卡反而 2 倍快 3 倍。所以现在每条路由**各测一次**:第一次用你配的倍率,第二次换另一档,之后固定用快的那档(每条路由只多花一次渲染)。你把「渲染精度」设成 50(就要 1 倍)时不测,直接听你的。
22
+ - **内核在等它根本加载不了的远程图片**:shotkit 内核在 Windows 上拉不到 https 资源(卡片里的远程头像、封面本来就是空白框),但它照样会去请求、还等你等到超时 —— 实测页面里只要有一个连不上的 https 图片,截图就从 1.5 秒变成 **22 秒**。React 还会给每张图片额外补一条 `<link rel="preload">`(一张评论卡 27 条),这些也算在加载里。现在交给内核之前会把这些摘掉(只有用内核时才摘,切到 puppeteer 不受影响,那边远程图片正常)。
23
+ - 顺带:渲染日志现在会写清**每张卡片花了多久**(页面多少毫秒 / 截图多少毫秒),慢的时候能一眼看出是慢在哪一步。
4
24
 
5
25
  ### 在线播放:声音不再等服务器
6
26
 
@@ -10,6 +10,7 @@ exports.buildPosterContext = buildPosterContext;
10
10
  exports.renderTemplateHtml = renderTemplateHtml;
11
11
  exports.downgradeNoiseLayers = downgradeNoiseLayers;
12
12
  exports.pruneMissingFontFaces = pruneMissingFontFaces;
13
+ exports.neutralizeRemoteAssets = neutralizeRemoteAssets;
13
14
  exports.optimizeForScreenshot = optimizeForScreenshot;
14
15
  /**
15
16
  * 渲染模块(Koishi 移植版)。
@@ -469,6 +470,36 @@ function pruneMissingFontFaces(html, baseDir) {
469
470
  return resolvable ? block : '';
470
471
  });
471
472
  }
473
+ /**
474
+ * 内核(shotkit)加载不了的远程资源,交给它之前先摘掉。
475
+ *
476
+ * 实测:内核在 Windows 上**加载不了 https 资源**(卡片里的 https 图片一律渲染成空白框),
477
+ * 但它照样会为这些地址发请求、并且等它们结束 —— 一张 1440x600 的小页面里只要有一个连不上的
478
+ * https 图片,截图就从 1.5 秒变成 22 秒。React 19 的 SSR 还会给每个 `<img>` 额外补一条
479
+ * `<link rel="preload" as="image">`(一张评论卡实测 27 条),这些 preload 同样算在页面加载里。
480
+ *
481
+ * 所以**只在 shotkit 这条路径**上做替换(puppeteer 那条路不动,它能正常加载远程图片):
482
+ * - 删掉 `<link rel="preload">`(纯预加载提示,本地渲染没有意义)
483
+ * - 远程 `<img src>` 换成 1x1 透明图(反正渲染不出来),顺带删掉 srcset
484
+ * - 内联样式/CSS 里的 `url(http…)` 换成 none
485
+ */
486
+ function neutralizeRemoteAssets(html) {
487
+ let links = 0;
488
+ let images = 0;
489
+ let output = html.replace(/<link\b[^>]*\brel=["']?preload["']?[^>]*>/gi, () => {
490
+ links++;
491
+ return '';
492
+ });
493
+ output = output.replace(/(<img\b[^>]*?)\ssrc="https?:\/\/[^"]*"/gi, (_all, head) => {
494
+ images++;
495
+ return head + ' src="' + TRANSPARENT_PIXEL + '"';
496
+ });
497
+ output = output.replace(/(<img\b[^>]*?)\ssrcset="[^"]*"/gi, '$1');
498
+ output = output.replace(/url\(\s*(['"]?)https?:\/\/[^)'"]*\1\s*\)/gi, 'none');
499
+ return { html: output, links, images };
500
+ }
501
+ /** 顶掉远程图片用的 1x1 透明 gif(内联 data: 内核能正常解码) */
502
+ const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
472
503
  /** 截图前的统一降级:先剔无效字体,再降级噪点层 */
473
504
  function optimizeForScreenshot(html, baseDir) {
474
505
  const pruned = pruneMissingFontFaces(html, baseDir);
@@ -491,7 +522,7 @@ function optimizeForScreenshot(html, baseDir) {
491
522
  */
492
523
  /** 用了 shotkit 只提示一次(每张卡片都刷一行没意义) */
493
524
  let shotkitLogged = false;
494
- async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg') {
525
+ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg', htmlSource) {
495
526
  const koishiCtx = (0, node_karin_1.getKoishiContext)();
496
527
  /** 浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas 之类) */
497
528
  const puppeteer = koishiCtx?.get ? koishiCtx.get('puppeteer') : koishiCtx?.puppeteer;
@@ -511,7 +542,8 @@ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg
511
542
  // 卡片是给手机看的:**低于 2x 会明显发虚**,所以下限锁 2(上限 3)。
512
543
  // 想更大更清晰就调 `app.renderScale`(100 → 2x,150 → 3x),配 100 时保持 2x 不出错。
513
544
  const requested = Number.isFinite(scale) && scale > 0 ? scale * 2 : 2;
514
- let deviceScaleFactor = Math.min(3, Math.max(2, requested));
545
+ // 下限是 1x:模板都按 1440 CSS 像素排版,1x 出图正好 1440 宽 —— 也就是切片时会被压到的那个宽度
546
+ let deviceScaleFactor = Math.min(3, Math.max(1, requested));
515
547
  // 安全阀:超大页面降一档缩放,避免单次渲染吃掉几 GB 内存(曾经把实例 OOM 崩掉)
516
548
  try {
517
549
  if (node_fs_1.default.statSync(htmlPath).size > 6 * 1024 * 1024)
@@ -534,19 +566,36 @@ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg
534
566
  if (shotkit && typeof shotkit.renderFile === 'function' && (preferShotkit || !puppeteer)) {
535
567
  try {
536
568
  const started = Date.now();
537
- const buffer = await shotkit.renderFile(htmlPath, {
538
- selector,
539
- timeout,
540
- deviceScaleFactor,
541
- type: 'png',
542
- });
569
+ /**
570
+ * 交给内核的 HTML 先摘掉它加载不了的远程资源(见 neutralizeRemoteAssets 的说明)。
571
+ * 走 `screenshot({ html })` 而不是 renderFile:磁盘上那份保持原样,
572
+ * 万一内核失败要回退到 puppeteer,那边还能正常加载远程图片。
573
+ */
574
+ const sanitized = htmlSource ? neutralizeRemoteAssets(htmlSource) : null;
575
+ if (sanitized && (sanitized.links > 0 || sanitized.images > 0)) {
576
+ node_karin_1.logger.debug('[Render] 内核用 HTML:摘掉 preload ' + sanitized.links + ' 条、远程图片 ' + sanitized.images + ' 张');
577
+ }
578
+ const buffer = sanitized && typeof shotkit.screenshot === 'function'
579
+ ? ((await shotkit.screenshot({
580
+ html: sanitized.html,
581
+ selector,
582
+ timeout,
583
+ deviceScaleFactor,
584
+ type: 'png',
585
+ }))?.image)
586
+ : await shotkit.renderFile(htmlPath, {
587
+ selector,
588
+ timeout,
589
+ deviceScaleFactor,
590
+ type: 'png',
591
+ });
543
592
  if (buffer && buffer.length) {
544
593
  if (!shotkitLogged) {
545
594
  shotkitLogged = true;
546
595
  node_karin_1.logger.info('[Render] 使用 shotkit 内核渲染卡片(静态内核,不执行页面 JS)');
547
596
  }
548
597
  node_karin_1.logger.debug('[Render] shotkit 渲染 ' + htmlPath.split(/[\\/]/).pop() + ' 用时 ' + (Date.now() - started) + 'ms');
549
- return { base64: Buffer.from(buffer).toString('base64'), mime: 'image/png' };
598
+ return { base64: Buffer.from(buffer).toString('base64'), mime: 'image/png', dsf: deviceScaleFactor };
550
599
  }
551
600
  }
552
601
  catch (error) {
@@ -627,7 +676,7 @@ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg
627
676
  type: format,
628
677
  quality: format === 'jpeg' ? 92 : undefined
629
678
  });
630
- return { base64: buffer.toString('base64'), mime: 'image/' + format };
679
+ return { base64: buffer.toString('base64'), mime: 'image/' + format, dsf: deviceScaleFactor };
631
680
  };
632
681
  /**
633
682
  * 自己开一个页面截图,**不要走插件的 render() 包装**。
@@ -757,9 +806,50 @@ function unwrapImageProxy(html) {
757
806
  }
758
807
  });
759
808
  }
809
+ const cardScaleTrials = new Map();
810
+ /** 配置里的倍率(面板 renderScale:100 = 2x,150 = 3x,50 = 1x) */
811
+ function configuredCardScale() {
812
+ return Math.min(2, Math.max(0.25, Number(Config_1.Config.app?.renderScale ?? 100) / 100));
813
+ }
814
+ /** 这张卡片这次该用多少倍渲染,以及这次是不是「试探」 */
815
+ function resolveCardScale(route) {
816
+ const configured = configuredCardScale();
817
+ // 用户明确只要 1x:不试,直接听配置
818
+ if (configured <= 0.5)
819
+ return { scale: configured, trial: false };
820
+ const entry = cardScaleTrials.get(route);
821
+ if (!entry)
822
+ return { scale: configured, trial: false };
823
+ if (entry.pending !== undefined)
824
+ return { scale: entry.pending, trial: true };
825
+ return { scale: entry.best, trial: false };
826
+ }
827
+ /** 渲染完把这次的耗时喂回来,决定这条路之后固定用哪一档 */
828
+ function recordCardScale(route, scale, ms) {
829
+ const configured = configuredCardScale();
830
+ if (configured <= 0.5)
831
+ return;
832
+ const entry = cardScaleTrials.get(route);
833
+ if (!entry) {
834
+ cardScaleTrials.set(route, { best: scale, bestMs: ms, pending: scale > 0.5 ? 0.5 : configured });
835
+ return;
836
+ }
837
+ if (entry.pending !== undefined && Math.abs(scale - entry.pending) < 1e-6) {
838
+ const better = ms < entry.bestMs;
839
+ const best = better ? scale : entry.best;
840
+ const bestMs = better ? ms : entry.bestMs;
841
+ node_karin_1.logger.info('[Render] ' + route + ' 倍率测完了:固定用 ' + best * 2 + 'x(' + Math.round(bestMs) + 'ms,'
842
+ + '本次 ' + scale * 2 + 'x=' + Math.round(ms) + 'ms / 上次 ' + entry.best * 2 + 'x=' + Math.round(entry.bestMs) + 'ms)');
843
+ cardScaleTrials.set(route, { best, bestMs });
844
+ return;
845
+ }
846
+ // 稳态:顺手刷新一下耗时,万一机器负载变化明显也能留着对比
847
+ entry.bestMs = ms;
848
+ }
760
849
  const Render = async (_event, route, data) => {
850
+ const startedAt = Date.now();
761
851
  const dark = await (0, coverTheme_1.resolveUseDarkTheme)(route, data).catch(() => false);
762
- const scale = Math.min(2, Math.max(0.5, Number(Config_1.Config.app?.renderScale ?? 100) / 100));
852
+ const { scale, trial } = resolveCardScale(route);
763
853
  let html = await renderTemplateHtml(route, data, dark);
764
854
  if (!html) {
765
855
  html = buildFallbackHtml(route, data, dark, scale);
@@ -775,10 +865,17 @@ const Render = async (_event, route, data) => {
775
865
  const isQrCode = /qrcode/i.test(route);
776
866
  const format = isQrCode ? 'png' : 'jpeg';
777
867
  // mime 由 screenshot 返回实际产出的格式:shotkit 走 PNG,其余路径仍是传入的 format
778
- const { base64, mime } = await screenshot(htmlPath, '#container', Number(Config_1.Config.app?.RenderWaitTime ?? 10) * 1000, scale, format);
868
+ const htmlBuiltAt = Date.now();
869
+ const { base64, mime, dsf } = await screenshot(htmlPath, '#container', Number(Config_1.Config.app?.RenderWaitTime ?? 10) * 1000, scale, format, html);
870
+ const shotAt = Date.now();
779
871
  const buffer = Buffer.from(base64, 'base64');
780
872
  const meta = (0, exports.getImageMetadata)(buffer);
781
- node_karin_1.logger.debug('[Render] ' + route + ' 渲染完成 ' + mime + ' ' + (meta.width ?? '?') + 'x' + (meta.height ?? '?') + ' ' + Math.round(buffer.length / 1024) + 'KB');
873
+ // 把这次的截图耗时喂回去,决定这条路之后固定用哪一档(见上面 cardScaleTrials)
874
+ recordCardScale(route, scale, shotAt - htmlBuiltAt);
875
+ node_karin_1.logger.info('[Render] ' + route + ' 渲染完成 ' + mime + ' ' + (meta.width ?? '?') + 'x' + (meta.height ?? '?')
876
+ + ' ' + Math.round(buffer.length / 1024) + 'KB'
877
+ + ' 用时 ' + (shotAt - startedAt) + 'ms(页面 ' + (htmlBuiltAt - startedAt) + 'ms / 截图 ' + (shotAt - htmlBuiltAt)
878
+ + 'ms,' + dsf + 'x' + (trial ? ',本次试另一档倍率' : '') + ')');
782
879
  // 用带 mime 的 data URL,适配器才知道按对应格式上传(base64:// 会被当成 PNG)
783
880
  return [node_karin_1.segment.image('data:' + mime + ';base64,' + base64)];
784
881
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -150,25 +150,25 @@ setTimeout(async () => {
150
150
  */
151
151
  console.log('\n[6] 面板保存(authoritative):改回默认值也要能写进 config.json')
152
152
  const defaultFake = defaults.app.fakeForward
153
- check('前提:app.fakeForward 的默认值就是 true(打开它 = 改回默认值)', defaultFake === true, String(defaultFake))
154
- // 1) 先关掉(和默认值不同,两条路都能写回)
155
- applyUpstreamOverrides({ app: { fakeForward: false } })
156
- const offValue = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).app.fakeForward
157
- check('关掉能写回', offValue === false, String(offValue))
153
+ check('前提:app.fakeForward 的默认值就是 false(合并转发默认全关)', defaultFake === false, String(defaultFake))
154
+ // 1) 先打开(和默认值不同,两条路都能写回)
155
+ applyUpstreamOverrides({ app: { fakeForward: true } })
156
+ const onValue = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).app.fakeForward
157
+ check('打开能写回', onValue === true, String(onValue))
158
158
  // 2) 保守模式下发「默认值」会被当成没动过(老行为,保护手改的文件)
159
- const conservative = applyUpstreamOverrides({ app: { fakeForward: true } })
159
+ const conservative = applyUpstreamOverrides({ app: { fakeForward: false } })
160
160
  const afterConservative = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).app.fakeForward
161
161
  check('保守模式:正好等于默认值时不写回(保护手改文件的老规则还在)',
162
- !conservative.changed.includes('app.fakeForward') && afterConservative === false,
162
+ !conservative.changed.includes('app.fakeForward') && afterConservative === true,
163
163
  'changed=' + conservative.changed.join(', ') + ' / 文件里=' + String(afterConservative))
164
- // 3) 面板保存(authoritative)必须写回 —— 这就是用户那条「打开了没用」
165
- const panel = applyUpstreamOverrides({ app: { fakeForward: true } }, { authoritative: true })
164
+ // 3) 面板保存(authoritative)必须写回 —— 这就是用户那条「改了没用」
165
+ const panel = applyUpstreamOverrides({ app: { fakeForward: false } }, { authoritative: true })
166
166
  const afterPanel = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).app.fakeForward
167
- check('面板保存:值等于默认值也能写回(打开开关立刻生效)',
168
- panel.changed.includes('app.fakeForward') && afterPanel === true,
167
+ check('面板保存:值等于默认值也能写回(关掉开关立刻生效)',
168
+ panel.changed.includes('app.fakeForward') && afterPanel === false,
169
169
  'changed=' + panel.changed.join(', ') + ' / 文件里=' + String(afterPanel))
170
170
  // 4) 面板保存也不会顺手把「没变」的项写坏
171
- const panelNoop = applyUpstreamOverrides({ app: { fakeForward: true, renderScale: 120 } }, { authoritative: true })
171
+ const panelNoop = applyUpstreamOverrides({ app: { fakeForward: false, renderScale: 120 } }, { authoritative: true })
172
172
  check('面板保存:值和文件里一样的项不重复写', panelNoop.changed.length === 0, panelNoop.changed.join(', '))
173
173
  check('手改过的其它项没被顺手改掉', JSON.parse(fs.readFileSync(cfgFile, 'utf8')).app.renderScale === 120)
174
174
  applyUpstreamOverrides({ app: { fakeForward: defaultFake } }, { authoritative: true })
@@ -462,6 +462,38 @@ export function pruneMissingFontFaces (html: string, baseDir: string): string {
462
462
  })
463
463
  }
464
464
 
465
+ /**
466
+ * 内核(shotkit)加载不了的远程资源,交给它之前先摘掉。
467
+ *
468
+ * 实测:内核在 Windows 上**加载不了 https 资源**(卡片里的 https 图片一律渲染成空白框),
469
+ * 但它照样会为这些地址发请求、并且等它们结束 —— 一张 1440x600 的小页面里只要有一个连不上的
470
+ * https 图片,截图就从 1.5 秒变成 22 秒。React 19 的 SSR 还会给每个 `<img>` 额外补一条
471
+ * `<link rel="preload" as="image">`(一张评论卡实测 27 条),这些 preload 同样算在页面加载里。
472
+ *
473
+ * 所以**只在 shotkit 这条路径**上做替换(puppeteer 那条路不动,它能正常加载远程图片):
474
+ * - 删掉 `<link rel="preload">`(纯预加载提示,本地渲染没有意义)
475
+ * - 远程 `<img src>` 换成 1x1 透明图(反正渲染不出来),顺带删掉 srcset
476
+ * - 内联样式/CSS 里的 `url(http…)` 换成 none
477
+ */
478
+ export function neutralizeRemoteAssets (html: string): { html: string; links: number; images: number } {
479
+ let links = 0
480
+ let images = 0
481
+ let output = html.replace(/<link\b[^>]*\brel=["']?preload["']?[^>]*>/gi, () => {
482
+ links++
483
+ return ''
484
+ })
485
+ output = output.replace(/(<img\b[^>]*?)\ssrc="https?:\/\/[^"]*"/gi, (_all: string, head: string) => {
486
+ images++
487
+ return head + ' src="' + TRANSPARENT_PIXEL + '"'
488
+ })
489
+ output = output.replace(/(<img\b[^>]*?)\ssrcset="[^"]*"/gi, '$1')
490
+ output = output.replace(/url\(\s*(['"]?)https?:\/\/[^)'"]*\1\s*\)/gi, 'none')
491
+ return { html: output, links, images }
492
+ }
493
+
494
+ /** 顶掉远程图片用的 1x1 透明 gif(内联 data: 内核能正常解码) */
495
+ const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
496
+
465
497
  /** 截图前的统一降级:先剔无效字体,再降级噪点层 */
466
498
  export function optimizeForScreenshot (html: string, baseDir: string): string {
467
499
  const pruned = pruneMissingFontFaces(html, baseDir)
@@ -487,7 +519,7 @@ export function optimizeForScreenshot (html: string, baseDir: string): string {
487
519
  /** 用了 shotkit 只提示一次(每张卡片都刷一行没意义) */
488
520
  let shotkitLogged = false
489
521
 
490
- async function screenshot (htmlPath: string, selector: string, timeout: number, scale = 1, format: 'png' | 'jpeg' = 'jpeg'): Promise<{ base64: string; mime: string }> {
522
+ async function screenshot (htmlPath: string, selector: string, timeout: number, scale = 1, format: 'png' | 'jpeg' = 'jpeg', htmlSource?: string): Promise<{ base64: string; mime: string; dsf: number }> {
491
523
  const koishiCtx: any = getKoishiContext()
492
524
  /** 浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas 之类) */
493
525
  const puppeteer: any = koishiCtx?.get ? koishiCtx.get('puppeteer') : koishiCtx?.puppeteer
@@ -508,7 +540,8 @@ async function screenshot (htmlPath: string, selector: string, timeout: number,
508
540
  // 卡片是给手机看的:**低于 2x 会明显发虚**,所以下限锁 2(上限 3)。
509
541
  // 想更大更清晰就调 `app.renderScale`(100 → 2x,150 → 3x),配 100 时保持 2x 不出错。
510
542
  const requested = Number.isFinite(scale) && scale > 0 ? scale * 2 : 2
511
- let deviceScaleFactor = Math.min(3, Math.max(2, requested))
543
+ // 下限是 1x:模板都按 1440 CSS 像素排版,1x 出图正好 1440 宽 —— 也就是切片时会被压到的那个宽度
544
+ let deviceScaleFactor = Math.min(3, Math.max(1, requested))
512
545
  // 安全阀:超大页面降一档缩放,避免单次渲染吃掉几 GB 内存(曾经把实例 OOM 崩掉)
513
546
  try {
514
547
  if (fs.statSync(htmlPath).size > 6 * 1024 * 1024) deviceScaleFactor = Math.max(1, deviceScaleFactor - 1)
@@ -530,19 +563,36 @@ async function screenshot (htmlPath: string, selector: string, timeout: number,
530
563
  if (shotkit && typeof shotkit.renderFile === 'function' && (preferShotkit || !puppeteer)) {
531
564
  try {
532
565
  const started = Date.now()
533
- const buffer = await shotkit.renderFile(htmlPath, {
534
- selector,
535
- timeout,
536
- deviceScaleFactor,
537
- type: 'png',
538
- })
566
+ /**
567
+ * 交给内核的 HTML 先摘掉它加载不了的远程资源(见 neutralizeRemoteAssets 的说明)。
568
+ * 走 `screenshot({ html })` 而不是 renderFile:磁盘上那份保持原样,
569
+ * 万一内核失败要回退到 puppeteer,那边还能正常加载远程图片。
570
+ */
571
+ const sanitized = htmlSource ? neutralizeRemoteAssets(htmlSource) : null
572
+ if (sanitized && (sanitized.links > 0 || sanitized.images > 0)) {
573
+ logger.debug('[Render] 内核用 HTML:摘掉 preload ' + sanitized.links + ' 条、远程图片 ' + sanitized.images + ' 张')
574
+ }
575
+ const buffer = sanitized && typeof shotkit.screenshot === 'function'
576
+ ? ((await shotkit.screenshot({
577
+ html: sanitized.html,
578
+ selector,
579
+ timeout,
580
+ deviceScaleFactor,
581
+ type: 'png',
582
+ }))?.image)
583
+ : await shotkit.renderFile(htmlPath, {
584
+ selector,
585
+ timeout,
586
+ deviceScaleFactor,
587
+ type: 'png',
588
+ })
539
589
  if (buffer && buffer.length) {
540
590
  if (!shotkitLogged) {
541
591
  shotkitLogged = true
542
592
  logger.info('[Render] 使用 shotkit 内核渲染卡片(静态内核,不执行页面 JS)')
543
593
  }
544
594
  logger.debug('[Render] shotkit 渲染 ' + htmlPath.split(/[\\/]/).pop() + ' 用时 ' + (Date.now() - started) + 'ms')
545
- return { base64: Buffer.from(buffer).toString('base64'), mime: 'image/png' }
595
+ return { base64: Buffer.from(buffer).toString('base64'), mime: 'image/png', dsf: deviceScaleFactor }
546
596
  }
547
597
  } catch (error: any) {
548
598
  shotkitFailure = error
@@ -626,7 +676,7 @@ async function screenshot (htmlPath: string, selector: string, timeout: number,
626
676
  type: format,
627
677
  quality: format === 'jpeg' ? 92 : undefined
628
678
  } as any)
629
- return { base64: buffer.toString('base64'), mime: 'image/' + format }
679
+ return { base64: buffer.toString('base64'), mime: 'image/' + format, dsf: deviceScaleFactor }
630
680
  }
631
681
 
632
682
  /**
@@ -745,9 +795,74 @@ function unwrapImageProxy (html: string): string {
745
795
  })
746
796
  }
747
797
 
798
+ /**
799
+ * 「这张卡片该用 1x 还是 2x 渲染」——**不猜,试**。
800
+ *
801
+ * 起因:模板按 1440 CSS 像素排版,长图切片时又会被压回 1440 宽再切
802
+ * (见 ImageSlice 的 SLICE_WIDTH),所以长卡片用 2x 渲染出来的 2880 宽像素一个都用不上。
803
+ *
804
+ * 但不能按「高不高」一刀切:同一台机器上实测(都是 1440 宽、同样的 HTML),
805
+ * 内核在不同倍率下走的**光栅化路径不同**,谁快完全看内容 ——
806
+ * 评论卡(9489 CSS 高):1x 1.5s vs 2x 3.9s,1x 快 2.6 倍;
807
+ * B 站评论卡(17965):1x 2.0s vs 2x 7.4s,1x 快 3.7 倍;
808
+ * 图集卡(2854):1x 7.8s vs 2x 3.0s,**2x 反而快 2.6 倍**;
809
+ * 直播推荐卡(2593):1x 10.1s vs 2x 4.4s,**2x 快 2.3 倍**。
810
+ * 按高度猜必然错一半,所以这里改成**每条路由各测一次**:第一次用配置的倍率,
811
+ * 第二次换另一档,之后固定用快的那档 —— 代价是每条路由多一次渲染(只在进程活着期间),
812
+ * 换来的是之后每次都走对路。用户把 renderScale 设成 50(只要 1x)时不试,直接听配置。
813
+ */
814
+ interface CardScaleTrial {
815
+ /** 目前认为更快的倍率 */
816
+ best: number
817
+ bestMs: number
818
+ /** 还没试过的另一档,试完就清空 */
819
+ pending?: number
820
+ }
821
+
822
+ const cardScaleTrials = new Map<string, CardScaleTrial>()
823
+
824
+ /** 配置里的倍率(面板 renderScale:100 = 2x,150 = 3x,50 = 1x) */
825
+ function configuredCardScale (): number {
826
+ return Math.min(2, Math.max(0.25, Number(Config.app?.renderScale ?? 100) / 100))
827
+ }
828
+
829
+ /** 这张卡片这次该用多少倍渲染,以及这次是不是「试探」 */
830
+ function resolveCardScale (route: string): { scale: number; trial: boolean } {
831
+ const configured = configuredCardScale()
832
+ // 用户明确只要 1x:不试,直接听配置
833
+ if (configured <= 0.5) return { scale: configured, trial: false }
834
+ const entry = cardScaleTrials.get(route)
835
+ if (!entry) return { scale: configured, trial: false }
836
+ if (entry.pending !== undefined) return { scale: entry.pending, trial: true }
837
+ return { scale: entry.best, trial: false }
838
+ }
839
+
840
+ /** 渲染完把这次的耗时喂回来,决定这条路之后固定用哪一档 */
841
+ function recordCardScale (route: string, scale: number, ms: number): void {
842
+ const configured = configuredCardScale()
843
+ if (configured <= 0.5) return
844
+ const entry = cardScaleTrials.get(route)
845
+ if (!entry) {
846
+ cardScaleTrials.set(route, { best: scale, bestMs: ms, pending: scale > 0.5 ? 0.5 : configured })
847
+ return
848
+ }
849
+ if (entry.pending !== undefined && Math.abs(scale - entry.pending) < 1e-6) {
850
+ const better = ms < entry.bestMs
851
+ const best = better ? scale : entry.best
852
+ const bestMs = better ? ms : entry.bestMs
853
+ logger.info('[Render] ' + route + ' 倍率测完了:固定用 ' + best * 2 + 'x(' + Math.round(bestMs) + 'ms,'
854
+ + '本次 ' + scale * 2 + 'x=' + Math.round(ms) + 'ms / 上次 ' + entry.best * 2 + 'x=' + Math.round(entry.bestMs) + 'ms)')
855
+ cardScaleTrials.set(route, { best, bestMs })
856
+ return
857
+ }
858
+ // 稳态:顺手刷新一下耗时,万一机器负载变化明显也能留着对比
859
+ entry.bestMs = ms
860
+ }
861
+
748
862
  export const Render = async (_event: any, route: string, data: any): Promise<any[]> => {
863
+ const startedAt = Date.now()
749
864
  const dark = await resolveUseDarkTheme(route, data).catch(() => false)
750
- const scale = Math.min(2, Math.max(0.5, Number(Config.app?.renderScale ?? 100) / 100))
865
+ const { scale, trial } = resolveCardScale(route)
751
866
 
752
867
  let html = await renderTemplateHtml(route, data, dark)
753
868
  if (!html) {
@@ -765,10 +880,17 @@ export const Render = async (_event: any, route: string, data: any): Promise<any
765
880
  const isQrCode = /qrcode/i.test(route)
766
881
  const format: 'png' | 'jpeg' = isQrCode ? 'png' : 'jpeg'
767
882
  // mime 由 screenshot 返回实际产出的格式:shotkit 走 PNG,其余路径仍是传入的 format
768
- const { base64, mime } = await screenshot(htmlPath, '#container', Number(Config.app?.RenderWaitTime ?? 10) * 1000, scale, format)
883
+ const htmlBuiltAt = Date.now()
884
+ const { base64, mime, dsf } = await screenshot(htmlPath, '#container', Number(Config.app?.RenderWaitTime ?? 10) * 1000, scale, format, html)
885
+ const shotAt = Date.now()
769
886
  const buffer = Buffer.from(base64, 'base64')
770
887
  const meta = getImageMetadata(buffer)
771
- logger.debug('[Render] ' + route + ' 渲染完成 ' + mime + ' ' + (meta.width ?? '?') + 'x' + (meta.height ?? '?') + ' ' + Math.round(buffer.length / 1024) + 'KB')
888
+ // 把这次的截图耗时喂回去,决定这条路之后固定用哪一档(见上面 cardScaleTrials)
889
+ recordCardScale(route, scale, shotAt - htmlBuiltAt)
890
+ logger.info('[Render] ' + route + ' 渲染完成 ' + mime + ' ' + (meta.width ?? '?') + 'x' + (meta.height ?? '?')
891
+ + ' ' + Math.round(buffer.length / 1024) + 'KB'
892
+ + ' 用时 ' + (shotAt - startedAt) + 'ms(页面 ' + (htmlBuiltAt - startedAt) + 'ms / 截图 ' + (shotAt - htmlBuiltAt)
893
+ + 'ms,' + dsf + 'x' + (trial ? ',本次试另一档倍率' : '') + ')')
772
894
  // 用带 mime 的 data URL,适配器才知道按对应格式上传(base64:// 会被当成 PNG)
773
895
  return [segment.image('data:' + mime + ';base64,' + base64)]
774
896
  } catch (error: any) {
@@ -18,7 +18,13 @@ export interface appConfig {
18
18
  */
19
19
  renderer: 'shotkit' | 'puppeteer'
20
20
 
21
- /** 渲染精度,可选值50~200,建议100。设置高精度会提高图片的精细度,过高可能会影响渲染与发送速度 */
21
+ /**
22
+ * 渲染精度,可选值50~200,建议100。设置高精度会提高图片的精细度,过高可能会影响渲染与发送速度。
23
+ *
24
+ * 这是**起点**,不是死值:内核在 1x / 2x 下走的渲染路径不同,谁快看内容(实测评论卡 1x 快 2.7 倍、
25
+ * 小红书笔记卡 2x 快 3 倍),所以每条路由会各测一次(第一次用这里的倍率,第二次换另一档),
26
+ * 之后固定用快的那档。设成 50(只要 1x)时不测,直接听配置。
27
+ */
22
28
  renderScale: number
23
29
 
24
30
  /**