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 }