Ctrl K
ComunidadGuías…

internal/app/livestream/segmenter.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package livestream

import (
	"bufio"
	"encoding/binary"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sync"

	"go.uber.org/zap"

	"telesrv/internal/domain"
)

// segmentDurationMs is the fixed duration of a broadcast part. tgcalls
// StreamingMediaContext hardcodes _segmentDuration=1000 (scale 0); the timeline advances in
// steps of +1000, and segments must be cut exactly every 1 second (transcoding forces a
// keyframe every second to guarantee the cut point).
const segmentDurationMs = 1000

// minSegmentsBeforeAnnounce: after receiving last_timestamp_ms the client pulls starting
// from last-2000, so accumulate at least 3 segments before announcing the timeline, avoiding
// a playback start that requests a nonexistent negative-offset segment.
const minSegmentsBeforeAnnounce = 3

// stream is one active RTMP publish: FLV into ffmpeg (transcode + one-second MPEG-TS
// splits) → packed parts into the in-memory ring. Timeline: T0 is the first segment's
// completion time floored to the second; segment i's 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 connection; the publisher is kicked on DropChannel

	mu          sync.Mutex
	flvStarted  bool
	baseMs      int64            // T0; 0 = no segment produced yet
	segments    map[int64][]byte // time_ms → packed part
	order       []int64          // ascending by time_ms (for ring eviction)
	lastMs      int64            // time_ms of the newest segment
	segmentSeq  int64            // count of completed segments
	ended       bool
	oversizeWas bool

	nowMs func() int64
}

// ffmpegArgs assembles the transcode + segment command. Key points:
//   - Force a keyframe every second (-force_key_frames) to keep -f segment cutting strictly
//     at 1s;
//   - Strict bitrate cap: TDesktop pulls a part with a single
//     `upload.getFile(offset=0,limit=128KiB)` and **does not continue reading**; a single
//     segment (video+audio+TS overhead) >128KiB is silently truncated, causing artifacts.
//     So the unified single quality must stay under ~1Mbps — target ~640kbps here: video 480k
//     (maxrate/bufsize=480k tightened to per-second VBV, preventing keyframe-segment bursts)
//     plus audio 64k, and lower to 640x360/24fps for extra headroom;
//   - Output a self-contained mp4 (each segment has its own moov and can be
//     avformat_open_input'd independently). ⚠ MPEG-TS cannot be used: TDesktop's trimmed
//     ffmpeg demuxer whitelist only has mov/mp4-family demuxers
//     (prepare.py --enable-demuxer), and mpegts makes tgcalls fail to open the container →
//     black screen;
//   - -segment_list pipe:1 prints one filename line per completed segment, serving as the
//     completion event.
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 consumes ffmpeg's segment-completion event stream.
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
		}
		// Diagnostics: when TELESRV_LIVESTREAM_DUMP_DIR is non-empty, archive the raw TS segments for ffprobe inspection.
		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 returns the current timeline (not announced until at least minSegmentsBeforeAnnounce segments).
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 fetches the packed part for the specified time_ms.
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 ends publishing: cut the RTMP connection, close ffmpeg stdin (natural exit), and clear the buffer directory.
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 writing (RTMP tag → ffmpeg stdin) ----

var flvHeader = []byte{'F', 'L', 'V', 0x01, 0x05, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00}

// writeTag writes one RTMP media/data tag, FLV-framed, into 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
}