koishi-plugin-kkk 3.0.0 → 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 +28 -0
- package/lib/karin/module/utils/Render/index.js +110 -13
- package/lib/karin/platform/bilibili/bilibili.js +51 -14
- package/lib/player/index.js +6 -1
- package/lib/player/page.js +77 -9
- package/lib/player/server.js +138 -17
- package/lib/player/store.js +73 -0
- package/package.json +1 -1
- package/scripts/smoke-config.cjs +12 -12
- package/scripts/smoke-player.cjs +15 -5
- package/src/karin/module/utils/Render/index.ts +135 -13
- package/src/karin/platform/bilibili/bilibili.ts +49 -14
- package/src/karin/types/config/app.ts +7 -1
- package/src/player/index.ts +11 -1
- package/src/player/page.ts +77 -9
- package/src/player/server.ts +108 -16
- package/src/player/store.ts +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
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
|
+
- 顺带:渲染日志现在会写清**每张卡片花了多久**(页面多少毫秒 / 截图多少毫秒),慢的时候能一眼看出是慢在哪一步。
|
|
24
|
+
|
|
25
|
+
### 在线播放:声音不再等服务器
|
|
26
|
+
|
|
27
|
+
- **默认不在服务器上合视频和声音了**:播放页会同时放画面和声音,由浏览器自己对好(原来要等 ffmpeg 在服务器上合完才能开始放,等得久)。
|
|
28
|
+
- **下载按钮分成了三个**:「下载画面」(只有画面的那份,和以前一样)、「下载声音」(单独把声音存下来)、「合成后下载」(连声音一起下载,点下去服务器才去合并,合好再给你;合过一次会记住,再点马上就好)。
|
|
29
|
+
- 页面上的静音按钮现在同时管画面和声音;拖动进度条时声音也会跟着对齐。
|
|
30
|
+
|
|
3
31
|
## 3.0.0
|
|
4
32
|
|
|
5
33
|
> 这一版主要两件事:**让它快**、**该发出去的东西真的发出去**。
|
|
@@ -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
|
}
|
|
@@ -324,7 +324,7 @@ class Bilibili extends utils_1.Base {
|
|
|
324
324
|
}
|
|
325
325
|
else {
|
|
326
326
|
/**
|
|
327
|
-
* 免登录直链分支:体积取自 html5 播放接口的
|
|
327
|
+
* 免登录直链分支:体积取自 html5 播放接口的 `durl[0].size`。
|
|
328
328
|
* 这个接口会偶发拿不到 durl(风控、字段变化、超时),原来这里直接下标取值,
|
|
329
329
|
* 一旦为空就抛 TypeError,**整条解析直接失败**(用户侧表现就是「提示开始解析,然后没下文」)。
|
|
330
330
|
* 体积只是展示信息,取不到就按 0 处理,不要拖垮解析。
|
|
@@ -1319,6 +1319,12 @@ class Bilibili extends utils_1.Base {
|
|
|
1319
1319
|
* - 本地文件(登录态合成/烧录的产物、免登录时提前下好的直链);
|
|
1320
1320
|
* - 没有下载产物时保持 null(例如体积超限根本没下)。
|
|
1321
1321
|
*/
|
|
1322
|
+
/**
|
|
1323
|
+
* 已经准备好的视频(见 prepareVideo)。
|
|
1324
|
+
*
|
|
1325
|
+
* `audioPath` 只有**在线播放 + 音视频分离(B站)**时才有:表示音轨单独存了一份,
|
|
1326
|
+
* 播放页会同时播这两个文件(默认不合成,用户要求)。
|
|
1327
|
+
*/
|
|
1322
1328
|
preparedVideo = null;
|
|
1323
1329
|
/**
|
|
1324
1330
|
* 下载视频(含合成音轨、烧录弹幕),**不发送**。
|
|
@@ -1421,23 +1427,52 @@ class Bilibili extends utils_1.Base {
|
|
|
1421
1427
|
}));
|
|
1422
1428
|
sourcePath = resultPath;
|
|
1423
1429
|
}
|
|
1430
|
+
else if ((0, player_1.isOnlinePlayerRequest)()) {
|
|
1431
|
+
/**
|
|
1432
|
+
* **在线播放:默认不合成**(用户要求)。
|
|
1433
|
+
*
|
|
1434
|
+
* B站的音视频是分离的,以前这里必定调一次 ffmpeg 合成(几分钟的视频也要几秒到几十秒,
|
|
1435
|
+
* 低配机器更久)。改成:画面和声音**各自留一份**,播放页用 `<video muted>` + `<audio>`
|
|
1436
|
+
* 同时播;只有用户在播放页点「服务器合并后下载」时才按需合成(见 player/server.ts)。
|
|
1437
|
+
*/
|
|
1438
|
+
success = true;
|
|
1439
|
+
sourcePath = bmp4.filepath;
|
|
1440
|
+
node_karin_1.logger.mark('[在线播放] 跳过音视频合成:画面与声音分开存,浏览器端同时播放');
|
|
1441
|
+
}
|
|
1424
1442
|
else {
|
|
1425
1443
|
success = await (0, utils_1.mergeVideoAudio)(bmp4.filepath, bmp3.filepath, resultPath);
|
|
1426
1444
|
sourcePath = resultPath;
|
|
1427
1445
|
}
|
|
1428
1446
|
if (success) {
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1447
|
+
/**
|
|
1448
|
+
* 在线播放(音视频分离)时**不要重命名** —— 画面那个文件原样留着,
|
|
1449
|
+
* 「audioPath」指向单独的音轨文件,两个都由 publishOnlinePlayer 搬进会话目录。
|
|
1450
|
+
*/
|
|
1451
|
+
const separateAudio = (0, player_1.isOnlinePlayerRequest)() && !!bmp3 && sourcePath === bmp4.filepath;
|
|
1452
|
+
if (separateAudio) {
|
|
1453
|
+
const videoStats = node_fs_1.default.statSync(bmp4.filepath);
|
|
1454
|
+
this.preparedVideo = {
|
|
1455
|
+
filepath: bmp4.filepath,
|
|
1456
|
+
totalBytes: Number((videoStats.size / (1024 * 1024)).toFixed(2)),
|
|
1457
|
+
originTitle: this.downloadfilename,
|
|
1458
|
+
audioPath: bmp3.filepath
|
|
1459
|
+
};
|
|
1460
|
+
node_karin_1.logger.mark('[在线播放] 已就绪:画面 + 声音分开,交给播放页同时播放');
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
const filePath = utils_1.Common.tempDri.video + `${Config_1.Config.app.removeCache ? 'tmp_' + Date.now() : this.downloadfilename}.mp4`;
|
|
1464
|
+
node_fs_1.default.renameSync(sourcePath, filePath);
|
|
1465
|
+
node_karin_1.logger.mark(`视频文件重命名完成: ${sourcePath.split('/').pop()} -> ${filePath.split('/').pop()}`);
|
|
1466
|
+
node_karin_1.logger.mark('正在尝试删除缓存文件');
|
|
1467
|
+
if (node_fs_1.default.existsSync(bmp4.filepath))
|
|
1468
|
+
await utils_1.Common.removeFile(bmp4.filepath, true);
|
|
1469
|
+
if (bmp3 && node_fs_1.default.existsSync(bmp3.filepath))
|
|
1470
|
+
await utils_1.Common.removeFile(bmp3.filepath, true);
|
|
1471
|
+
const stats = node_fs_1.default.statSync(filePath);
|
|
1472
|
+
const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2));
|
|
1473
|
+
// 本地合成的没有视频直链,交给 sendPreparedVideo 上传
|
|
1474
|
+
this.preparedVideo = { filepath: filePath, totalBytes: fileSizeInMB, originTitle: this.downloadfilename };
|
|
1475
|
+
}
|
|
1441
1476
|
}
|
|
1442
1477
|
else {
|
|
1443
1478
|
await utils_1.Common.removeFile(bmp4.filepath, true);
|
|
@@ -1528,7 +1563,7 @@ class Bilibili extends utils_1.Base {
|
|
|
1528
1563
|
this.preparedVideo = null;
|
|
1529
1564
|
if (!prepared)
|
|
1530
1565
|
return false;
|
|
1531
|
-
const { filepath, totalBytes, originTitle, videoUrl } = prepared;
|
|
1566
|
+
const { filepath, totalBytes, originTitle, videoUrl, audioPath } = prepared;
|
|
1532
1567
|
/**
|
|
1533
1568
|
* 在线播放模式:不烧录、也不上传,直接把下好的视频登记成播放会话,
|
|
1534
1569
|
* 回一条公网链接(弹幕存下来给播放页用)。
|
|
@@ -1538,6 +1573,8 @@ class Bilibili extends utils_1.Base {
|
|
|
1538
1573
|
if ((0, player_1.isOnlinePlayerRequest)()) {
|
|
1539
1574
|
const published = await (0, player_1.publishOnlinePlayer)(this.e, {
|
|
1540
1575
|
videoPath: filepath,
|
|
1576
|
+
// 分离音轨:一起交给播放页(默认不合成,浏览器里同时播)
|
|
1577
|
+
audioPath,
|
|
1541
1578
|
title: originTitle || this.downloadfilename || this.workInfo?.title,
|
|
1542
1579
|
platform: 'bilibili',
|
|
1543
1580
|
danmaku: this.danmakuList,
|
package/lib/player/index.js
CHANGED
|
@@ -526,7 +526,11 @@ async function publishOnlinePlayer(e, input) {
|
|
|
526
526
|
* 体积上限:超过就不做在线播放,回一句说明并返回 false ——
|
|
527
527
|
* 调用方拿到 false 会退回「直接发送视频文件」,用户不会什么都没有。
|
|
528
528
|
*/
|
|
529
|
-
|
|
529
|
+
/** 有独立音轨时按「画面 + 声音」的总和算,免得两条加起来把磁盘塞爆 */
|
|
530
|
+
const audioBytes = input.audioPath && node_fs_1.default.existsSync(input.audioPath)
|
|
531
|
+
? Number(node_fs_1.default.statSync(input.audioPath).size) || 0
|
|
532
|
+
: 0;
|
|
533
|
+
const sizeMB = (Number(node_fs_1.default.statSync(input.videoPath).size) + audioBytes) / 1024 / 1024;
|
|
530
534
|
const limitMB = await resolvePlayerSizeLimitMB();
|
|
531
535
|
if (limitMB > 0 && sizeMB > limitMB) {
|
|
532
536
|
/**
|
|
@@ -547,6 +551,7 @@ async function publishOnlinePlayer(e, input) {
|
|
|
547
551
|
const localOnly = !hasPublicBaseUrl();
|
|
548
552
|
const session = (0, store_1.registerPlayerSession)({
|
|
549
553
|
videoPath: input.videoPath,
|
|
554
|
+
audioPath: input.audioPath,
|
|
550
555
|
title: input.title,
|
|
551
556
|
platform: input.platform,
|
|
552
557
|
danmaku,
|
package/lib/player/page.js
CHANGED
|
@@ -226,6 +226,9 @@ const PLAYER_STYLE = [
|
|
|
226
226
|
'background:var(--pink);color:#fff;font-size:14px;font-weight:600;cursor:pointer;}',
|
|
227
227
|
'.dlbtn svg{font-size:20px}',
|
|
228
228
|
'.dlbtn:hover{background:#ff8aac}',
|
|
229
|
+
/** 「合成后下载」是主按钮(另外两个只是分别下载原始流),稍微区分一下 */
|
|
230
|
+
'.dlbtn.main{background:#fb7299;color:#fff}',
|
|
231
|
+
'.dlbtn.main:hover{background:#ff8aac}',
|
|
229
232
|
'.dlbtn:focus-visible{outline:2px solid #fff;outline-offset:2px}',
|
|
230
233
|
'.dlhint{color:var(--muted);font-size:12px;}',
|
|
231
234
|
/* 没配公网地址时的提示条:用户打不开得知道是「没配域名」,不是「插件坏了」 */
|
|
@@ -287,6 +290,8 @@ function renderPlayerPage(info) {
|
|
|
287
290
|
const platform = escapeHtml(platformLabel(info.platform));
|
|
288
291
|
const expire = formatExpire(info.expireAt);
|
|
289
292
|
const danmakuCount = Number(info.danmakuCount) || 0;
|
|
293
|
+
/** 有没有**单独的音轨**(B站这类音视频分离的):有就 <video muted> + <audio> 同时播 */
|
|
294
|
+
const hasAudio = typeof info.audioPath === 'string' && info.audioPath.length > 0;
|
|
290
295
|
const duration = formatDuration(info.durationSeconds);
|
|
291
296
|
const script = PLAYER_SCRIPT.split('__KKK_TOKEN__').join(token);
|
|
292
297
|
const stats = [
|
|
@@ -338,7 +343,12 @@ function renderPlayerPage(info) {
|
|
|
338
343
|
+ ' </div>\n'
|
|
339
344
|
+ ' <div class="player">\n'
|
|
340
345
|
+ ' <div class="video-wrap" id="videoWrap">\n'
|
|
341
|
-
|
|
346
|
+
/**
|
|
347
|
+
* 音视频分离时:视频**静音**播画面,声音交给下面这个 <audio> 一起播
|
|
348
|
+
* (用户要求:默认不在服务器合成,浏览器端同时播放即可)。
|
|
349
|
+
*/
|
|
350
|
+
+ ' <video id="video" src="/kkk/player/' + token + '/video" preload="metadata" playsinline webkit-playsinline' + (hasAudio ? ' muted' : '') + '></video>\n'
|
|
351
|
+
+ (hasAudio ? ' <audio id="audioTrack" src="/kkk/player/' + token + '/audio" preload="auto"></audio>\n' : '')
|
|
342
352
|
+ ' <canvas id="danmaku"></canvas>\n'
|
|
343
353
|
+ ' <div id="notice" class="notice" hidden></div>\n'
|
|
344
354
|
+ ' <button type="button" class="bigplay" id="bigPlay" aria-label="播放">' + ICON.play + '</button>\n'
|
|
@@ -384,9 +394,23 @@ function renderPlayerPage(info) {
|
|
|
384
394
|
* 放在播放器下面单独一条 —— 作品信息可能缺(那时不会渲染操作按钮排),下载入口不能跟着一起消失。
|
|
385
395
|
*/
|
|
386
396
|
+ ' <div class="downbar">'
|
|
387
|
-
+
|
|
388
|
-
|
|
389
|
-
|
|
397
|
+
+ (hasAudio
|
|
398
|
+
/**
|
|
399
|
+
* 音视频分离:给两个「分别下载」+ 一个「服务器合成后下载」。
|
|
400
|
+
* 分别是秒下(就是两个原文件),合成那条第一次点要等一两秒(服务器跑一次 ffmpeg -c copy)。
|
|
401
|
+
*/
|
|
402
|
+
? '<a class="dlbtn" href="/kkk/player/' + token + '/video?download=1" download>'
|
|
403
|
+
+ ICON.download + '<span>下载画面</span></a>'
|
|
404
|
+
+ '<a class="dlbtn" href="/kkk/player/' + token + '/audio?download=1" download>'
|
|
405
|
+
+ ICON.download + '<span>下载声音</span></a>'
|
|
406
|
+
+ '<a class="dlbtn main" href="/kkk/player/' + token + '/merged?download=1" download>'
|
|
407
|
+
+ ICON.download + '<span>合成后下载</span></a>'
|
|
408
|
+
+ '<span class="dlhint">画面与声音是分开的两条流:前两个直接下载,最后一个由服务器合成成一个视频(第一次要等一两秒);'
|
|
409
|
+
+ '想在播放器里看就直接播放,声音会自动跟上</span>'
|
|
410
|
+
: '<a class="dlbtn" id="downloadBtn" href="/kkk/player/' + token + '/video?download=1" download>'
|
|
411
|
+
+ ICON.download + '<span>下载视频</span></a>'
|
|
412
|
+
+ '<span class="dlhint">保存到本机(原画质,不重新编码)</span>')
|
|
413
|
+
+ '</div>\n'
|
|
390
414
|
+ linkRow + '\n'
|
|
391
415
|
+ (actions ? ' <div class="actions">' + actions + '</div>\n' : '')
|
|
392
416
|
+ (info.localOnly
|
|
@@ -579,6 +603,14 @@ const PLAYER_SCRIPT = [
|
|
|
579
603
|
" var notice = document.getElementById('notice')",
|
|
580
604
|
" var video = document.getElementById('video')",
|
|
581
605
|
" var canvas = document.getElementById('danmaku')",
|
|
606
|
+
" /**",
|
|
607
|
+
" * 单独的音轨(B站这类音视频分离的)。有它的时候:",
|
|
608
|
+
" * - 视频元素是 muted 的(只出画面),声音由这个 <audio> 出;",
|
|
609
|
+
" * - 播放 / 暂停 / 拖动进度都要**带着它一起**,否则声音会跟画面错开。",
|
|
610
|
+
" */",
|
|
611
|
+
" var audioTrack = document.getElementById('audioTrack')",
|
|
612
|
+
" /** 音量控制作用在谁身上:有独立音轨就是音轨,否则是视频自己 */",
|
|
613
|
+
" var soundEl = audioTrack || video",
|
|
582
614
|
" var toggle = document.getElementById('dmOn')",
|
|
583
615
|
" var countEl = document.getElementById('dmCount')",
|
|
584
616
|
" var settingsBtn = document.getElementById('dmSettingsBtn')",
|
|
@@ -763,6 +795,8 @@ const PLAYER_SCRIPT = [
|
|
|
763
795
|
" cursor++",
|
|
764
796
|
" guard++",
|
|
765
797
|
" }",
|
|
798
|
+
" // 音画同步:偏差超过 0.25 秒就拉回来(长时间播放 / 卡顿后必须对一次)",
|
|
799
|
+
" if (audioTrack && !video.paused && !video.ended && !video.seeking) alignAudio()",
|
|
766
800
|
" draw(dt, now)",
|
|
767
801
|
" }",
|
|
768
802
|
"",
|
|
@@ -967,12 +1001,34 @@ const PLAYER_SCRIPT = [
|
|
|
967
1001
|
" })",
|
|
968
1002
|
" }",
|
|
969
1003
|
"",
|
|
1004
|
+
" /** 把音轨拉到和画面同一个位置(拖动 / 卡顿时对表) */",
|
|
1005
|
+
" function alignAudio () {",
|
|
1006
|
+
" if (!audioTrack) return",
|
|
1007
|
+
" try {",
|
|
1008
|
+
" if (Math.abs((audioTrack.currentTime || 0) - video.currentTime) > 0.25) audioTrack.currentTime = video.currentTime",
|
|
1009
|
+
" } catch (error) { /* 还没加载好就等下一帧 */ }",
|
|
1010
|
+
" }",
|
|
1011
|
+
"",
|
|
1012
|
+
" function playAudio () {",
|
|
1013
|
+
" if (!audioTrack) return",
|
|
1014
|
+
" alignAudio()",
|
|
1015
|
+
" var started = audioTrack.play()",
|
|
1016
|
+
" if (started && started.catch) started.catch(function () { /* 画面已经在放,声音起不来就先算了 */ })",
|
|
1017
|
+
" }",
|
|
1018
|
+
"",
|
|
1019
|
+
" function pauseAudio () {",
|
|
1020
|
+
" if (!audioTrack) return",
|
|
1021
|
+
" try { audioTrack.pause() } catch (error) { /* 忽略 */ }",
|
|
1022
|
+
" }",
|
|
1023
|
+
"",
|
|
970
1024
|
" function togglePlay () {",
|
|
971
1025
|
" if (video.paused || video.ended) {",
|
|
972
1026
|
" var started = video.play()",
|
|
973
1027
|
" if (started && started.catch) started.catch(function () { showNotice('浏览器拦住了自动播放,点一下画面中间的播放按钮就行') })",
|
|
1028
|
+
" playAudio()",
|
|
974
1029
|
" } else {",
|
|
975
1030
|
" video.pause()",
|
|
1031
|
+
" pauseAudio()",
|
|
976
1032
|
" }",
|
|
977
1033
|
" }",
|
|
978
1034
|
"",
|
|
@@ -984,22 +1040,34 @@ const PLAYER_SCRIPT = [
|
|
|
984
1040
|
" }",
|
|
985
1041
|
"",
|
|
986
1042
|
" function syncMute () {",
|
|
987
|
-
" if (muteBtn) muteBtn.classList.toggle('is-muted',
|
|
1043
|
+
" if (muteBtn) muteBtn.classList.toggle('is-muted', soundEl.muted || soundEl.volume === 0)",
|
|
988
1044
|
" }",
|
|
989
1045
|
"",
|
|
990
|
-
" video.addEventListener('play', syncPlayState)",
|
|
991
|
-
" video.addEventListener('pause', syncPlayState)",
|
|
992
|
-
" video.addEventListener('ended', syncPlayState)",
|
|
1046
|
+
" video.addEventListener('play', function () { syncPlayState(); playAudio() })",
|
|
1047
|
+
" video.addEventListener('pause', function () { syncPlayState(); pauseAudio() })",
|
|
1048
|
+
" video.addEventListener('ended', function () { syncPlayState(); pauseAudio() })",
|
|
993
1049
|
" video.addEventListener('timeupdate', drawProgress)",
|
|
994
1050
|
" video.addEventListener('progress', drawProgress)",
|
|
995
1051
|
" video.addEventListener('durationchange', drawProgress)",
|
|
996
1052
|
" video.addEventListener('loadedmetadata', drawProgress)",
|
|
997
1053
|
" video.addEventListener('volumechange', syncMute)",
|
|
1054
|
+
" // 拖动进度(含键盘 / 快进):画面跳完马上把音轨对齐,不然会差出好几秒",
|
|
1055
|
+
" video.addEventListener('seeked', alignAudio)",
|
|
1056
|
+
" if (audioTrack) {",
|
|
1057
|
+
" audioTrack.addEventListener('volumechange', syncMute)",
|
|
1058
|
+
" // 音轨自己卡住 / 被系统暂停时,跟着画面走",
|
|
1059
|
+
" audioTrack.addEventListener('pause', function () { if (!video.paused && !video.ended) playAudio() })",
|
|
1060
|
+
" audioTrack.addEventListener('error', function () { showNotice('声音加载失败,画面继续播放(可以在下面单独下载声音)') })",
|
|
1061
|
+
" }",
|
|
998
1062
|
" if (playBtn) playBtn.addEventListener('click', togglePlay)",
|
|
999
1063
|
" if (bigPlay) bigPlay.addEventListener('click', togglePlay)",
|
|
1000
1064
|
" // 点画面中间也能播放 / 暂停(弹幕 canvas 是 pointer-events:none,点它等于点在 video 上)",
|
|
1001
1065
|
" video.addEventListener('click', togglePlay)",
|
|
1002
|
-
" if (muteBtn)
|
|
1066
|
+
" if (muteBtn) {",
|
|
1067
|
+
" // 有独立音轨时,静音 / 取消静音作用在音轨上(视频一直是 muted 的)",
|
|
1068
|
+
" if (audioTrack) soundEl.muted = true",
|
|
1069
|
+
" muteBtn.addEventListener('click', function () { soundEl.muted = !soundEl.muted; syncMute() })",
|
|
1070
|
+
" }",
|
|
1003
1071
|
"",
|
|
1004
1072
|
" function isFullscreen () { return !!(document.fullscreenElement || document.webkitFullscreenElement) }",
|
|
1005
1073
|
"",
|