Skip to content

[bug] 部分抖音视频能解析也能发送,但 QQ 客户端无法显示(音频为 HE-AACv2) #194

Description

@Reality582

环境

AstrBot v4.28.0
astrbot_plugin_parser v1.5.7
协议端 NapCat(motricseven7/snowluma 镜像,Docker)
系统 Debian 12 (Linux, x86_64)
ffmpeg / ffprobe 5.1.9

现象

在群里发送抖音分享链接(形如 https://v.douyin.com/xxxxxxxx/,由抖音 App「分享 → 复制链接」生成)后:

  • 机器人先给原消息贴上一个表情回应(即 EmojiLikeArbiter 仲裁占用的 emoji 289), 此后就再没有任何下一步
  • 群里看不到解析卡片,也看不到视频
  • 同一时间段的 B 站视频解析能正常显示。

用户在群里实际观察到的表现(:

只给那条消息贴了个表情占位,然后没有下一步。

需要特别说明:这个"只有占位表情、没有下文"的表象具有误导性 —— 它会让人以为机器人卡在了仲裁或解析阶段。 而实际上按日志,消息已经成功发出(协议端有发送记录并返回了 message_id), 只是由于下述音频编码原因,QQ 客户端没有把它渲染出来, 于是呈现为"贴完表情就没动静了"。

排查:问题不在解析,也不在发送

链路各环节均有证据表明是成功的:

环节 证据
链接匹配 v.douyin.com/[a-zA-Z0-9_-]+ 对实际消息文本可命中
仲裁 EmojiLikeArbiter 协议端日志可见 [Event] 表情回应 ... +[289]
解析 + 下载 缓存产物 cache/.mp4(371,182 字节),ffprobe 可正常读取
AstrBot 提交 send_group_msg -> group=<群号> segs=['video'],无异常
协议端接收 send_group_msg <- ret={'message_id': 1090905876}
协议端转发 INFO [OneBot] 群聊 <群号> | 发送:[视频]

两者唯一处于"不兼容档位"的差异是音频 profile:抖音侧为 HE-AACv2,B 站侧为 AAC-LC

结合两组事实——(a) AstrBot 与协议端都报告发送成功、且协议端返回了 message_id, (b) 把音频换成 AAC-LC 后该视频即可正常显示—— 判定本问题为 QQ 侧对该视频的 HE-AACv2 音频渲染失败, 表现为"消息已发出但客户端不可见"。 (说明:QQ 客户端内部行为无法直接观测,此处是由对比实验反推的结论。)

复现方式:
ffprobe -v error -select_streams a:0 \
  -show_entries stream=profile,codec_name \
  -of default=nw=1:nk=1 <下载到的抖音视频>.mp4
# 输出:
#   aac
#   HE-AACv2

建议的修复方向

在发送视频前检测音频 profile,仅当不是 LC 时才转码(视频流直接 copy,几乎无耗时):

# 1) 探测
ffprobe -v error -select_streams a:0 -show_entries stream=profile \
  -of default=nw=1:nk=1 input.mp4

# 2) 若非 LC,则只重编码音频
ffmpeg -y -v error -i input.mp4 \
  -c:v copy -c:a aac -profile:a aac_low -movflags +faststart output.mp4

要点:

  • 只对非 LC 的视频生效,B 站等本来就是 LC 的视频直接跳过,不引入额外开销;
  • 视频流 -c:v copy,只重编码音频,成本极低;
  • 转码产物建议放在插件自有 cache 目录,交由已有的 CacheCleaner 清理。

我采用的实现(供参考)

改动集中在 core/sender.py,共 3 处。只对非 LC 音频的视频生效,B 站等本来就是 LC 的视频会直接跳过。

1) 文件头补 import

import asyncio
from itertools import chain
from pathlib import Path

2) 新增转码方法(与 _video_from_path 同级)

    @staticmethod
    async def _transcode_video_for_qq(path: Path) -> Path:
        """把 QQ 可能不渲染的音频(如 HE-AACv2)转成 AAC-LC,视频流直接复制。

        抖音视频常见 HE-AACv2 音频,QQ 收下后会静默不显示;B站的 LC 音频不受影响,
        因此这里先用 ffprobe 判断,已是 LC 就直接跳过,不做无谓转码。
        """
        try:
            probe = await asyncio.create_subprocess_exec(
                "ffprobe",
                "-v", "error",
                "-select_streams", "a:0",
                "-show_entries", "stream=profile",
                "-of", "default=nw=1:nk=1",
                str(path),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.DEVNULL,
            )
            out, _ = await probe.communicate()
            profile = (out or b"").decode(errors="ignore").strip()
        except Exception as e:
            logger.warning(f"[视频] ffprobe 探测失败,跳过转码: {e}")
            return path

        if not profile or profile.lower() in ("lc", "aac_lc", "aac lc"):
            return path

        out_path = path.with_name(path.stem + "_lc.mp4")
        try:
            proc = await asyncio.create_subprocess_exec(
                "ffmpeg",
                "-y", "-v", "error",
                "-i", str(path),
                "-c:v", "copy",
                "-c:a", "aac",
                "-profile:a", "aac_low",
                "-movflags", "+faststart",
                str(out_path),
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.PIPE,
            )
            _, err = await proc.communicate()
        except Exception as e:
            logger.warning(f"[视频] ffmpeg 调用异常,跳过转码: {e}")
            return path

        if proc.returncode != 0 or not out_path.exists():
            logger.warning(
                f"[视频] 音频转码失败(profile={profile}),改用原文件: "
                f"{(err or b'')[:200]!r}"
            )
            return path

        logger.info(
            f"[视频] 音频 {profile} -> AAC-LC 完成: {out_path.name} "
            f"({out_path.stat().st_size} bytes)"
        )
        return out_path

3) 在 _build_segments 的重媒体分支调用

                case VideoContent() | DynamicContent():
                    path = await self._transcode_video_for_qq(path)
                    segs.append(self._video_from_path(path))

设计要点

  • ffprobe 探测失败 / 无音频流 → 直接用原文件,不因工具缺失或异常中断发送;
  • 已是 LC → 立即返回原路径,零额外开销(B 站视频走这条);
  • 转码失败 → 回退原文件并打 WARNING,不影响主流程;
  • 转码产物命名为 <原名>_lc.mp4,与源文件同目录,交由插件已有的 CacheCleaner 清理。

排查过程中已排除的可能(供参考,避免重复走弯路)

以下都曾被怀疑,但均已被实测或日志证伪:

  1. 链接匹配失败 —— 实测正则对真实消息文本可命中;
  2. 仲裁失败 —— 协议端日志可见 表情回应 +[289]
  3. ttwid / cookie 缺失 —— 缓存里有完整的 371KB mp4;
  4. desc 字段为空导致降级 —— 代码中不存在此分支,desc 仅用于日志与 title
  5. _ROUTER_DATA 缺少 videoInfoRes —— 与"已下载到完整 mp4"的事实矛盾;
  6. 合并转发(Nodes)导致 —— 将 forward_threshold 调大改为 Video 直发后,现象不变;
  7. NapCat 读不到 file:// 本地路径 —— B 站用同样的 file:// 形式可正常显示;
  8. 特定群存在限制 —— 换到此前成功过的群,现象不变;
  9. AstrBot 与协议端 WS 连接半死 —— 重启协议端容器后现象不变。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions