karin-plugin-kkk 2.41.2 → 2.42.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 +13 -0
- package/lib/build-metadata.json +5 -5
- package/lib/core_chunk/main.js +630 -109
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [2.42.0](https://github.com/ikenxuan/karin-plugin-kkk/compare/v2.41.2...v2.42.0) (2026-08-02)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### ✨ 新功能
|
|
9
|
+
|
|
10
|
+
* **douyin:** 弹幕烧录支持显示点赞数量与表情 ([1d46900](https://github.com/ikenxuan/karin-plugin-kkk/commit/1d4690068478b201084259757024da4c612f91ef))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### 🐛 错误修复
|
|
14
|
+
|
|
15
|
+
* ci? ([d5dfb4f](https://github.com/ikenxuan/karin-plugin-kkk/commit/d5dfb4f0f663c19292d40af6d3948ba62815a32c))
|
|
16
|
+
* ci? ([2f713f0](https://github.com/ikenxuan/karin-plugin-kkk/commit/2f713f08f17b99f03d3337f87b1829781c18fd57))
|
|
17
|
+
|
|
5
18
|
## [2.41.2](https://github.com/ikenxuan/karin-plugin-kkk/compare/v2.41.1...v2.41.2) (2026-08-02)
|
|
6
19
|
|
|
7
20
|
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
3
|
-
"buildTime": "2026-08-
|
|
4
|
-
"buildTimestamp":
|
|
2
|
+
"version": "2.42.0",
|
|
3
|
+
"buildTime": "2026-08-03T00:23:16.502Z",
|
|
4
|
+
"buildTimestamp": 1785716596502,
|
|
5
5
|
"name": "karin-plugin-kkk",
|
|
6
6
|
"description": "Karin 的「抖音」「B 站」视频解析/动态推送插件",
|
|
7
7
|
"homepage": "https://github.com/ikenxuan/karin-plugin-kkk",
|
|
8
|
-
"commitHash": "
|
|
9
|
-
"shortCommitHash": "
|
|
8
|
+
"commitHash": "f3d273bcaa5c34e490d16e281818459725d79e32",
|
|
9
|
+
"shortCommitHash": "f3d273bc"
|
|
10
10
|
}
|
package/lib/core_chunk/main.js
CHANGED
|
@@ -38435,7 +38435,8 @@ var douyinComments = async (data, emojidata) => {
|
|
|
38435
38435
|
/**
|
|
38436
38436
|
* 抖音弹幕处理模块
|
|
38437
38437
|
* 负责抖音弹幕数据转换、ASS 字幕生成、视频烧录等功能
|
|
38438
|
-
*
|
|
38438
|
+
* 抖音弹幕只有滚动弹幕:纯文字弹幕走 ASS \move 烧录;
|
|
38439
|
+
* 含平台表情或被选中展示点赞角标的弹幕预渲染为透明 PNG 条,通过 overlay 滤镜叠加(libass 不支持内嵌图片)
|
|
38439
38440
|
*/
|
|
38440
38441
|
var ENCODER_PRIORITY = {
|
|
38441
38442
|
h264: [
|
|
@@ -38464,6 +38465,11 @@ var SOFTWARE_FALLBACK = {
|
|
|
38464
38465
|
av1: "libsvtav1"
|
|
38465
38466
|
};
|
|
38466
38467
|
var cachedEncoders = {};
|
|
38468
|
+
/**
|
|
38469
|
+
* 按优先级探测可用的硬件/软件编码器(结果缓存)
|
|
38470
|
+
* @param codec 目标编码格式
|
|
38471
|
+
* @returns 可用的 ffmpeg 编码器名
|
|
38472
|
+
*/
|
|
38467
38473
|
async function detectEncoder(codec) {
|
|
38468
38474
|
if (cachedEncoders[codec]) return cachedEncoders[codec];
|
|
38469
38475
|
logger.debug(`[DouyinDanmaku] 开始检测 ${codec.toUpperCase()} 编码器...`);
|
|
@@ -38479,7 +38485,11 @@ async function detectEncoder(codec) {
|
|
|
38479
38485
|
logger.info(`[DouyinDanmaku] 回退到软件编码器: ${fallback}`);
|
|
38480
38486
|
return fallback;
|
|
38481
38487
|
}
|
|
38482
|
-
/**
|
|
38488
|
+
/**
|
|
38489
|
+
* 获取视频平均码率
|
|
38490
|
+
* @param path 视频文件路径
|
|
38491
|
+
* @returns 平均码率(kbps),获取失败返回 0
|
|
38492
|
+
*/
|
|
38483
38493
|
async function getVideoBitrate(path) {
|
|
38484
38494
|
try {
|
|
38485
38495
|
const fileSize = fs.statSync(path).size;
|
|
@@ -38494,46 +38504,65 @@ async function getVideoBitrate(path) {
|
|
|
38494
38504
|
} catch {}
|
|
38495
38505
|
return 0;
|
|
38496
38506
|
}
|
|
38507
|
+
/** 各编码器的目标码率模式参数模板 */
|
|
38508
|
+
var BITRATE_MODE_PARAMS = {
|
|
38509
|
+
h264_nvenc: ({ b, m, u }) => `-c:v h264_nvenc -preset p4 -rc vbr -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38510
|
+
h264_qsv: ({ b, m, u }) => `-c:v h264_qsv -preset medium -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38511
|
+
h264_amf: ({ b, m }) => `-c:v h264_amf -quality balanced -rc vbr_peak -b:v ${b} -maxrate ${m}`,
|
|
38512
|
+
libx264: ({ b, m, u, t }) => `-c:v libx264 -preset medium -b:v ${b} -maxrate ${m} -bufsize ${u} -threads ${t}`,
|
|
38513
|
+
hevc_nvenc: ({ b, m, u }) => `-c:v hevc_nvenc -preset p4 -rc vbr -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38514
|
+
hevc_qsv: ({ b, m, u }) => `-c:v hevc_qsv -preset medium -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38515
|
+
hevc_amf: ({ b, m }) => `-c:v hevc_amf -quality balanced -rc vbr_peak -b:v ${b} -maxrate ${m}`,
|
|
38516
|
+
libx265: ({ b, m, u, t }) => `-c:v libx265 -preset medium -b:v ${b} -maxrate ${m} -bufsize ${u} -threads ${t}`,
|
|
38517
|
+
av1_nvenc: ({ b, m, u }) => `-c:v av1_nvenc -preset p4 -rc vbr -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38518
|
+
av1_qsv: ({ b, m, u }) => `-c:v av1_qsv -preset medium -b:v ${b} -maxrate ${m} -bufsize ${u}`,
|
|
38519
|
+
av1_amf: ({ b, m }) => `-c:v av1_amf -quality balanced -rc vbr_peak -b:v ${b} -maxrate ${m}`,
|
|
38520
|
+
libsvtav1: ({ b, m, u, t }) => `-c:v libsvtav1 -preset 6 -b:v ${b} -maxrate ${m} -bufsize ${u} -threads ${t}`,
|
|
38521
|
+
"libaom-av1": ({ b, m, u, t }) => `-c:v libaom-av1 -cpu-used 4 -b:v ${b} -maxrate ${m} -bufsize ${u} -threads ${t}`
|
|
38522
|
+
};
|
|
38523
|
+
/** 各编码器的 CRF/CQ 兜底模式参数模板(无码率信息时使用) */
|
|
38524
|
+
var CRF_MODE_PARAMS = {
|
|
38525
|
+
h264_nvenc: () => "-c:v h264_nvenc -preset p4 -rc vbr -cq 23",
|
|
38526
|
+
h264_qsv: () => "-c:v h264_qsv -preset medium -global_quality 23",
|
|
38527
|
+
h264_amf: () => "-c:v h264_amf -quality balanced -rc cqp -qp_i 23 -qp_p 23",
|
|
38528
|
+
libx264: (t) => `-c:v libx264 -crf 23 -preset medium -threads ${t}`,
|
|
38529
|
+
hevc_nvenc: () => "-c:v hevc_nvenc -preset p4 -rc vbr -cq 28",
|
|
38530
|
+
hevc_qsv: () => "-c:v hevc_qsv -preset medium -global_quality 28",
|
|
38531
|
+
hevc_amf: () => "-c:v hevc_amf -quality balanced -rc cqp -qp_i 28 -qp_p 28",
|
|
38532
|
+
libx265: (t) => `-c:v libx265 -crf 28 -preset medium -threads ${t}`,
|
|
38533
|
+
av1_nvenc: () => "-c:v av1_nvenc -preset p4 -rc vbr -cq 30",
|
|
38534
|
+
av1_qsv: () => "-c:v av1_qsv -preset medium -global_quality 30",
|
|
38535
|
+
av1_amf: () => "-c:v av1_amf -quality balanced -rc cqp -qp_i 30 -qp_p 30",
|
|
38536
|
+
libsvtav1: (t) => `-c:v libsvtav1 -crf 30 -preset 6 -threads ${t}`,
|
|
38537
|
+
"libaom-av1": (t) => `-c:v libaom-av1 -crf 30 -cpu-used 4 -threads ${t}`
|
|
38538
|
+
};
|
|
38497
38539
|
/**
|
|
38498
38540
|
* 获取编码器参数
|
|
38541
|
+
* @param encoder detectEncoder 探测到的编码器名
|
|
38542
|
+
* @param targetBitrate 源视频平均码率(kbps),为空或 0 时使用 CRF/CQ 兜底模式
|
|
38543
|
+
* @returns ffmpeg 视频编码参数串
|
|
38499
38544
|
*/
|
|
38500
38545
|
function getEncoderParams(encoder, targetBitrate) {
|
|
38501
38546
|
const threads = Math.max(1, Math.floor(os.cpus().length / 2));
|
|
38502
38547
|
if (targetBitrate && targetBitrate > 0) {
|
|
38503
|
-
const
|
|
38504
|
-
const
|
|
38505
|
-
|
|
38506
|
-
|
|
38507
|
-
|
|
38508
|
-
|
|
38509
|
-
|
|
38510
|
-
|
|
38511
|
-
if (encoder === "hevc_nvenc") return `-c:v hevc_nvenc -preset p4 -rc vbr -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
|
|
38512
|
-
if (encoder === "hevc_qsv") return `-c:v hevc_qsv -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
|
|
38513
|
-
if (encoder === "hevc_amf") return `-c:v hevc_amf -quality balanced -rc vbr_peak -b:v ${bitrateK} -maxrate ${maxrate}`;
|
|
38514
|
-
if (encoder === "libx265") return `-c:v libx265 -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize} -threads ${threads}`;
|
|
38515
|
-
if (encoder === "av1_nvenc") return `-c:v av1_nvenc -preset p4 -rc vbr -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
|
|
38516
|
-
if (encoder === "av1_qsv") return `-c:v av1_qsv -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
|
|
38517
|
-
if (encoder === "av1_amf") return `-c:v av1_amf -quality balanced -rc vbr_peak -b:v ${bitrateK} -maxrate ${maxrate}`;
|
|
38518
|
-
if (encoder === "libsvtav1") return `-c:v libsvtav1 -preset 6 -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize} -threads ${threads}`;
|
|
38519
|
-
if (encoder === "libaom-av1") return `-c:v libaom-av1 -cpu-used 4 -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize} -threads ${threads}`;
|
|
38520
|
-
return `-c:v libx265 -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize} -threads ${threads}`;
|
|
38548
|
+
const adjusted = Math.round(targetBitrate * 1.4);
|
|
38549
|
+
const params = {
|
|
38550
|
+
b: `${adjusted}k`,
|
|
38551
|
+
m: `${Math.round(adjusted * 2.5)}k`,
|
|
38552
|
+
u: `${Math.round(adjusted * 4)}k`,
|
|
38553
|
+
t: threads
|
|
38554
|
+
};
|
|
38555
|
+
return (BITRATE_MODE_PARAMS[encoder] ?? BITRATE_MODE_PARAMS.libx265)(params);
|
|
38521
38556
|
}
|
|
38522
|
-
|
|
38523
|
-
if (encoder === "h264_qsv") return "-c:v h264_qsv -preset medium -global_quality 23";
|
|
38524
|
-
if (encoder === "h264_amf") return "-c:v h264_amf -quality balanced -rc cqp -qp_i 23 -qp_p 23";
|
|
38525
|
-
if (encoder === "libx264") return `-c:v libx264 -crf 23 -preset medium -threads ${threads}`;
|
|
38526
|
-
if (encoder === "hevc_nvenc") return "-c:v hevc_nvenc -preset p4 -rc vbr -cq 28";
|
|
38527
|
-
if (encoder === "hevc_qsv") return "-c:v hevc_qsv -preset medium -global_quality 28";
|
|
38528
|
-
if (encoder === "hevc_amf") return "-c:v hevc_amf -quality balanced -rc cqp -qp_i 28 -qp_p 28";
|
|
38529
|
-
if (encoder === "libx265") return `-c:v libx265 -crf 28 -preset medium -threads ${threads}`;
|
|
38530
|
-
if (encoder === "av1_nvenc") return "-c:v av1_nvenc -preset p4 -rc vbr -cq 30";
|
|
38531
|
-
if (encoder === "av1_qsv") return "-c:v av1_qsv -preset medium -global_quality 30";
|
|
38532
|
-
if (encoder === "av1_amf") return "-c:v av1_amf -quality balanced -rc cqp -qp_i 30 -qp_p 30";
|
|
38533
|
-
if (encoder === "libsvtav1") return `-c:v libsvtav1 -crf 30 -preset 6 -threads ${threads}`;
|
|
38534
|
-
if (encoder === "libaom-av1") return `-c:v libaom-av1 -crf 30 -cpu-used 4 -threads ${threads}`;
|
|
38535
|
-
return `-c:v libx265 -crf 28 -preset medium -threads ${threads}`;
|
|
38557
|
+
return (CRF_MODE_PARAMS[encoder] ?? CRF_MODE_PARAMS.libx265)(threads);
|
|
38536
38558
|
}
|
|
38559
|
+
/** 输出帧率:弹幕逐帧重算位置,高帧率下步进更小、观感更顺滑(视频帧复制,内容不变) */
|
|
38560
|
+
var OUTPUT_FPS = 60;
|
|
38561
|
+
/**
|
|
38562
|
+
* 毫秒转 ASS 时间戳(h:mm:ss.cc,厘秒精度)
|
|
38563
|
+
* @param ms 毫秒时间
|
|
38564
|
+
* @returns ASS 时间戳字符串
|
|
38565
|
+
*/
|
|
38537
38566
|
var toASSTime = (ms) => {
|
|
38538
38567
|
const s = ms / 1e3;
|
|
38539
38568
|
const h = Math.floor(s / 3600);
|
|
@@ -38542,14 +38571,68 @@ var toASSTime = (ms) => {
|
|
|
38542
38571
|
const cs = Math.floor(s % 1 * 100);
|
|
38543
38572
|
return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${cs.toString().padStart(2, "0")}`;
|
|
38544
38573
|
};
|
|
38574
|
+
/**
|
|
38575
|
+
* 估算文本宽度(全角字符按 1 倍字号、半角按 0.5 倍)
|
|
38576
|
+
* @param text 文本内容
|
|
38577
|
+
* @param fontSize 字号(像素)
|
|
38578
|
+
* @returns 估算宽度(像素)
|
|
38579
|
+
*/
|
|
38545
38580
|
var estimateWidth = (text, fontSize) => {
|
|
38546
38581
|
let w = 0;
|
|
38547
38582
|
for (const c of text) w += c.charCodeAt(0) > 127 ? fontSize : fontSize * .5;
|
|
38548
38583
|
return w;
|
|
38549
38584
|
};
|
|
38585
|
+
/**
|
|
38586
|
+
* ASS 文本转义
|
|
38587
|
+
* @param text 原始文本
|
|
38588
|
+
* @returns 转义后的文本
|
|
38589
|
+
*/
|
|
38550
38590
|
var escapeASS = (text) => text.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/\n/g, "\\N");
|
|
38591
|
+
/**
|
|
38592
|
+
* HTML 文本转义
|
|
38593
|
+
* @param text 原始文本
|
|
38594
|
+
* @returns 转义后的文本
|
|
38595
|
+
*/
|
|
38596
|
+
var escapeHTML = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
38597
|
+
/**
|
|
38598
|
+
* Windows 路径转义为正斜杠形式(供 ffmpeg 滤镜内使用,盘符冒号需转义)
|
|
38599
|
+
* @param path 原始路径
|
|
38600
|
+
* @returns 转义后的路径
|
|
38601
|
+
*/
|
|
38551
38602
|
var escapeWinPath = (path) => path.replace(/\\/g, "/").replace(/:/g, "\\:");
|
|
38603
|
+
/**
|
|
38604
|
+
* 判断是否为横屏
|
|
38605
|
+
* @param w 宽度
|
|
38606
|
+
* @param h 高度
|
|
38607
|
+
* @returns 宽大于高时为 true
|
|
38608
|
+
*/
|
|
38552
38609
|
var isLandscape = (w, h) => w > h;
|
|
38610
|
+
/**
|
|
38611
|
+
* 从 PNG 文件头读取尺寸
|
|
38612
|
+
* @param buffer PNG 文件内容
|
|
38613
|
+
* @returns 宽高(像素),非 PNG 返回空对象
|
|
38614
|
+
*/
|
|
38615
|
+
var getPngSize = (buffer) => {
|
|
38616
|
+
if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([
|
|
38617
|
+
137,
|
|
38618
|
+
80,
|
|
38619
|
+
78,
|
|
38620
|
+
71,
|
|
38621
|
+
13,
|
|
38622
|
+
10,
|
|
38623
|
+
26,
|
|
38624
|
+
10
|
|
38625
|
+
]))) return {
|
|
38626
|
+
width: buffer.readUInt32BE(16),
|
|
38627
|
+
height: buffer.readUInt32BE(20)
|
|
38628
|
+
};
|
|
38629
|
+
return {};
|
|
38630
|
+
};
|
|
38631
|
+
/**
|
|
38632
|
+
* 获取视频分辨率
|
|
38633
|
+
* @param path 视频文件路径
|
|
38634
|
+
* @returns 宽高,获取失败返回抖音默认竖屏 1080x1920
|
|
38635
|
+
*/
|
|
38553
38636
|
async function getDouyinResolution(path) {
|
|
38554
38637
|
try {
|
|
38555
38638
|
const { stdout } = await ffprobe(`-v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${path}"`);
|
|
@@ -38571,23 +38654,212 @@ async function getDouyinResolution(path) {
|
|
|
38571
38654
|
height: 1920
|
|
38572
38655
|
};
|
|
38573
38656
|
}
|
|
38574
|
-
|
|
38575
|
-
|
|
38576
|
-
|
|
38577
|
-
|
|
38578
|
-
|
|
38579
|
-
|
|
38657
|
+
/**
|
|
38658
|
+
* 获取抖音平台表情列表
|
|
38659
|
+
* 与评论处理一致:name 为文本占位符(如 [捂脸]),url 为表情图地址
|
|
38660
|
+
* 获取失败时返回空列表,含表情的弹幕将退化为纯文字烧录
|
|
38661
|
+
* @returns 表情列表(按名称长度降序,避免短名抢先匹配)
|
|
38662
|
+
*/
|
|
38663
|
+
async function fetchDouyinEmojiList() {
|
|
38580
38664
|
try {
|
|
38581
|
-
|
|
38582
|
-
|
|
38583
|
-
|
|
38584
|
-
|
|
38665
|
+
return ((await douyinFetcher.fetchEmojiList({ typeMode: "strict" })).data?.emoji_list ?? []).map((item) => ({
|
|
38666
|
+
name: item.display_name,
|
|
38667
|
+
url: item.emoji_url?.url_list?.[0] ?? ""
|
|
38668
|
+
})).filter((item) => Boolean(item.name) && Boolean(item.url)).sort((a, b) => b.name.length - a.name.length);
|
|
38669
|
+
} catch (err) {
|
|
38670
|
+
logger.warn("[DouyinDanmaku] 获取表情列表失败,表情弹幕将按纯文字处理", err);
|
|
38671
|
+
return [];
|
|
38672
|
+
}
|
|
38585
38673
|
}
|
|
38586
38674
|
/**
|
|
38587
|
-
*
|
|
38588
|
-
*
|
|
38675
|
+
* 判断弹幕文本是否包含平台表情
|
|
38676
|
+
* @param text 弹幕文本
|
|
38677
|
+
* @param emojiList 表情列表
|
|
38678
|
+
* @returns 包含任一表情占位符时为 true
|
|
38589
38679
|
*/
|
|
38590
|
-
|
|
38680
|
+
var hasEmoji = (text, emojiList) => emojiList.some((e) => text.includes(e.name));
|
|
38681
|
+
/**
|
|
38682
|
+
* 把弹幕文本拆成文字/表情片段(长名优先匹配,与评论处理一致)
|
|
38683
|
+
* @param text 弹幕文本
|
|
38684
|
+
* @param emojiList 表情列表(已按名称长度降序)
|
|
38685
|
+
* @returns 有序的片段列表
|
|
38686
|
+
*/
|
|
38687
|
+
function splitDanmakuSegments(text, emojiList) {
|
|
38688
|
+
const segments = [];
|
|
38689
|
+
let buffer = "";
|
|
38690
|
+
let index = 0;
|
|
38691
|
+
const pushBuffer = () => {
|
|
38692
|
+
if (buffer.length > 0) {
|
|
38693
|
+
segments.push({
|
|
38694
|
+
type: "text",
|
|
38695
|
+
content: buffer
|
|
38696
|
+
});
|
|
38697
|
+
buffer = "";
|
|
38698
|
+
}
|
|
38699
|
+
};
|
|
38700
|
+
while (index < text.length) {
|
|
38701
|
+
const matched = emojiList.find((e) => text.startsWith(e.name, index));
|
|
38702
|
+
if (matched) {
|
|
38703
|
+
pushBuffer();
|
|
38704
|
+
segments.push({
|
|
38705
|
+
type: "emoji",
|
|
38706
|
+
name: matched.name,
|
|
38707
|
+
url: matched.url
|
|
38708
|
+
});
|
|
38709
|
+
index += matched.name.length;
|
|
38710
|
+
continue;
|
|
38711
|
+
}
|
|
38712
|
+
buffer += text[index];
|
|
38713
|
+
index += 1;
|
|
38714
|
+
}
|
|
38715
|
+
pushBuffer();
|
|
38716
|
+
return segments;
|
|
38717
|
+
}
|
|
38718
|
+
/** 抖音官方点赞图标(同 packages/template/src/components/platforms/douyin/Icons.tsx 的 DouyinLikeIcon) */
|
|
38719
|
+
var LIKE_ICON_PATH = "M453.036 88.712C493.774 30.664 560.66 0 634.251 0 774.403 0 890.972 121.59 890.972 266.137v.054c.01-.02.019-.04.028-.06 0 4.121-.07 7.241-.122 9.537-.072 3.151-.108 4.747.122 5.247-.531 30.403-5.778 55.522-15.101 88.712-5.289 5.96-10.204 17.184-15.1 29.572-7.724 11.998-10.647 17.81-15.101 29.57a546.67 546.67 0 0 1-14.452 22.974c-37.449 56.8-87.537 113.33-137.579 163.509-78.331 79.025-158.123 144.805-192.835 173.422-9.585 7.901-15.732 12.969-17.464 14.7-12.301 12.303-24.603 12.611-36.905 12.619-.324.004-.649.006-.977.006-25.236 0-37.854-12.619-50.472-25.237-.963-.963-3.413-2.984-7.156-5.979-38.233-28.184-124.273-96.997-205.116-180C121.066 542.02 61.622 470.007 29.092 399.588 16.474 374.351.731 314.264 0 280.922c.269-.267.227-1.873.144-5.078C.083 273.498 0 270.297 0 266.137 0 121.524 116.502 0 256.721 0c73.458 0 140.405 30.664 196.315 88.712Z";
|
|
38720
|
+
/**
|
|
38721
|
+
* 生成弹幕条 HTML:文字 + 内联表情图 + 可选点赞角标(白色图标 + 点赞数),透明背景,样式对齐 ASS(白字黑描边)
|
|
38722
|
+
* @param segments 弹幕内容片段
|
|
38723
|
+
* @param fontSize CSS 字号(像素)
|
|
38724
|
+
* @param fontName 字体名
|
|
38725
|
+
* @param opacity 整体不透明度(0-1)
|
|
38726
|
+
* @param likeLabel 点赞角标文本(如 "14"、"2.4w"),为 null 时不渲染角标
|
|
38727
|
+
* @returns 完整 HTML 文档
|
|
38728
|
+
*/
|
|
38729
|
+
function buildStripHTML(segments, fontSize, fontName, opacity, likeLabel) {
|
|
38730
|
+
return `<!DOCTYPE html>
|
|
38731
|
+
<html><head><meta charset="utf-8" /><style>
|
|
38732
|
+
html,body{margin:0;padding:0;background:transparent}
|
|
38733
|
+
#container{display:inline-flex;align-items:center;white-space:nowrap;font-family:'${fontName.replace(/['\\]/g, "")}',sans-serif;font-size:${fontSize}px;line-height:1.2;color:#fff;opacity:${opacity}}
|
|
38734
|
+
#container span{paint-order:stroke fill;-webkit-text-stroke:${Math.max(1, Math.round(fontSize / 21))}px rgba(0,0,0,0.85)}
|
|
38735
|
+
#container img{height:${Math.round(fontSize * 1.1)}px;width:auto;display:inline-block}
|
|
38736
|
+
.like-icon{height:0.95em;width:0.95em;display:inline-block;margin-left:0.35em}
|
|
38737
|
+
.like-count{margin-left:0.25em}
|
|
38738
|
+
</style></head><body><div id="container">${segments.map((seg) => seg.type === "text" ? `<span>${escapeHTML(seg.content)}</span>` : `<img src="${escapeHTML(seg.url)}" alt="${escapeHTML(seg.name)}" referrerpolicy="no-referrer" crossorigin="anonymous" />`).join("") + (likeLabel !== null ? `<svg viewBox="0 0 891 816" class="like-icon" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="${LIKE_ICON_PATH}" /></svg><span class="like-count">${escapeHTML(likeLabel)}</span>` : "")}</div></body></html>`;
|
|
38739
|
+
}
|
|
38740
|
+
/**
|
|
38741
|
+
* 把含表情/点赞的弹幕渲染成透明 PNG 条(按内容缓存,相同文本复用同一张图)
|
|
38742
|
+
* 渲染失败时返回 null,调用方退化为纯文字 ASS 烧录
|
|
38743
|
+
* @param text 弹幕原文
|
|
38744
|
+
* @param likeLabel 点赞角标文本,为 null 时不渲染角标
|
|
38745
|
+
* @param emojiList 表情列表
|
|
38746
|
+
* @param style 渲染样式
|
|
38747
|
+
* @param cache 渲染缓存(内容 hash -> 渲染 Promise)
|
|
38748
|
+
* @param tempFiles 临时文件收集器(HTML/PNG 路径会追加进去,由调用方统一清理)
|
|
38749
|
+
* @returns 弹幕条信息,失败返回 null
|
|
38750
|
+
*/
|
|
38751
|
+
async function renderDanmakuStrip(text, likeLabel, emojiList, style, cache, tempFiles) {
|
|
38752
|
+
const cacheKey = `${style.fontSize}:${style.fontName}:${style.opacity}:${likeLabel ?? ""}:${text}`;
|
|
38753
|
+
let pending = cache.get(cacheKey);
|
|
38754
|
+
if (!pending) {
|
|
38755
|
+
pending = (async () => {
|
|
38756
|
+
const hash = crypto.createHash("md5").update(cacheKey).digest("hex").slice(0, 12);
|
|
38757
|
+
const htmlPath = `${Common.tempDri.video}danmaku_strip_${hash}.html`;
|
|
38758
|
+
const pngPath = `${Common.tempDri.video}danmaku_strip_${hash}.png`;
|
|
38759
|
+
tempFiles.push(htmlPath, pngPath);
|
|
38760
|
+
try {
|
|
38761
|
+
const segments = splitDanmakuSegments(text, emojiList);
|
|
38762
|
+
fs.writeFileSync(htmlPath, buildStripHTML(segments, style.fontSize, style.fontName, style.opacity, likeLabel), "utf-8");
|
|
38763
|
+
const renderResult = await render.render({
|
|
38764
|
+
name: "karin-plugin-kkk/danmaku",
|
|
38765
|
+
file: htmlPath,
|
|
38766
|
+
selector: "#container",
|
|
38767
|
+
fullPage: false,
|
|
38768
|
+
type: "png",
|
|
38769
|
+
omitBackground: true,
|
|
38770
|
+
pageGotoParams: {
|
|
38771
|
+
waitUntil: "load",
|
|
38772
|
+
timeout: 15e3
|
|
38773
|
+
}
|
|
38774
|
+
});
|
|
38775
|
+
const base64 = Array.isArray(renderResult) ? renderResult[0] : renderResult;
|
|
38776
|
+
const pngBuffer = Buffer.from(base64, "base64");
|
|
38777
|
+
const size = getPngSize(pngBuffer);
|
|
38778
|
+
if (!size.width || !size.height) throw new Error("无法读取弹幕条 PNG 尺寸");
|
|
38779
|
+
fs.writeFileSync(pngPath, pngBuffer);
|
|
38780
|
+
return {
|
|
38781
|
+
text,
|
|
38782
|
+
pngPath,
|
|
38783
|
+
width: size.width,
|
|
38784
|
+
height: size.height
|
|
38785
|
+
};
|
|
38786
|
+
} catch (err) {
|
|
38787
|
+
logger.warn(`[DouyinDanmaku] 弹幕条渲染失败,将按纯文字处理: ${text}`, err);
|
|
38788
|
+
return null;
|
|
38789
|
+
}
|
|
38790
|
+
})();
|
|
38791
|
+
cache.set(cacheKey, pending);
|
|
38792
|
+
}
|
|
38793
|
+
return pending;
|
|
38794
|
+
}
|
|
38795
|
+
/**
|
|
38796
|
+
* 预渲染需要 PNG 条的弹幕(含平台表情,或被选中展示点赞角标)
|
|
38797
|
+
* 渲染失败的弹幕退化为纯文字 ASS 烧录
|
|
38798
|
+
* @param sortedDanmaku 按出现时间升序的弹幕列表
|
|
38799
|
+
* @param emojiList 表情列表
|
|
38800
|
+
* @param liked 点赞角标选择结果
|
|
38801
|
+
* @param style 渲染样式
|
|
38802
|
+
* @returns 弹幕 ID -> PNG 条 的映射,以及过程中产生的临时文件
|
|
38803
|
+
*/
|
|
38804
|
+
async function prepareDanmakuStrips(sortedDanmaku, emojiList, liked, style) {
|
|
38805
|
+
const strips = /* @__PURE__ */ new Map();
|
|
38806
|
+
const tempFiles = [];
|
|
38807
|
+
const needsStrip = (dm) => liked.ids.has(dm.danmaku_id) || emojiList.length > 0 && hasEmoji(dm.text, emojiList);
|
|
38808
|
+
if (!sortedDanmaku.some(needsStrip)) return {
|
|
38809
|
+
strips,
|
|
38810
|
+
tempFiles
|
|
38811
|
+
};
|
|
38812
|
+
const cache = /* @__PURE__ */ new Map();
|
|
38813
|
+
for (const dm of sortedDanmaku) {
|
|
38814
|
+
if (!needsStrip(dm)) continue;
|
|
38815
|
+
const likeLabel = liked.ids.has(dm.danmaku_id) ? formatLikeCount(dm.digg_count ?? 0) : null;
|
|
38816
|
+
const strip = await renderDanmakuStrip(dm.text, likeLabel, emojiList, style, cache, tempFiles);
|
|
38817
|
+
if (strip) strips.set(dm.danmaku_id, strip);
|
|
38818
|
+
}
|
|
38819
|
+
return {
|
|
38820
|
+
strips,
|
|
38821
|
+
tempFiles
|
|
38822
|
+
};
|
|
38823
|
+
}
|
|
38824
|
+
/**
|
|
38825
|
+
* 格式化点赞数:破万保留一位小数并加 w 后缀,只舍不入(四舍不能五入)
|
|
38826
|
+
* 如 14 -> "14",24800 -> "2.4w",1001900 -> "100.1w"
|
|
38827
|
+
* @param count 点赞数
|
|
38828
|
+
* @returns 格式化后的文本
|
|
38829
|
+
*/
|
|
38830
|
+
var formatLikeCount = (count) => {
|
|
38831
|
+
if (count < 1e4) return String(count);
|
|
38832
|
+
return `${(Math.floor(count / 1e3) / 10).toFixed(1)}w`;
|
|
38833
|
+
};
|
|
38834
|
+
/**
|
|
38835
|
+
* 从有点赞的弹幕中选出展示点赞角标的子集
|
|
38836
|
+
* 全部展示会非常拥挤,目标展示数随弹幕总量按 1.5√N 缩放(100 条弹幕约展示 15 条),
|
|
38837
|
+
* 弹幕越多比例越低(不是固定比例);再按点赞量从高到低截取
|
|
38838
|
+
* @param sortedDanmaku 按出现时间升序的弹幕列表
|
|
38839
|
+
* @returns 选中的弹幕 ID 集合及统计
|
|
38840
|
+
*/
|
|
38841
|
+
function selectLikedDanmaku(sortedDanmaku) {
|
|
38842
|
+
const candidates = sortedDanmaku.filter((dm) => (dm.digg_count ?? 0) > 0);
|
|
38843
|
+
const target = Math.min(50, Math.max(5, Math.round(Math.sqrt(sortedDanmaku.length) * 1.5)));
|
|
38844
|
+
if (candidates.length === 0) return {
|
|
38845
|
+
ids: /* @__PURE__ */ new Set(),
|
|
38846
|
+
candidateCount: 0,
|
|
38847
|
+
target
|
|
38848
|
+
};
|
|
38849
|
+
if (candidates.length <= target) return {
|
|
38850
|
+
ids: new Set(candidates.map((dm) => dm.danmaku_id)),
|
|
38851
|
+
candidateCount: candidates.length,
|
|
38852
|
+
target
|
|
38853
|
+
};
|
|
38854
|
+
const byDiggDesc = [...candidates].sort((a, b) => (b.digg_count ?? 0) - (a.digg_count ?? 0));
|
|
38855
|
+
const cutoff = byDiggDesc[target - 1].digg_count ?? 0;
|
|
38856
|
+
return {
|
|
38857
|
+
ids: new Set(byDiggDesc.filter((dm) => (dm.digg_count ?? 0) >= cutoff).map((dm) => dm.danmaku_id)),
|
|
38858
|
+
candidateCount: candidates.length,
|
|
38859
|
+
target
|
|
38860
|
+
};
|
|
38861
|
+
}
|
|
38862
|
+
/** 字号配置映射(1080 高度基准) */
|
|
38591
38863
|
var FONT_SIZE_MAP = {
|
|
38592
38864
|
small: {
|
|
38593
38865
|
base: 25,
|
|
@@ -38602,8 +38874,15 @@ var FONT_SIZE_MAP = {
|
|
|
38602
38874
|
trackH: 46
|
|
38603
38875
|
}
|
|
38604
38876
|
};
|
|
38605
|
-
|
|
38606
|
-
|
|
38877
|
+
/**
|
|
38878
|
+
* 计算弹幕布局参数
|
|
38879
|
+
* @param height 画布高度(像素),所有几何以 1080 高为基准等比缩放
|
|
38880
|
+
* @param danmakuArea 弹幕显示区域比例(0.25/0.5/0.75/1)
|
|
38881
|
+
* @param danmakuFontSize 弹幕字号档位
|
|
38882
|
+
* @param danmakuOpacity 弹幕不透明度(0-100)
|
|
38883
|
+
* @returns 布局参数
|
|
38884
|
+
*/
|
|
38885
|
+
function computeDanmakuLayout(height, danmakuArea, danmakuFontSize, danmakuOpacity) {
|
|
38607
38886
|
const fontScale = height / 1080;
|
|
38608
38887
|
const sizeConfig = FONT_SIZE_MAP[danmakuFontSize];
|
|
38609
38888
|
const fontSize = Math.round(sizeConfig.base * fontScale);
|
|
@@ -38612,8 +38891,110 @@ function generateDouyinASS(danmakuList, width, height, options = {}) {
|
|
|
38612
38891
|
const areaHeight = Math.floor(height * danmakuArea) - topMargin;
|
|
38613
38892
|
const trackCount = Math.max(1, Math.floor((areaHeight - fontSize) / trackH));
|
|
38614
38893
|
const minGap = Math.round(15 * fontScale);
|
|
38615
|
-
|
|
38616
|
-
|
|
38894
|
+
return {
|
|
38895
|
+
fontSize,
|
|
38896
|
+
stripFontSize: Math.max(1, Math.round(fontSize * .75)),
|
|
38897
|
+
trackH,
|
|
38898
|
+
topMargin,
|
|
38899
|
+
trackCount,
|
|
38900
|
+
minGap,
|
|
38901
|
+
alpha: Math.round((100 - Math.max(0, Math.min(100, danmakuOpacity))) * 2.55).toString(16).padStart(2, "0").toUpperCase()
|
|
38902
|
+
};
|
|
38903
|
+
}
|
|
38904
|
+
/**
|
|
38905
|
+
* 计算同轨道上后一条弹幕与前一条弹幕的间距是否安全
|
|
38906
|
+
* 间距指 next 头部进入屏幕右缘时与 prev 尾部的距离;若 next 更快,还会校验 prev 消失前不被追尾
|
|
38907
|
+
* @param prev 先入轨(时间上更早)的弹幕
|
|
38908
|
+
* @param next 后入轨(时间上更晚)的弹幕
|
|
38909
|
+
* @param canvasWidth 画布宽度(像素)
|
|
38910
|
+
* @param minGap 最小间距(像素)
|
|
38911
|
+
* @returns 间距(像素),>= 0 表示不重叠
|
|
38912
|
+
*/
|
|
38913
|
+
var calcTrackDistance = (prev, next, canvasWidth, minGap) => {
|
|
38914
|
+
const prevSpeed = (canvasWidth + prev.textWidth) / prev.duration;
|
|
38915
|
+
const nextSpeed = (canvasWidth + next.textWidth) / next.duration;
|
|
38916
|
+
let dist = canvasWidth - (canvasWidth - prevSpeed * (next.startTime - prev.startTime) + prev.textWidth) - minGap;
|
|
38917
|
+
if (nextSpeed > prevSpeed) {
|
|
38918
|
+
const prevRightXAtEnd = canvasWidth - prevSpeed * (next.startTime + next.duration - prev.startTime) + prev.textWidth;
|
|
38919
|
+
dist = Math.min(dist, -next.textWidth - prevRightXAtEnd - minGap);
|
|
38920
|
+
}
|
|
38921
|
+
return dist;
|
|
38922
|
+
};
|
|
38923
|
+
/**
|
|
38924
|
+
* 单条弹幕入轨(区间表模型):每条轨道维护按时间排序的已承诺弹幕列表,
|
|
38925
|
+
* 新弹幕只需与直接前驱/后继做间距检查(所有弹幕滚动时长相同,起止时间同序,最近邻居必是最强约束)
|
|
38926
|
+
* @param lanes 所有轨道的区间表
|
|
38927
|
+
* @param entry 待入轨弹幕
|
|
38928
|
+
* @param canvasWidth 画布宽度(像素)
|
|
38929
|
+
* @param minGap 同轨道相邻弹幕最小间距(像素)
|
|
38930
|
+
* @param preferFreeLane true 时优先随机选择空闲轨道(用于点赞角标,使其散布到任意行);
|
|
38931
|
+
* false 时优先紧凑复用(普通弹幕,保持"避免阶梯"的观感)
|
|
38932
|
+
* @returns 轨道下标,放不下返回 -1
|
|
38933
|
+
*/
|
|
38934
|
+
var allocateTrack = (lanes, entry, canvasWidth, minGap, preferFreeLane) => {
|
|
38935
|
+
const { startTime, duration } = entry;
|
|
38936
|
+
let bestIdx = -1;
|
|
38937
|
+
let bestDist = Infinity;
|
|
38938
|
+
const freeLanes = [];
|
|
38939
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
38940
|
+
const lane = lanes[i];
|
|
38941
|
+
let predIdx = -1;
|
|
38942
|
+
for (let j = lane.length - 1; j >= 0; j--) if (lane[j].startTime <= startTime) {
|
|
38943
|
+
predIdx = j;
|
|
38944
|
+
break;
|
|
38945
|
+
}
|
|
38946
|
+
const pred = predIdx >= 0 ? lane[predIdx] : null;
|
|
38947
|
+
const succ = predIdx + 1 < lane.length ? lane[predIdx + 1] : null;
|
|
38948
|
+
const predActive = pred !== null && pred.startTime + pred.duration > startTime;
|
|
38949
|
+
if (predActive && calcTrackDistance(pred, entry, canvasWidth, minGap) < 0) continue;
|
|
38950
|
+
if (succ !== null && startTime + duration > succ.startTime && calcTrackDistance(entry, succ, canvasWidth, minGap) < 0) continue;
|
|
38951
|
+
if (!predActive) {
|
|
38952
|
+
freeLanes.push(i);
|
|
38953
|
+
continue;
|
|
38954
|
+
}
|
|
38955
|
+
const d = calcTrackDistance(pred, entry, canvasWidth, minGap);
|
|
38956
|
+
if (d < bestDist) {
|
|
38957
|
+
bestDist = d;
|
|
38958
|
+
bestIdx = i;
|
|
38959
|
+
}
|
|
38960
|
+
}
|
|
38961
|
+
if (preferFreeLane && freeLanes.length > 0) return freeLanes[Math.floor(Math.random() * freeLanes.length)];
|
|
38962
|
+
if (bestIdx !== -1) return bestIdx;
|
|
38963
|
+
if (freeLanes.length > 0) return freeLanes[0];
|
|
38964
|
+
return -1;
|
|
38965
|
+
};
|
|
38966
|
+
/**
|
|
38967
|
+
* 把弹幕按时间序插入轨道区间表
|
|
38968
|
+
* @param lane 目标轨道的区间表
|
|
38969
|
+
* @param entry 待插入弹幕
|
|
38970
|
+
*/
|
|
38971
|
+
var insertIntoLane = (lane, entry) => {
|
|
38972
|
+
let j = lane.length - 1;
|
|
38973
|
+
while (j >= 0 && lane[j].startTime > entry.startTime) j--;
|
|
38974
|
+
lane.splice(j + 1, 0, entry);
|
|
38975
|
+
};
|
|
38976
|
+
/**
|
|
38977
|
+
* 弹幕滚动宽度基准:ASS 与 overlay 必须使用同一度量,否则两类弹幕速度不一致、会错开
|
|
38978
|
+
* 纯文字用估算宽度;PNG 条取估算宽度与真实宽度的较大值(角标图标可能让真实宽度超过估算,
|
|
38979
|
+
* 取大者保证条尾在 endTime 前完全滚出屏幕,不会突然消失)
|
|
38980
|
+
* @param text 弹幕文本
|
|
38981
|
+
* @param strip 预渲染弹幕条(纯文字弹幕为 undefined)
|
|
38982
|
+
* @param fontSize ASS 字号(像素)
|
|
38983
|
+
* @returns 滚动宽度基准(像素)
|
|
38984
|
+
*/
|
|
38985
|
+
var resolveMoveWidth = (text, strip, fontSize) => {
|
|
38986
|
+
return strip ? Math.max(estimateWidth(text, fontSize), strip.width) : estimateWidth(text, fontSize);
|
|
38987
|
+
};
|
|
38988
|
+
/**
|
|
38989
|
+
* 生成 ASS 文件头(Script Info + 样式表 + 事件格式声明)
|
|
38990
|
+
* @param width 画布宽度(ASS PlayResX)
|
|
38991
|
+
* @param height 画布高度(ASS PlayResY)
|
|
38992
|
+
* @param fontName 字体名
|
|
38993
|
+
* @param layout 布局参数
|
|
38994
|
+
* @returns ASS 头部内容
|
|
38995
|
+
*/
|
|
38996
|
+
function buildAssHeader(width, height, fontName, layout) {
|
|
38997
|
+
return `[Script Info]
|
|
38617
38998
|
Title: Douyin Danmaku
|
|
38618
38999
|
ScriptType: v4.00+
|
|
38619
39000
|
PlayResX: ${width}
|
|
@@ -38622,64 +39003,115 @@ Timer: 100.0000
|
|
|
38622
39003
|
|
|
38623
39004
|
[V4+ Styles]
|
|
38624
39005
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
38625
|
-
Style: Scroll,${fontName},${fontSize},&H${alpha}FFFFFF,&H${alpha}FFFFFF,&H${alpha}000000,&H${alpha}000000,0,0,0,0,100,100,0,0,1,0.8,0,2,0,0,0,1
|
|
39006
|
+
Style: Scroll,${fontName},${layout.fontSize},&H${layout.alpha}FFFFFF,&H${layout.alpha}FFFFFF,&H${layout.alpha}000000,&H${layout.alpha}000000,0,0,0,0,100,100,0,0,1,0.8,0,2,0,0,0,1
|
|
38626
39007
|
|
|
38627
39008
|
[Events]
|
|
38628
39009
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
38629
39010
|
`;
|
|
38630
|
-
|
|
38631
|
-
|
|
38632
|
-
|
|
38633
|
-
|
|
38634
|
-
|
|
38635
|
-
|
|
38636
|
-
|
|
38637
|
-
|
|
39011
|
+
}
|
|
39012
|
+
/**
|
|
39013
|
+
* 生成一条纯文字弹幕的 ASS Dialogue 行(滚动弹幕,\move 从右缘外滚到左缘外)
|
|
39014
|
+
* @param text 弹幕文本
|
|
39015
|
+
* @param entry 轨道条目(时间/宽度基准)
|
|
39016
|
+
* @param trackIdx 轨道下标
|
|
39017
|
+
* @param canvasWidth 画布宽度(像素)
|
|
39018
|
+
* @param layout 布局参数
|
|
39019
|
+
* @returns Dialogue 行(含换行符)
|
|
39020
|
+
*/
|
|
39021
|
+
function buildScrollDialogue(text, entry, trackIdx, canvasWidth, layout) {
|
|
39022
|
+
const y = layout.topMargin + trackIdx * layout.trackH + layout.fontSize;
|
|
39023
|
+
const content = escapeASS(text);
|
|
39024
|
+
return `Dialogue: 0,${toASSTime(entry.startTime)},${toASSTime(entry.startTime + entry.duration)},Scroll,,0,0,0,,{\\an7}{\\move(${canvasWidth},${y},${-entry.textWidth},${y})}${content}\n`;
|
|
39025
|
+
}
|
|
39026
|
+
/**
|
|
39027
|
+
* 生成一条图片弹幕的 overlay 描述
|
|
39028
|
+
* @param strip 预渲染弹幕条
|
|
39029
|
+
* @param entry 轨道条目(时间/宽度基准)
|
|
39030
|
+
* @param trackIdx 轨道下标
|
|
39031
|
+
* @param layout 布局参数
|
|
39032
|
+
* @returns overlay 描述
|
|
39033
|
+
*/
|
|
39034
|
+
function buildOverlay(strip, entry, trackIdx, layout) {
|
|
39035
|
+
const textTop = layout.topMargin + trackIdx * layout.trackH + layout.fontSize;
|
|
39036
|
+
const y = Math.max(0, Math.round(textTop + (layout.fontSize - strip.height) / 2));
|
|
39037
|
+
return {
|
|
39038
|
+
pngPath: strip.pngPath,
|
|
39039
|
+
startTime: entry.startTime,
|
|
39040
|
+
endTime: entry.startTime + entry.duration,
|
|
39041
|
+
y,
|
|
39042
|
+
moveW: entry.textWidth
|
|
39043
|
+
};
|
|
39044
|
+
}
|
|
39045
|
+
/**
|
|
39046
|
+
* 生成弹幕 ASS 与图片弹幕(表情/点赞)overlay 列表
|
|
39047
|
+
* 纯文字弹幕输出 ASS \move 行;含表情或被选中展示点赞角标的弹幕预渲染为 PNG 条,统一参与轨道分配后转为 overlay
|
|
39048
|
+
* @param danmakuList 弹幕列表
|
|
39049
|
+
* @param width 画布宽度(像素)
|
|
39050
|
+
* @param height 画布高度(像素)
|
|
39051
|
+
* @param options 烧录配置
|
|
39052
|
+
* @returns ASS 内容、overlay 列表、临时文件与分类统计
|
|
39053
|
+
*/
|
|
39054
|
+
async function generateDouyinASS(danmakuList, width, height, options = {}) {
|
|
39055
|
+
const { scrollTime = 8, danmakuOpacity = 70, fontName = "Microsoft YaHei", danmakuArea = .5, danmakuFontSize = "medium" } = options;
|
|
39056
|
+
const layout = computeDanmakuLayout(height, danmakuArea, danmakuFontSize, danmakuOpacity);
|
|
39057
|
+
const sorted = danmakuList.filter((dm) => dm.text && dm.text.trim()).sort((a, b) => a.offset_time - b.offset_time);
|
|
39058
|
+
const emojiList = await fetchDouyinEmojiList();
|
|
39059
|
+
const liked = selectLikedDanmaku(sorted);
|
|
39060
|
+
const { strips, tempFiles } = await prepareDanmakuStrips(sorted, emojiList, liked, {
|
|
39061
|
+
fontSize: layout.stripFontSize,
|
|
39062
|
+
fontName,
|
|
39063
|
+
opacity: Math.max(0, Math.min(100, danmakuOpacity)) / 100
|
|
39064
|
+
});
|
|
39065
|
+
const assLines = [];
|
|
39066
|
+
const overlays = [];
|
|
39067
|
+
let likedOverlayCount = 0;
|
|
39068
|
+
let emojiOverlayCount = 0;
|
|
39069
|
+
/**
|
|
39070
|
+
* 弹幕入轨并上屏:写入 ASS(纯文字)或 overlay 列表(PNG 条)
|
|
39071
|
+
* @param dm 弹幕元素
|
|
39072
|
+
* @param preferFreeLane 是否优先随机选择空闲轨道(点赞角标弹幕为 true)
|
|
39073
|
+
*/
|
|
39074
|
+
const placeDanmaku = (dm, preferFreeLane) => {
|
|
39075
|
+
const strip = strips.get(dm.danmaku_id);
|
|
39076
|
+
const entry = {
|
|
39077
|
+
startTime: dm.offset_time,
|
|
39078
|
+
duration: scrollTime * 1e3,
|
|
39079
|
+
textWidth: resolveMoveWidth(dm.text, strip, layout.fontSize)
|
|
39080
|
+
};
|
|
39081
|
+
const trackIdx = allocateTrack(lanes, entry, width, layout.minGap, preferFreeLane);
|
|
39082
|
+
if (trackIdx === -1) return;
|
|
39083
|
+
insertIntoLane(lanes[trackIdx], entry);
|
|
39084
|
+
if (strip) {
|
|
39085
|
+
overlays.push(buildOverlay(strip, entry, trackIdx, layout));
|
|
39086
|
+
if (liked.ids.has(dm.danmaku_id)) likedOverlayCount++;
|
|
39087
|
+
else emojiOverlayCount++;
|
|
39088
|
+
return;
|
|
38638
39089
|
}
|
|
38639
|
-
|
|
39090
|
+
assLines.push(buildScrollDialogue(dm.text, entry, trackIdx, width, layout));
|
|
38640
39091
|
};
|
|
38641
|
-
const
|
|
38642
|
-
for (const dm of sorted)
|
|
38643
|
-
|
|
38644
|
-
|
|
38645
|
-
|
|
38646
|
-
|
|
38647
|
-
|
|
38648
|
-
|
|
38649
|
-
|
|
38650
|
-
|
|
38651
|
-
|
|
38652
|
-
|
|
38653
|
-
let bestDist = -Infinity;
|
|
38654
|
-
for (let i = 0; i < scrollTracks.length; i++) {
|
|
38655
|
-
const t = scrollTracks[i];
|
|
38656
|
-
if (!t) {
|
|
38657
|
-
if (bestIdx === -1) bestIdx = i;
|
|
38658
|
-
continue;
|
|
38659
|
-
}
|
|
38660
|
-
const d = calcDistance(t, startTime, duration, textWidth);
|
|
38661
|
-
if (d >= 0) {
|
|
38662
|
-
if (bestDist < 0 || d < bestDist) {
|
|
38663
|
-
bestDist = d;
|
|
38664
|
-
bestIdx = i;
|
|
38665
|
-
}
|
|
38666
|
-
}
|
|
39092
|
+
const lanes = Array.from({ length: layout.trackCount }, () => []);
|
|
39093
|
+
for (const dm of sorted) if (liked.ids.has(dm.danmaku_id)) placeDanmaku(dm, true);
|
|
39094
|
+
for (const dm of sorted) if (!liked.ids.has(dm.danmaku_id)) placeDanmaku(dm, false);
|
|
39095
|
+
return {
|
|
39096
|
+
ass: buildAssHeader(width, height, fontName, layout) + assLines.join(""),
|
|
39097
|
+
overlays,
|
|
39098
|
+
tempFiles,
|
|
39099
|
+
stats: {
|
|
39100
|
+
likedOverlays: likedOverlayCount,
|
|
39101
|
+
emojiOverlays: emojiOverlayCount,
|
|
39102
|
+
likedCandidates: liked.candidateCount,
|
|
39103
|
+
likedTarget: liked.target
|
|
38667
39104
|
}
|
|
38668
|
-
|
|
38669
|
-
scrollTracks[bestIdx] = {
|
|
38670
|
-
startTime,
|
|
38671
|
-
duration,
|
|
38672
|
-
textWidth
|
|
38673
|
-
};
|
|
38674
|
-
const y = topMargin + bestIdx * trackH + fontSize;
|
|
38675
|
-
ass += `Dialogue: 0,${toASSTime(startTime)},${toASSTime(endTime)},Scroll,,0,0,0,,{\\an7}{\\move(${width},${y},${-textWidth},${y})}${content}\n`;
|
|
38676
|
-
}
|
|
38677
|
-
return ass;
|
|
39105
|
+
};
|
|
38678
39106
|
}
|
|
38679
39107
|
var MAX_OUTPUT_WIDTH = 2160;
|
|
38680
39108
|
/**
|
|
38681
39109
|
* 计算画布尺寸(竖屏适配)
|
|
38682
39110
|
* 抖音视频大多是竖屏,但也可能有横屏视频需要转竖屏
|
|
39111
|
+
* @param origW 源视频宽度(像素)
|
|
39112
|
+
* @param origH 源视频高度(像素)
|
|
39113
|
+
* @param verticalMode 横屏转竖屏模式
|
|
39114
|
+
* @returns 画布信息
|
|
38683
39115
|
*/
|
|
38684
39116
|
function calcCanvas(origW, origH, verticalMode) {
|
|
38685
39117
|
if (verticalMode === "off") return {
|
|
@@ -38720,7 +39152,7 @@ function calcCanvas(origW, origH, verticalMode) {
|
|
|
38720
39152
|
}
|
|
38721
39153
|
if (isWide && ratio >= 1.7) {
|
|
38722
39154
|
const newW = Math.min(origH, MAX_OUTPUT_WIDTH);
|
|
38723
|
-
const scaleRatio = newW /
|
|
39155
|
+
const scaleRatio = newW / origW;
|
|
38724
39156
|
const newH = Math.round(origW * scaleRatio);
|
|
38725
39157
|
const scaledH = Math.round(newW / ratio);
|
|
38726
39158
|
return {
|
|
@@ -38739,37 +39171,126 @@ function calcCanvas(origW, origH, verticalMode) {
|
|
|
38739
39171
|
};
|
|
38740
39172
|
}
|
|
38741
39173
|
/**
|
|
38742
|
-
* 构建 FFmpeg
|
|
39174
|
+
* 构建 FFmpeg 滤镜(无图片弹幕时的 -vf 简单滤镜链)
|
|
39175
|
+
* fps=60 放最前:视频帧复制(内容不变),libass 在每个新帧重新求值 \move,弹幕步进更小更顺滑
|
|
39176
|
+
* @param canvas 画布信息
|
|
39177
|
+
* @param assPath ASS 字幕文件路径
|
|
39178
|
+
* @returns -vf 滤镜链字符串
|
|
38743
39179
|
*/
|
|
38744
39180
|
function buildFilter(canvas, assPath) {
|
|
38745
39181
|
const escaped = escapeWinPath(assPath);
|
|
38746
39182
|
if (canvas.isVertical) {
|
|
38747
|
-
if (canvas.scale && canvas.scale !== 1 && canvas.scale < 1) return `scale=${canvas.width}:-1,pad=${canvas.width}:${canvas.height}:0:${canvas.offsetY}:black,subtitles='${escaped}'`;
|
|
38748
|
-
return `pad=${canvas.width}:${canvas.height}:0:${canvas.offsetY}:black,subtitles='${escaped}'`;
|
|
39183
|
+
if (canvas.scale && canvas.scale !== 1 && canvas.scale < 1) return `fps=${OUTPUT_FPS},scale=${canvas.width}:-1,pad=${canvas.width}:${canvas.height}:0:${canvas.offsetY}:black,subtitles='${escaped}'`;
|
|
39184
|
+
return `fps=${OUTPUT_FPS},pad=${canvas.width}:${canvas.height}:0:${canvas.offsetY}:black,subtitles='${escaped}'`;
|
|
38749
39185
|
}
|
|
38750
|
-
return `subtitles='${escaped}'`;
|
|
39186
|
+
return `fps=${OUTPUT_FPS},subtitles='${escaped}'`;
|
|
39187
|
+
}
|
|
39188
|
+
/**
|
|
39189
|
+
* 构建 FFmpeg filter_complex(含图片弹幕时的叠加链)
|
|
39190
|
+
* 每条图片弹幕对应一个 PNG 输入(内容相同的弹幕条复用同一输入,见 burnDouyinDanmaku),
|
|
39191
|
+
* 用 overlay 按 \move 同语义滚动:x(t) = width - (t - start) * speed,speed = (width + moveW) / scrollTime
|
|
39192
|
+
* @param canvas 画布信息
|
|
39193
|
+
* @param assPath ASS 字幕文件路径
|
|
39194
|
+
* @param overlays 图片弹幕 overlay 列表
|
|
39195
|
+
* @param inputIndices 每条 overlay 对应的 ffmpeg 输入序号(与去重后的 -i 顺序一致)
|
|
39196
|
+
* @param scrollTime 滚动时间(秒)
|
|
39197
|
+
* @returns filter_complex 字符串
|
|
39198
|
+
*/
|
|
39199
|
+
function buildFilterComplex(canvas, assPath, overlays, inputIndices, scrollTime) {
|
|
39200
|
+
const escaped = escapeWinPath(assPath);
|
|
39201
|
+
let base = `[0:v]fps=${OUTPUT_FPS}`;
|
|
39202
|
+
if (canvas.isVertical) {
|
|
39203
|
+
if (canvas.scale && canvas.scale !== 1 && canvas.scale < 1) base += `,scale=${canvas.width}:-1`;
|
|
39204
|
+
base += `,pad=${canvas.width}:${canvas.height}:0:${canvas.offsetY}:black`;
|
|
39205
|
+
}
|
|
39206
|
+
base += `,subtitles='${escaped}'[base]`;
|
|
39207
|
+
const parts = [base];
|
|
39208
|
+
let prev = "base";
|
|
39209
|
+
overlays.forEach((o, i) => {
|
|
39210
|
+
const speed = ((canvas.width + o.moveW) / scrollTime).toFixed(3);
|
|
39211
|
+
const s = (o.startTime / 1e3).toFixed(3);
|
|
39212
|
+
const e = (o.endTime / 1e3).toFixed(3);
|
|
39213
|
+
const label = i === overlays.length - 1 ? "vout" : `v${i}`;
|
|
39214
|
+
parts.push(`[${prev}][${inputIndices[i]}:v]overlay=x='${canvas.width}-(t-${s})*${speed}':y=${o.y}:enable='between(t,${s},${e})'[${label}]`);
|
|
39215
|
+
prev = label;
|
|
39216
|
+
});
|
|
39217
|
+
return parts.join(";");
|
|
39218
|
+
}
|
|
39219
|
+
/**
|
|
39220
|
+
* 生成相对路径转换器:能相对化的转相对路径(正斜杠),否则保留绝对路径
|
|
39221
|
+
* 用于把 ffmpeg 命令行长度控制在 Windows cmd 的 8191 字符上限内
|
|
39222
|
+
* @param workDir 工作目录(ffmpeg 的 cwd)
|
|
39223
|
+
* @returns 路径转换函数
|
|
39224
|
+
*/
|
|
39225
|
+
var createRelativizer = (workDir) => {
|
|
39226
|
+
return (p) => {
|
|
39227
|
+
const rel = path.relative(workDir, p).replace(/\\/g, "/");
|
|
39228
|
+
return rel && !rel.startsWith("..") ? rel : p.replace(/\\/g, "/");
|
|
39229
|
+
};
|
|
39230
|
+
};
|
|
39231
|
+
/**
|
|
39232
|
+
* 规划图片弹幕的 ffmpeg 输入:内容相同的弹幕条复用同一个输入,减少命令行长度
|
|
39233
|
+
* @param videoPath 视频文件路径(相对化后)
|
|
39234
|
+
* @param overlays 图片弹幕 overlay 列表
|
|
39235
|
+
* @param toRel 相对路径转换器
|
|
39236
|
+
* @returns 输入参数列表与每条 overlay 的输入序号
|
|
39237
|
+
*/
|
|
39238
|
+
function planOverlayInputs(videoPath, overlays, toRel) {
|
|
39239
|
+
const inputIndexByPath = /* @__PURE__ */ new Map();
|
|
39240
|
+
const inputArgs = [`-i "${toRel(videoPath)}"`];
|
|
39241
|
+
return {
|
|
39242
|
+
inputArgs,
|
|
39243
|
+
inputIndices: overlays.map((o) => {
|
|
39244
|
+
let idx = inputIndexByPath.get(o.pngPath);
|
|
39245
|
+
if (idx === void 0) {
|
|
39246
|
+
idx = inputIndexByPath.size + 1;
|
|
39247
|
+
inputIndexByPath.set(o.pngPath, idx);
|
|
39248
|
+
inputArgs.push(`-i "${toRel(o.pngPath)}"`);
|
|
39249
|
+
}
|
|
39250
|
+
return idx;
|
|
39251
|
+
})
|
|
39252
|
+
};
|
|
38751
39253
|
}
|
|
38752
39254
|
/**
|
|
38753
39255
|
* 烧录抖音弹幕到视频
|
|
39256
|
+
* @param videoPath 源视频文件路径
|
|
39257
|
+
* @param danmakuList 弹幕列表
|
|
39258
|
+
* @param outputPath 输出视频文件路径
|
|
39259
|
+
* @param options 烧录配置
|
|
39260
|
+
* @returns 是否烧录成功
|
|
38754
39261
|
*/
|
|
38755
39262
|
async function burnDouyinDanmaku(videoPath, danmakuList, outputPath, options = {}) {
|
|
38756
|
-
const { removeSource = false, verticalMode = "off", videoCodec = "h265" } = options;
|
|
39263
|
+
const { removeSource = false, verticalMode = "off", videoCodec = "h265", scrollTime = 8 } = options;
|
|
38757
39264
|
if (!fs.existsSync(videoPath)) {
|
|
38758
39265
|
logger.error(`[DouyinDanmaku] 视频文件不存在: ${videoPath}`);
|
|
38759
39266
|
return false;
|
|
38760
39267
|
}
|
|
38761
39268
|
const resolution = await getDouyinResolution(videoPath);
|
|
38762
|
-
const frameRate = await getDouyinFrameRate(videoPath);
|
|
38763
39269
|
const sourceBitrate = await getVideoBitrate(videoPath);
|
|
38764
39270
|
const canvas = calcCanvas(resolution.width, resolution.height, verticalMode);
|
|
38765
39271
|
if (canvas.isVertical) logger.debug(`[DouyinDanmaku] 竖屏模式: ${resolution.width}x${resolution.height} -> ${canvas.width}x${canvas.height}`);
|
|
38766
|
-
logger.debug(`[DouyinDanmaku] 分辨率: ${canvas.width}x${canvas.height},
|
|
38767
|
-
const
|
|
39272
|
+
logger.debug(`[DouyinDanmaku] 分辨率: ${canvas.width}x${canvas.height}, 码率: ${sourceBitrate}kbps`);
|
|
39273
|
+
const { ass, overlays, tempFiles, stats } = await generateDouyinASS(danmakuList, canvas.width, canvas.height, options);
|
|
38768
39274
|
const assPath = videoPath.replace(/\.[^.]+$/, "_danmaku.ass");
|
|
38769
|
-
fs.writeFileSync(assPath,
|
|
38770
|
-
logger.debug(`[DouyinDanmaku] 弹幕字幕已生成: ${assPath},共 ${danmakuList.length}
|
|
38771
|
-
const
|
|
39275
|
+
fs.writeFileSync(assPath, ass, "utf-8");
|
|
39276
|
+
logger.debug(`[DouyinDanmaku] 弹幕字幕已生成: ${assPath},共 ${danmakuList.length} 条,图片弹幕 ${overlays.length} 条(点赞角标 ${stats.likedOverlays} 条 [候选 ${stats.likedCandidates}/目标 ${stats.likedTarget}],表情 ${stats.emojiOverlays} 条)`);
|
|
39277
|
+
const encoderParams = getEncoderParams(await detectEncoder(videoCodec), sourceBitrate);
|
|
39278
|
+
let command;
|
|
39279
|
+
let execCwd;
|
|
39280
|
+
if (overlays.length === 0) command = `-y -i "${videoPath}" -vf "${buildFilter(canvas, assPath)}" -r ${OUTPUT_FPS} ${encoderParams} -c:a copy "${outputPath}"`;
|
|
39281
|
+
else {
|
|
39282
|
+
const workDir = path.dirname(videoPath);
|
|
39283
|
+
execCwd = workDir;
|
|
39284
|
+
const toRel = createRelativizer(workDir);
|
|
39285
|
+
const { inputArgs, inputIndices } = planOverlayInputs(videoPath, overlays, toRel);
|
|
39286
|
+
const graphPath = videoPath.replace(/\.[^.]+$/, "_danmaku.graph.txt");
|
|
39287
|
+
fs.writeFileSync(graphPath, buildFilterComplex(canvas, toRel(assPath), overlays, inputIndices, scrollTime), "utf-8");
|
|
39288
|
+
tempFiles.push(graphPath);
|
|
39289
|
+
command = `-y ${inputArgs.join(" ")} -filter_complex_script "${toRel(graphPath)}" -map "[vout]" -map "0:a?" -r ${OUTPUT_FPS} ${encoderParams} -c:a copy "${toRel(outputPath)}"`;
|
|
39290
|
+
}
|
|
39291
|
+
const result = await ffmpeg(command, execCwd ? { cwd: execCwd } : void 0);
|
|
38772
39292
|
Common.removeFile(assPath, true);
|
|
39293
|
+
for (const file of tempFiles) Common.removeFile(file, true);
|
|
38773
39294
|
if (result.status) {
|
|
38774
39295
|
logger.mark(`[DouyinDanmaku] 弹幕烧录成功: ${outputPath}`);
|
|
38775
39296
|
if (removeSource) Common.removeFile(videoPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karin-plugin-kkk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.42.0",
|
|
4
4
|
"description": "Karin 的「抖音」「B 站」视频解析/动态推送插件",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"karin",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"web": "./lib/web.config.js",
|
|
90
90
|
"ts-web": "./src/web.config.ts"
|
|
91
91
|
},
|
|
92
|
-
"timestamp": "2026-
|
|
92
|
+
"timestamp": "2026-08-02T10:09:55.540Z"
|
|
93
93
|
}
|