2026-07-22 21:15:25 +08:00
|
|
|
|
// Package source owns the FFprobe/FFmpeg stream lifecycle for one camera.
|
|
|
|
|
|
package source
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-23 20:31:00 +08:00
|
|
|
|
"bytes"
|
2026-07-22 21:15:25 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-07-23 20:31:00 +08:00
|
|
|
|
"log"
|
2026-07-22 21:15:25 +08:00
|
|
|
|
"os/exec"
|
2026-07-23 20:31:00 +08:00
|
|
|
|
"regexp"
|
2026-07-22 21:15:25 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-23 20:31:00 +08:00
|
|
|
|
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>")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 21:15:25 +08:00
|
|
|
|
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)
|
2026-07-23 20:31:00 +08:00
|
|
|
|
command := exec.CommandContext(ctx, config.FFprobePath, args...)
|
|
|
|
|
|
var stderr bytes.Buffer
|
|
|
|
|
|
command.Stderr = &stderr
|
|
|
|
|
|
output, err := command.Output()
|
2026-07-22 21:15:25 +08:00
|
|
|
|
if err != nil {
|
2026-07-23 20:31:00 +08:00
|
|
|
|
log.Printf("ffprobe 探测失败:%s(%v)", strings.TrimSpace(redact(stderr.String(), config.SourceURL)), err)
|
2026-07-22 21:15:25 +08:00
|
|
|
|
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...)
|
2026-07-23 20:31:00 +08:00
|
|
|
|
var stderr bytes.Buffer
|
|
|
|
|
|
command.Stderr = &stderr
|
2026-07-22 21:15:25 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-07-23 20:31:00 +08:00
|
|
|
|
log.Printf("ffmpeg 解码停止:%s(%v)", strings.TrimSpace(redact(stderr.String(), config.SourceURL)), err)
|
2026-07-22 21:15:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-23 20:36:51 +08:00
|
|
|
|
// 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.
|
2026-07-22 21:15:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|