基于 FFmpeg 实现 HTTP-FLV 内置推流服务器


用 C++ 自己实现一个 HTTP-FLV 推流服务器(无需 SRS/Nginx)

1779528846889


前言

项目中需要将本地视频实时推送到局域网内的浏览器播放,调研了常见方案后决定放弃外部依赖(SRS、Nginx-RTMP),直接在 C++ 程序内嵌一个 HTTP-FLV 服务器。

本文记录完整实现思路、核心代码及踩坑记录,环境:C++17、Qt 5.14.2、FFmpeg 4.x、Windows 11。


一、方案选型

协议 延迟 浏览器原生支持 实现复杂度
HLS 5–30s 原生(iOS/macOS)
RTMP 1–3s 需 Flash(已死) 高(需外部服务)
WebRTC <1s 原生 极高
HTTP-FLV 1–3s flv.js(JS 库)

HTTP-FLV 本质是一条永不关闭的 HTTP 响应,body 持续追加 FLV tag。浏览器端配合 flv.js 即可播放,无需插件,适合局域网场景。


二、整体架构

  DemuxThread
    ├─ videoPacketQueue ──┐
    └─ audioPacketQueue ──┤
                          ▼
                    HttpFlvServer(QThread)
                      ├─ processPackets()
                      │    └─ FFmpeg FLV muxer(avio_alloc_context)
                      │         └─ writeCallback() → broadcastData()
                      └─ QTcpServer(HTTP 监听)
                           ├─ GET /          → 重定向 /player.html
                           ├─ GET /player.html → 内嵌播放器页面
                           ├─ GET /flv.js    → flv.min.js 文件
                           └─ GET /live.flv  → FLV 流(chunked)

关键设计:FFmpeg 的 avio_alloc_context 允许自定义 IO,将 FLV muxer 的输出字节流通过回调函数直接转发给所有已连接的 HTTP 客户端。


三、核心实现

3.1 自定义 IO 上下文

FFmpeg 默认将 muxer 输出写入文件,通过 avio_alloc_context 可以重定向到任意目标:

  bool HttpFlvServer::initMuxer() {
      // 分配输出格式上下文
      avformat_alloc_output_context2(&fmtCtx_, nullptr, "flv", nullptr);

      // 4KB 写缓冲,writeCallback 是静态成员函数
      const int BUF_SIZE = 4096;
      uint8_t* buf = (uint8_t*)av_malloc(BUF_SIZE);
      fmtCtx_->pb = avio_alloc_context(
          buf, BUF_SIZE,
          1,          // write_flag
          this,       // opaque,传回 this 指针
          nullptr,    // read_packet(不需要)
          &HttpFlvServer::writeCallback,
          nullptr     // seek(FLV 流式输出不支持 seek)
      );
      fmtCtx_->flags |= AVFMT_FLAG_CUSTOM_IO;

      // 添加视频流、音频流,复制编解码参数
      vStream_ = avformat_new_stream(fmtCtx_, nullptr);
      avcodec_parameters_copy(vStream_->codecpar, vpar_);
      vStream_->time_base = videoTb_;

      aStream_ = avformat_new_stream(fmtCtx_, nullptr);
      avcodec_parameters_copy(aStream_->codecpar, apar_);
      aStream_->time_base = audioTb_;

      // 写 FLV 文件头(触发 writeCallback)
      AVDictionary* opts = nullptr;
      av_dict_set(&opts, "flvflags", "no_duration_filesize", 0);
      return avformat_write_header(fmtCtx_, &opts) >= 0;
  }

3.2 写回调广播

  int HttpFlvServer::writeCallback(void* opaque, const uint8_t* buf, int size) {
      auto* self = static_cast<HttpFlvServer*>(opaque);
      QByteArray data(reinterpret_cast<const char*>(buf), size);

      // 冻结前:追加到 FLV 头缓冲(包含 sequence header)
      if (!self->headerFrozen_)
          self->flvHeader_.append(data);

      // 关键帧后重置 GOP 缓冲
      if (self->newGopStarting_) {
          self->currentGopBytes_.clear();
          self->newGopStarting_ = false;
      }
      self->currentGopBytes_.append(data);

      self->broadcastData(data);
      return size;
  }

  void HttpFlvServer::broadcastData(const QByteArray& data) {
      for (QTcpSocket* sock : streamClients_) {
          if (sock->isWritable())
              sock->write(data);
      }
  }

3.3 数据包处理循环

