feat: sync group call livestream support

This commit is contained in:
A 2026-07-06 14:31:19 +08:00
parent 56d995474c
commit f1a27996d3
37 changed files with 2219 additions and 83 deletions

View file

@ -0,0 +1,285 @@
package livestream
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sync"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// segmentDurationMs 是 broadcast part 的固定时长。tgcalls StreamingMediaContext
// 写死 _segmentDuration=1000(scale 0),时间轴推进按 +1000 走,segment 必须严格
// 1 秒切齐(转码强制每秒关键帧保证切点)。
const segmentDurationMs = 1000
// minSegmentsBeforeAnnounce:客户端拿到 last_timestamp_ms 后从 last-2000 开始拉,
// 至少积 3 段再对外公布时间轴,避免起播即请求不存在的负偏移段。
const minSegmentsBeforeAnnounce = 3
// stream 是一路活跃 RTMP 推流:FLV 入 ffmpeg(转码+按秒切 MPEG-TS)→ 打包 part
// 入内存 ring。时间轴:T0 取首段完成时刻向下取整秒,第 i 段的 time_ms = T0+i*1000。
type stream struct {
channelID int64
log *zap.Logger
dir string
keep int
cmd *exec.Cmd
stdin io.WriteCloser
closer io.Closer // RTMP 连接,DropChannel 时踢掉推流端
mu sync.Mutex
flvStarted bool
baseMs int64 // T0;0=尚未产出任何 segment
segments map[int64][]byte // time_ms → packed part
order []int64 // 按 time_ms 升序(ring 淘汰用)
lastMs int64 // 最新 segment 的 time_ms
segmentSeq int64 // 已完成 segment 计数
ended bool
oversizeWas bool
nowMs func() int64
}
// ffmpegArgs 组装转码+切段命令。要点:
// - 强制每秒关键帧(-force_key_frames)保证 -f segment 严格按 1s 切;
// - 严格码率上限:TDesktop 拉 part 单次 `upload.getFile(offset=0,limit=128KiB)`
// 且**不续读**,单段(视频+音频+TS 开销)>128KiB 会被静默截断致花屏。
// 故 unified 单质量必须压在 ~1Mbps 以下——这里目标 ~640kbps:视频
// 480k(maxrate/bufsize=480k 收紧到每秒 VBV,杜绝关键帧段爆量)+ 音频 64k,
// 并降到 640x360/24fps 进一步留余量;
// - 输出自包含 mp4(每段独立 moov,可单独 avformat_open_input)。⚠ 不能用
// MPEG-TS:TDesktop 裁剪版 ffmpeg 的 demuxer 白名单只有 mov/mp4 系
// (prepare.py --enable-demuxer),mpegts 会让 tgcalls 打不开容器 → 黑屏;
// - -segment_list pipe:1 每完成一段输出一行文件名,作为完成事件。
func ffmpegArgs(outDir string) []string {
return []string{
"-hide_banner", "-nostats", "-loglevel", "warning",
"-fflags", "+genpts",
"-f", "flv", "-i", "pipe:0",
"-vf", "scale=-2:360", "-r", "24",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-profile:v", "main", "-pix_fmt", "yuv420p",
"-b:v", "480k", "-maxrate", "480k", "-bufsize", "480k",
"-g", "24", "-keyint_min", "24",
"-force_key_frames", "expr:gte(t,n_forced*1)", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "64k", "-ar", "48000", "-ac", "2",
"-f", "segment",
"-segment_time", "1",
"-segment_format", "mp4",
"-segment_format_options", "movflags=+faststart",
"-segment_list", "pipe:1",
"-segment_list_type", "flat",
"-reset_timestamps", "1",
filepath.Join(outDir, "seg%06d.mp4"),
}
}
func newStream(channelID int64, ffmpegPath, workDir string, keep int, closer io.Closer, nowMs func() int64, log *zap.Logger) (*stream, error) {
dir, err := os.MkdirTemp(workDir, fmt.Sprintf("live_%d_", channelID))
if err != nil {
return nil, fmt.Errorf("livestream: workdir: %w", err)
}
cmd := exec.Command(ffmpegPath, ffmpegArgs(dir)...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("livestream: ffmpeg stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("livestream: ffmpeg stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("livestream: ffmpeg stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("livestream: start ffmpeg: %w", err)
}
s := &stream{
channelID: channelID,
log: log,
dir: dir,
keep: keep,
cmd: cmd,
stdin: stdin,
closer: closer,
segments: make(map[int64][]byte),
nowMs: nowMs,
}
go s.readSegmentList(stdout)
go s.logStderr(stderr)
go func() {
_ = cmd.Wait()
s.mu.Lock()
s.ended = true
s.mu.Unlock()
}()
return s, nil
}
// readSegmentList 消费 ffmpeg 的 segment 完成事件流。
func (s *stream) readSegmentList(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
name := scanner.Text()
if name == "" {
continue
}
path := filepath.Join(s.dir, filepath.Base(name))
data, err := os.ReadFile(path)
if err != nil {
s.log.Warn("live stream read segment", zap.String("path", path), zap.Error(err))
continue
}
// 诊断:TELESRV_LIVESTREAM_DUMP_DIR 非空时把原始 TS 切片留档供 ffprobe 检查。
if dump := os.Getenv("TELESRV_LIVESTREAM_DUMP_DIR"); dump != "" {
_ = os.MkdirAll(dump, 0o755)
_ = os.WriteFile(filepath.Join(dump, fmt.Sprintf("ch%d_%s", s.channelID, filepath.Base(name))), data, 0o644)
}
_ = os.Remove(path)
s.addSegment(data)
}
}
func (s *stream) logStderr(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
s.log.Info("ffmpeg", zap.Int64("channel_id", s.channelID), zap.String("line", scanner.Text()))
}
}
func (s *stream) addSegment(tsData []byte) {
part := packUnifiedPart(tsData)
s.mu.Lock()
defer s.mu.Unlock()
if s.baseMs == 0 {
s.baseMs = s.nowMs() / segmentDurationMs * segmentDurationMs
}
timeMs := s.baseMs + s.segmentSeq*segmentDurationMs
s.segmentSeq++
s.segments[timeMs] = part
s.order = append(s.order, timeMs)
s.lastMs = timeMs
for len(s.order) > s.keep {
delete(s.segments, s.order[0])
s.order = s.order[1:]
}
s.log.Debug("live stream segment produced",
zap.Int64("channel_id", s.channelID), zap.Int64("time_ms", timeMs),
zap.Int("bytes", len(part)), zap.Int64("seq", s.segmentSeq),
zap.Int64("wall_ms", s.nowMs()))
if len(part) > 128<<10 && !s.oversizeWas {
s.oversizeWas = true
s.log.Warn("live stream segment exceeds 128KiB, client will truncate",
zap.Int64("channel_id", s.channelID), zap.Int("bytes", len(part)))
}
}
// channels 返回当前时间轴(不足 minSegmentsBeforeAnnounce 段时不公布)。
func (s *stream) channels() []domain.LiveStreamChannel {
s.mu.Lock()
defer s.mu.Unlock()
if s.ended || len(s.order) < minSegmentsBeforeAnnounce {
return nil
}
return []domain.LiveStreamChannel{{Channel: 1, Scale: 0, LastTimestampMs: s.lastMs}}
}
// part 取指定 time_ms 的打包 part。
func (s *stream) part(timeMs int64) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.baseMs == 0 {
return nil, domain.ErrLiveStreamPartNotReady
}
if timeMs < s.baseMs || (timeMs-s.baseMs)%segmentDurationMs != 0 {
return nil, domain.ErrLiveStreamPartExpired
}
if timeMs > s.lastMs {
if s.ended {
return nil, domain.ErrLiveStreamNoStream
}
return nil, domain.ErrLiveStreamPartNotReady
}
part, ok := s.segments[timeMs]
if !ok {
return nil, domain.ErrLiveStreamPartExpired
}
return part, nil
}
func (s *stream) active() bool {
s.mu.Lock()
defer s.mu.Unlock()
return !s.ended
}
// stop 结束推流:断 RTMP 连接、关 ffmpeg stdin(自然退出),清空缓冲目录。
func (s *stream) stop() {
s.mu.Lock()
if s.ended {
s.mu.Unlock()
return
}
s.ended = true
s.mu.Unlock()
if s.closer != nil {
_ = s.closer.Close()
}
_ = s.stdin.Close()
go func() {
_ = s.cmd.Wait()
_ = os.RemoveAll(s.dir)
}()
}
// ---- FLV 写入(RTMP tag → ffmpeg stdin)----
var flvHeader = []byte{'F', 'L', 'V', 0x01, 0x05, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00}
// writeTag 把一条 RTMP media/data tag 以 FLV 封装写进 ffmpeg stdin。
// tagType:8=audio 9=video 18=script data。
func (s *stream) writeTag(tagType byte, timestampMs uint32, body []byte) error {
s.mu.Lock()
if s.ended {
s.mu.Unlock()
return domain.ErrLiveStreamNoStream
}
started := s.flvStarted
s.flvStarted = true
s.mu.Unlock()
if !started {
if _, err := s.stdin.Write(flvHeader); err != nil {
return err
}
}
var hdr [11]byte
hdr[0] = tagType
hdr[1] = byte(len(body) >> 16)
hdr[2] = byte(len(body) >> 8)
hdr[3] = byte(len(body))
hdr[4] = byte(timestampMs >> 16)
hdr[5] = byte(timestampMs >> 8)
hdr[6] = byte(timestampMs)
hdr[7] = byte(timestampMs >> 24)
// stream id hdr[8:11] = 0
if _, err := s.stdin.Write(hdr[:]); err != nil {
return err
}
if _, err := s.stdin.Write(body); err != nil {
return err
}
var prev [4]byte
binary.BigEndian.PutUint32(prev[:], uint32(11+len(body)))
_, err := s.stdin.Write(prev[:])
return err
}