Files
silver_pose/v2/internal/source/stream.go
T
ilaandClaude Opus 4.8 99226c7479 fix(v2): log redacted ffmpeg/ffprobe stderr on stream failure
The stream worker discarded ffmpeg stderr, so reconnect loops were opaque.
Capture stderr and log it with the RTSP URL/credentials redacted, so the
real cause (auth, timeout, option, URL parse) is diagnosable without
leaking the source address.

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

309 lines
8.6 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)
}
if config.Timeout > 0 {
args = append(args, "-rw_timeout", strconv.FormatInt(config.Timeout.Microseconds(), 10))
}
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)
}