processPackets() 在 run() 的事件循环中周期性调用(QTimer 10ms):

  void HttpFlvServer::processPackets() {
      // 处理视频包
      AVPacket* vpkt = nullptr;
      while (videoQueue_->tryPop(vpkt)) {
          bool isKey = (vpkt->flags & AV_PKT_FLAG_KEY);

          if (needsKeyframe_) {
              if (!isKey) { av_packet_free(&vpkt); continue; }
              // 首个关键帧:记录源 PTS,冻结 FLV 头
              firstKeyframeSrcSec_ = vpkt->pts * av_q2d(videoTb_);
              needsKeyframe_ = false;
              headerFrozen_ = true;
              // 通知 writeCallback 开始 GOP 缓冲
              newGopStarting_ = true;
          } else if (isKey) {
              newGopStarting_ = true;  // 新 GOP 开始,重置缓冲
          }

          // PTS 续接(处理 seek 后时间戳跳变)
          int64_t pts = computeContinuousPts(vpkt, /*isVideo=*/true);

          AVPacket out = *vpkt;
          out.stream_index = vStream_->index;
          out.pts = out.dts = pts;
          av_interleaved_write_frame(fmtCtx_, &out);
          av_packet_free(&vpkt);
      }

      // 处理音频包(等待首帧视频关键帧后才开始输出)
      AVPacket* apkt = nullptr;
      while (!needsKeyframe_ && audioQueue_->tryPop(apkt)) {
          int64_t pts = computeContinuousPts(apkt, /*isVideo=*/false);
          AVPacket out = *apkt;
          out.stream_index = aStream_->index;
          out.pts = out.dts = pts;
          av_interleaved_write_frame(fmtCtx_, &out);
          av_packet_free(&apkt);
      }
  }

3.4 新客户端接入(延迟接入优化)

新客户端连接时直接发原始 FLV 流会有两个问题:

  1. 浏览器收到 FLV 需要先有 sequence header(AVC/AAC 解码配置)
  2. 如果只发 sequence header 没有关键帧,播放器需等到下一个关键帧才能起播,可能有数秒黑屏

解决方案:维护两段缓冲

  // 新客户端接入时发送:
  // 1. codecConfigHeader_:FLV头 + metadata + AVC序列头 + AAC序列头(无帧数据)
  // 2. currentGopBytes_:最近一个关键帧起至今的帧数据

  void HttpFlvServer::startStreaming(QTcpSocket* socket) {
      // HTTP 响应头
      socket->write(
          "HTTP/1.1 200 OK\r\n"
          "Content-Type: video/x-flv\r\n"
          "Transfer-Encoding: chunked\r\n"
          "Connection: keep-alive\r\n"
          "Cache-Control: no-cache\r\n\r\n"
      );

      // 发送编解码配置 + 当前 GOP
      if (!codecConfigHeader_.isEmpty())
          socket->write(codecConfigHeader_);
      if (!currentGopBytes_.isEmpty())
          socket->write(currentGopBytes_);

      streamClients_.append(socket);
  }

  codecConfigHeader_ 和 currentGopBytes_ 的提取方式:解析 flvHeader_ 中的 FLV tag,按 tag type 分类(0x08=音频,0x09=视频),序列头 tag 的 AVCPacketType 字节为 0。

四、HTTP 服务层

run() 使用 Qt 事件循环:

  void HttpFlvServer::run() {
      QTcpServer server;
      server.listen(QHostAddress::Any, port_);

      connect(&server, &QTcpServer::newConnection, [&]() {
          QTcpSocket* sock = server.nextPendingConnection();
          connect(sock, &QTcpSocket::readyRead, [=]() {
              QByteArray req = sock->readAll();
              handleRequest(sock, req);
          });
      });

      QTimer timer;
      connect(&timer, &QTimer::timeout, this, &HttpFlvServer::processPackets);
      timer.start(10);

      // 尝试添加 Windows 防火墙入站规则
      addFirewallRule();

      exec();  // Qt 事件循环

      // 退出后写 FLV trailer
      if (fmtCtx_) av_write_trailer(fmtCtx_);
      cleanupMuxer();
  }

五、前端播放器

服务器内嵌一段 HTML,由 buildPlayerHtml() 在运行时生成:

  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="UTF-8">
    <script src="/flv.js"></script>
  </head>
  <body style="background:#000;margin:0">
    <video id="v" muted style="width:100vw;height:100vh" controls></video>
    <button onclick="document.getElementById('v').muted=false"
            style="position:absolute;top:10px;right:10px;z-index:9">
      开启声音
    </button>
    <script>
      if (flvjs.isSupported()) {
        var player = flvjs.createPlayer({ type:'flv', url:'/live.flv' });
        player.attachMediaElement(document.getElementById('v'));
        player.load();
        player.play();
      }
    </script>
  </body>
  </html>

▎ 注意:移动端浏览器自动播放策略限制,video 必须带 muted 才能自动播放,页面上额外提供"开启声音"按钮。


六、踩坑记录

坑 1:FLV 容器只支持 H.264 + AAC

