Adds a 测试连接摄像头 button next to save; it builds the RTSP URL from the current form fields and runs ffprobe (8s timeout) off the UI thread, then shows a result popup (success WxH@fps, or a redacted failure). Exports config.BuildRTSPURL and source.Probe/Redact for it. Cross-compiles for Windows; config/source tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
9.2 KiB
Go
317 lines
9.2 KiB
Go
// 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
|
||
}
|
||
}
|
||
|
||
// Probe attempts to read stream metadata once, for a settings "test connection".
|
||
func Probe(ctx context.Context, config Config) (Metadata, error) { return probe(ctx, config) }
|
||
|
||
// Redact removes the source URL/credentials from text for safe display.
|
||
func Redact(text, sourceURL string) string { return redact(text, sourceURL) }
|
||
|
||
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)
|
||
}
|