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
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
package/scripts/smoke-config.cjs
CHANGED
|
@@ -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 的默认值就是
|
|
154
|
-
// 1)
|
|
155
|
-
applyUpstreamOverrides({ app: { fakeForward:
|
|
156
|
-
const
|
|
157
|
-
check('
|
|
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:
|
|
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 ===
|
|
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:
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
/**
|