在 init() 时需要提前检查编解码器,否则 avformat_write_header 会失败但错误信息极不明显:

  bool HttpFlvServer::init(quint16 port,
                           AVCodecParameters* vpar, AVRational vtb,
                           AVCodecParameters* apar, AVRational atb) {
      if (vpar && vpar->codec_id != AV_CODEC_ID_H264) {
          qWarning() << "HttpFlvServer: codec"
                     << avcodec_get_name(vpar->codec_id)
                     << "not supported by FLV (H.264 only)";
          return false;
      }
      // ...
  }

上层 StreamController 据此给出明确提示:

  QString reason = (vpar && vpar->codec_id != AV_CODEC_ID_H264)
      ? QString("视频编码 %1 不支持 FLV,请使用 H.264 编码的视频")
            .arg(avcodec_get_name(vpar->codec_id))
      : QString("端口 %1 初始化失败").arg(dest.port);
  emit errorOccurred("HTTP-FLV 推流失败:" + reason);

坑 2:initMuxer 失败后队列需要 abort

若 initMuxer() 失败但 run() 直接返回,生产者线程(DemuxThread)的 tryPush 会一直阻塞:

  if (!initMuxer()) {
      if (videoQueue_) videoQueue_->abort();
      if (audioQueue_) audioQueue_->abort();
      emit errorOccurred("FLV muxer 初始化失败");
      return;
  }

坑 3:seek 后音视频时间戳需要续接

seek 会使 PTS 产生跳变,需要在 processPackets 中做时间戳重映射。以视频为例:

  int64_t HttpFlvServer::computeContinuousPts(AVPacket* pkt, bool isVideo) {
      int64_t& segBase   = isVideo ? videoSegBase_   : audioSegBase_;
      int64_t& accumPts  = isVideo ? videoAccumPts_  : audioAccumPts_;
      int64_t& lastOut   = isVideo ? videoLastOut_   : audioLastOut_;
      AVRational tb      = isVideo ? videoTb_        : audioTb_;

      if (segBase == AV_NOPTS_VALUE || pkt->pts < segBase) {
          // 新段:将上次输出 PTS 作为续接基准
          segBase  = pkt->pts;
          accumPts = lastOut + 1;
      }
      int64_t out = accumPts + (pkt->pts - segBase);
      lastOut = out;
      return out;
  }

坑 4:首帧音频对齐

seek 后视频关键帧可能比音频目标早几帧,若不对齐会导致声音超前或滞后。做法:记录首个关键帧的源 PTS,首帧音频到来时将音频的 accumPts 对齐至该时间点:

  // 首帧音频:对齐至首个视频关键帧的时间位置
  if (firstKeyframeSrcSec_ >= 0.0) {
      int64_t alignPts = (int64_t)(firstKeyframeSrcSec_ / av_q2d(audioTb_));
      audioAccumPts_ = alignPts;
      audioSegBase_  = apkt->pts;
      firstKeyframeSrcSec_ = -1.0;  // 只对齐一次
  }

坑 5:Windows 防火墙自动开放端口

局域网其他设备无法访问时,需要添加入站规则。程序启动时自动尝试(需管理员权限),失败时发出信号提示用户:

  void HttpFlvServer::addFirewallRule() {
      QString cmd = QString(
          "netsh advfirewall firewall add rule name=\"RambosPlayer HTTP-FLV\" "
          "protocol=TCP dir=in localport=%1 action=allow"
      ).arg(port_);

      int ret = QProcess::execute("netsh", cmd.split(' '));
      if (ret != 0)
          emit firewallHint(cmd);  // 通知用户以管理员身份手动执行
  }

七、效果

  • 同局域网浏览器打开 http://局域网IP:8080/player.html 即可观看
  • 延迟约 1–2 秒(取决于 GOP 大小,默认 2s 一个关键帧)
  • 支持多客户端同时连接(广播模式)
  • 客户端任意时刻接入均能快速起播(GOP 缓冲)

八、总结

核心技术点 实现方式
FLV 流式输出 avio_alloc_context + 自定义 writeCallback
HTTP 广播 QTcpServer + QTcpSocket::write
延迟接入优化 codecConfigHeader_ + currentGopBytes_
音视频同步 needsKeyframe_ 门控 + firstKeyframeSrcSec_ 对齐
seek 时间戳续接 分段基准 + 累计 PTS 重映射
移动端播放 muted 属性 + 用户点击解除静音

完整代码参见:src/httpflvserver.h / src/httpflvserver.cpp
github:https://github.com/johnjiamzhong-project/RambosPlayer

环境:C++17 / Qt 5.14.2 / FFmpeg 4.x / Windows 11

posted @ 2026-05-23 17:34  rambos1996  阅读(28)  评论(0)    收藏  举报