Files
silver_pose/v2/internal/source/stream.go
T
ilaandClaude Opus 4.8 09f7ecad67 fix(v2): drop rw_timeout ffmpeg option rejected by the RTSP demuxer
Field ffmpeg reported 'Option rw_timeout not found' and exited, causing a
reconnect loop. rw_timeout is an avio/protocol option, not an RTSP demuxer
option; the RTSP socket timeout is build-specific (-stimeout / -timeout).
Omit it for portability — a dead TCP connection still ends decode and
triggers reconnect. Keeps transport and low-latency flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:36:51 +08:00

311 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package source owns the FFprobe/FFmpeg stream lifecycle for one camera.
package source
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
var rtspURLPattern = regexp.MustCompile(`rtsp://[^\s'"]+`)
// redact removes the resolved source URL (which may carry credentials) from any
// FFmpeg/FFprobe diagnostic text before it is logged.
func redact(text, sourceURL string) string {
if sourceURL != "" {
text = strings.ReplaceAll(text, sourceURL, "<source>")
}
return rtspURLPattern.ReplaceAllString(text, "rtsp://<redacted>")
}
type Status string
const (
Connecting Status = "CONNECTING"
Connected Status = "CONNECTED"
Retrying Status = "RETRYING"
Stopped Status = "STOPPED"
)
// Config is intentionally separate from the persisted configuration. SourceURL
// exists only in process memory after config resolves its named environment variable.
type Config struct {
SourceURL string
FFmpegPath string
FFprobePath string
Transport string
Timeout time.Duration
LowLatency bool
}
type Metadata struct {
Width int
Height int
FPS float64
}
type Frame struct {
BGR []byte
Width int
Height int
Timestamp time.Duration
Sequence uint64
}
type Update struct {
Status Status
Message string
Attempt int
}
// Dependencies make lifecycle behavior testable without an FFmpeg installation
// or a real source URL. Nil functions use the safe default subprocess adapter.
type Dependencies struct {
Probe func(context.Context, Config) (Metadata, error)
Decode func(context.Context, Config, Metadata, func([]byte)) error
Wait func(context.Context, time.Duration) error
}
type Stream struct {
frames chan Frame
statuses chan Update
done chan struct{}
once sync.Once
}
// Start begins one reconnecting stream worker. Consumers receive at most one
// pending frame: a slow inference/UI path always observes the most recent frame.
func Start(ctx context.Context, config Config, dependencies Dependencies) *Stream {
stream := &Stream{
frames: make(chan Frame, 1),
statuses: make(chan Update, 16),
done: make(chan struct{}),
}
if dependencies.Probe == nil {
dependencies.Probe = probe
}
if dependencies.Decode == nil {
dependencies.Decode = decode
}
if dependencies.Wait == nil {
dependencies.Wait = waitContext
}
go stream.run(ctx, config, dependencies)
return stream
}
func (stream *Stream) Frames() <-chan Frame { return stream.frames }
func (stream *Stream) Statuses() <-chan Update { return stream.statuses }
func (stream *Stream) Done() <-chan struct{} { return stream.done }
func (stream *Stream) run(ctx context.Context, config Config, dependencies Dependencies) {
defer stream.close()
started := time.Now()
attempt := 0
sequence := uint64(0)
for {
if ctx.Err() != nil {
stream.publishStatus(Update{Status: Stopped, Message: "视频流已停止", Attempt: attempt})
return
}
stream.publishStatus(Update{Status: Connecting, Message: "正在连接视频流", Attempt: attempt})
metadata, err := dependencies.Probe(ctx, config)
if err == nil {
stream.publishStatus(Update{Status: Connected, Message: "视频流已连接", Attempt: attempt})
err = dependencies.Decode(ctx, config, metadata, func(bgr []byte) {
sequence++
publishLatest(stream.frames, Frame{
BGR: append([]byte(nil), bgr...),
Width: metadata.Width,
Height: metadata.Height,
Timestamp: time.Since(started),
Sequence: sequence,
})
})
}
if ctx.Err() != nil {
stream.publishStatus(Update{Status: Stopped, Message: "视频流已停止", Attempt: attempt})
return
}
stream.publishStatus(Update{Status: Retrying, Message: "视频流已断开,正在重连", Attempt: attempt})
if err := dependencies.Wait(ctx, retryDelay(attempt)); err != nil {
stream.publishStatus(Update{Status: Stopped, Message: "视频流已停止", Attempt: attempt})
return
}
attempt++
}
}
func (stream *Stream) close() {
stream.once.Do(func() {
close(stream.frames)
close(stream.statuses)
close(stream.done)
})
}
func (stream *Stream) publishStatus(update Update) {
select {
case stream.statuses <- update:
default:
// Status is advisory. A saturated observer must not block source cleanup.
}
}
func publishLatest(frames chan Frame, frame Frame) {
select {
case frames <- frame:
return
default:
}
select {
case <-frames:
default:
}
select {
case frames <- frame:
default:
}
}
func retryDelay(attempt int) time.Duration {
if attempt <= 0 {
return time.Second
}
if attempt >= 3 {
return 8 * time.Second
}
return time.Second << attempt
}
func waitContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func probe(ctx context.Context, config Config) (Metadata, error) {
if strings.TrimSpace(config.FFprobePath) == "" {
return Metadata{}, errors.New("FFprobe executable is not configured")
}
if strings.TrimSpace(config.SourceURL) == "" {
return Metadata{}, errors.New("video source is not configured")
}
args := []string{"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,r_frame_rate", "-of", "json"}
args = appendInputOptions(args, config)
args = append(args, "-i", config.SourceURL)
command := exec.CommandContext(ctx, config.FFprobePath, args...)
var stderr bytes.Buffer
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
log.Printf("ffprobe 探测失败:%s(%v)", strings.TrimSpace(redact(stderr.String(), config.SourceURL)), err)
return Metadata{}, errors.New("unable to probe video metadata")
}
var parsed struct {
Streams []struct {
Width int `json:"width"`
Height int `json:"height"`
FrameRate string `json:"r_frame_rate"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &parsed); err != nil || len(parsed.Streams) == 0 {
return Metadata{}, errors.New("invalid video metadata")
}
metadata := Metadata{Width: parsed.Streams[0].Width, Height: parsed.Streams[0].Height}
metadata.FPS = parseFrameRate(parsed.Streams[0].FrameRate)
if metadata.Width <= 0 || metadata.Height <= 0 || metadata.FPS <= 0 {
return Metadata{}, errors.New("invalid video dimensions or frame rate")
}
return metadata, nil
}
func decode(ctx context.Context, config Config, metadata Metadata, publish func([]byte)) error {
if strings.TrimSpace(config.FFmpegPath) == "" {
return errors.New("FFmpeg executable is not configured")
}
if strings.TrimSpace(config.SourceURL) == "" {
return errors.New("video source is not configured")
}
if metadata.Width <= 0 || metadata.Height <= 0 {
return errors.New("invalid video dimensions")
}
args := append([]string{"-hide_banner", "-loglevel", "error"}, appendInputOptions(nil, config)...)
args = append(args, "-i", config.SourceURL, "-an", "-sn", "-dn", "-f", "rawvideo", "-pix_fmt", "bgr24", "-")
command := exec.CommandContext(ctx, config.FFmpegPath, args...)
var stderr bytes.Buffer
command.Stderr = &stderr
stdout, err := command.StdoutPipe()
if err != nil {
return errors.New("unable to read video frames")
}
if err := command.Start(); err != nil {
return errors.New("unable to start video decoder")
}
frameSize := metadata.Width * metadata.Height * 3
frame := make([]byte, frameSize)
for {
_, err := io.ReadFull(stdout, frame)
if err != nil {
_ = command.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
log.Printf("ffmpeg 解码停止:%s(%v)", strings.TrimSpace(redact(stderr.String(), config.SourceURL)), err)
return errors.New("video decoder stopped")
}
publish(frame)
}
}
func appendInputOptions(args []string, config Config) []string {
transport := strings.ToLower(strings.TrimSpace(config.Transport))
if transport == "tcp" || transport == "udp" {
args = append(args, "-rtsp_transport", transport)
}
// NOTE: the RTSP socket-timeout option is build-specific (`-stimeout` on
// ffmpeg < 7.0, `-timeout` on >= 5.1) and `-rw_timeout` is rejected by the
// RTSP demuxer ("Option rw_timeout not found"). We omit it for portability;
// a dead TCP connection still ends the decode and triggers reconnect. A
// version-matched timeout can be reintroduced once the ffmpeg build is known.
if config.LowLatency {
args = append(args, "-fflags", "nobuffer", "-flags", "low_delay")
}
return args
}
func parseFrameRate(value string) float64 {
parts := strings.Split(value, "/")
if len(parts) == 2 {
numerator, numeratorErr := strconv.ParseFloat(parts[0], 64)
denominator, denominatorErr := strconv.ParseFloat(parts[1], 64)
if numeratorErr == nil && denominatorErr == nil && denominator > 0 {
return numerator / denominator
}
}
fps, err := strconv.ParseFloat(value, 64)
if err != nil || fps <= 0 {
return 0
}
return fps
}
func (metadata Metadata) String() string {
return fmt.Sprintf("%dx%d@%.3f", metadata.Width, metadata.Height, metadata.FPS)
}