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 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
 
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "2.41.2",
3
- "buildTime": "2026-08-02T03:48:44.956Z",
4
- "buildTimestamp": 1785642524956,
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": "f55f3ee34be37df21d7543caf195ef311a19c5d4",
9
- "shortCommitHash": "f55f3ee3"
8
+ "commitHash": "f3d273bcaa5c34e490d16e281818459725d79e32",
9
+ "shortCommitHash": "f3d273bc"
10
10
  }
@@ -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
- /** 获取视频平均码率(kbps) */
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 adjustedBitrate = Math.round(targetBitrate * 1.4);
38504
- const bitrateK = `${adjustedBitrate}k`;
38505
- const maxrate = `${Math.round(adjustedBitrate * 2.5)}k`;
38506
- const bufsize = `${Math.round(adjustedBitrate * 4)}k`;
38507
- if (encoder === "h264_nvenc") return `-c:v h264_nvenc -preset p4 -rc vbr -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
38508
- if (encoder === "h264_qsv") return `-c:v h264_qsv -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize}`;
38509
- if (encoder === "h264_amf") return `-c:v h264_amf -quality balanced -rc vbr_peak -b:v ${bitrateK} -maxrate ${maxrate}`;
38510
- if (encoder === "libx264") return `-c:v libx264 -preset medium -b:v ${bitrateK} -maxrate ${maxrate} -bufsize ${bufsize} -threads ${threads}`;
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
- if (encoder === "h264_nvenc") return "-c:v h264_nvenc -preset p4 -rc vbr -cq 23";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- async function getDouyinFrameRate(path) {
38575
- try {
38576
- const { stdout } = await ffprobe(`-v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 "${path}"`);
38577
- const [num, den] = stdout.trim().split("/").map(Number);
38578
- if (den > 0) return num / den;
38579
- } catch {}
38657
+ /**
38658
+ * 获取抖音平台表情列表
38659
+ * 与评论处理一致:name 为文本占位符(如 [捂脸]),url 为表情图地址
38660
+ * 获取失败时返回空列表,含表情的弹幕将退化为纯文字烧录
38661
+ * @returns 表情列表(按名称长度降序,避免短名抢先匹配)
38662
+ */
38663
+ async function fetchDouyinEmojiList() {
38580
38664
  try {
38581
- const fpsMatch = ((await ffmpeg(`-i "${path}" -f null -`, { timeout: 5e3 })).stderr || "").match(/(\d+(?:\.\d+)?)\s*fps/);
38582
- if (fpsMatch) return parseFloat(fpsMatch[1]);
38583
- } catch {}
38584
- return 30;
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
- * 生成抖音弹幕 ASS 字幕内容
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
- function generateDouyinASS(danmakuList, width, height, options = {}) {
38606
- const { scrollTime = 8, danmakuOpacity = 70, fontName = "Microsoft YaHei", danmakuArea = .5, danmakuFontSize = "medium" } = options;
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
- const alpha = Math.round((100 - Math.max(0, Math.min(100, danmakuOpacity))) * 2.55).toString(16).padStart(2, "0").toUpperCase();
38616
- let ass = `[Script Info]
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
- const scrollTracks = Array(trackCount).fill(null);
38631
- const calcDistance = (last, startTime, duration, textWidth) => {
38632
- const lastSpeed = (width + last.textWidth) / last.duration;
38633
- const newSpeed = (width + textWidth) / duration;
38634
- let dist = width - (width - lastSpeed * (startTime - last.startTime) + last.textWidth) - minGap;
38635
- if (newSpeed > lastSpeed) {
38636
- const lastRightXAtEnd = width - lastSpeed * (startTime + duration - last.startTime) + last.textWidth;
38637
- dist = Math.min(dist, -textWidth - lastRightXAtEnd - minGap);
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
- return dist;
39090
+ assLines.push(buildScrollDialogue(dm.text, entry, trackIdx, width, layout));
38640
39091
  };
38641
- const sorted = [...danmakuList.filter((dm) => dm.text && dm.text.trim())].sort((a, b) => a.offset_time - b.offset_time);
38642
- for (const dm of sorted) {
38643
- const startTime = dm.offset_time;
38644
- const textWidth = estimateWidth(dm.text, fontSize);
38645
- const content = escapeASS(dm.text);
38646
- const duration = scrollTime * 1e3;
38647
- const endTime = startTime + duration;
38648
- for (let i = 0; i < scrollTracks.length; i++) {
38649
- const t = scrollTracks[i];
38650
- if (t && t.startTime + t.duration <= startTime) scrollTracks[i] = null;
38651
- }
38652
- let bestIdx = -1;
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
- if (bestIdx === -1 || bestDist < 0 && scrollTracks[bestIdx] !== null) continue;
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 / origH;
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}, 帧率: ${frameRate}fps, 码率: ${sourceBitrate}kbps`);
38767
- const assContent = generateDouyinASS(danmakuList, canvas.width, canvas.height, options);
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, assContent, "utf-8");
38770
- logger.debug(`[DouyinDanmaku] 弹幕字幕已生成: ${assPath},共 ${danmakuList.length} 条`);
38771
- const result = await ffmpeg(`-y -i "${videoPath}" -vf "${buildFilter(canvas, assPath)}" -r ${frameRate} ${getEncoderParams(await detectEncoder(videoCodec), sourceBitrate)} -c:a copy "${outputPath}"`);
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.41.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-07-31T03:44:22.390Z"
92
+ "timestamp": "2026-08-02T10:09:55.540Z"
93
93
  }