camera_stream.go 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. package parking
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/url"
  9. "os"
  10. "os/exec"
  11. "path/filepath"
  12. "strings"
  13. "sync"
  14. "time"
  15. "github.com/gofrs/uuid/v5"
  16. "go.uber.org/zap"
  17. "wails-app/internal/dao"
  18. "wails-app/internal/global"
  19. )
  20. const (
  21. cameraStreamTokenTTL = 2 * time.Minute
  22. // A stream has to emit data recently before it is considered online. The
  23. // short grace window prevents an active MJPEG stream from briefly flipping
  24. // to offline between browser refreshes.
  25. cameraStreamOnlineTTL = 15 * time.Second
  26. )
  27. type cameraStreamToken struct {
  28. DeviceCode string
  29. ExpiresAt time.Time
  30. }
  31. var cameraStreamTokens sync.Map
  32. var cameraLastFrameAt sync.Map
  33. // cameraMJPEGHub keeps one FFmpeg source per camera. Browser pages can create
  34. // multiple MJPEG connections during refreshes, route transitions, or when the
  35. // workbench is open in more than one window; those connections must not each
  36. // open a separate RTSP session and transcoder.
  37. var cameraMJPEGHub = newCameraStreamHub()
  38. type cameraStreamConfig struct {
  39. DeviceCode string
  40. SourceURL string
  41. FFmpegPath string
  42. FPS int
  43. Quality int
  44. }
  45. type cameraStreamSubscription struct {
  46. frames <-chan []byte
  47. unsubscribe func()
  48. }
  49. type cameraStreamHub struct {
  50. mu sync.Mutex
  51. sessions map[string]*cameraStreamSession
  52. }
  53. type cameraStreamSession struct {
  54. hub *cameraStreamHub
  55. config cameraStreamConfig
  56. ctx context.Context
  57. cancel context.CancelFunc
  58. mu sync.Mutex
  59. nextID uint64
  60. subscribers map[uint64]chan []byte
  61. }
  62. func newCameraStreamHub() *cameraStreamHub {
  63. return &cameraStreamHub{sessions: make(map[string]*cameraStreamSession)}
  64. }
  65. func newCameraStreamSession(hub *cameraStreamHub, config cameraStreamConfig) *cameraStreamSession {
  66. ctx, cancel := context.WithCancel(context.Background())
  67. return &cameraStreamSession{
  68. hub: hub,
  69. config: config,
  70. ctx: ctx,
  71. cancel: cancel,
  72. subscribers: make(map[uint64]chan []byte),
  73. }
  74. }
  75. func (h *cameraStreamHub) subscribe(config cameraStreamConfig) *cameraStreamSubscription {
  76. h.mu.Lock()
  77. session := h.sessions[config.DeviceCode]
  78. created := session == nil
  79. if session == nil {
  80. session = newCameraStreamSession(h, config)
  81. h.sessions[config.DeviceCode] = session
  82. }
  83. id, frames := session.addSubscriber()
  84. if created {
  85. go session.run()
  86. }
  87. h.mu.Unlock()
  88. return &cameraStreamSubscription{
  89. frames: frames,
  90. unsubscribe: func() {
  91. session.removeSubscriber(id)
  92. },
  93. }
  94. }
  95. func (h *cameraStreamHub) removeSession(session *cameraStreamSession) {
  96. h.mu.Lock()
  97. if h.sessions[session.config.DeviceCode] == session {
  98. delete(h.sessions, session.config.DeviceCode)
  99. }
  100. h.mu.Unlock()
  101. }
  102. func (h *cameraStreamHub) stop(deviceCode string) {
  103. h.mu.Lock()
  104. session := h.sessions[deviceCode]
  105. if session != nil {
  106. delete(h.sessions, deviceCode)
  107. }
  108. h.mu.Unlock()
  109. if session != nil {
  110. session.cancel()
  111. }
  112. }
  113. func (s *cameraStreamSession) addSubscriber() (uint64, <-chan []byte) {
  114. s.mu.Lock()
  115. defer s.mu.Unlock()
  116. s.nextID++
  117. frames := make(chan []byte, 64)
  118. s.subscribers[s.nextID] = frames
  119. return s.nextID, frames
  120. }
  121. func (s *cameraStreamSession) removeSubscriber(id uint64) {
  122. s.mu.Lock()
  123. delete(s.subscribers, id)
  124. empty := len(s.subscribers) == 0
  125. s.mu.Unlock()
  126. if empty {
  127. s.hub.removeSession(s)
  128. s.cancel()
  129. }
  130. }
  131. func (s *cameraStreamSession) publish(frame []byte) {
  132. s.mu.Lock()
  133. defer s.mu.Unlock()
  134. for _, subscriber := range s.subscribers {
  135. select {
  136. case subscriber <- frame:
  137. default:
  138. // A slow client can resynchronize at the next MJPEG boundary. Never
  139. // let it block the camera source or every other subscribed browser.
  140. }
  141. }
  142. }
  143. func (s *cameraStreamSession) closeSubscribers() {
  144. s.mu.Lock()
  145. for id, subscriber := range s.subscribers {
  146. close(subscriber)
  147. delete(s.subscribers, id)
  148. }
  149. s.mu.Unlock()
  150. }
  151. func (s *cameraStreamSession) run() {
  152. defer s.closeSubscribers()
  153. defer s.hub.removeSession(s)
  154. args := []string{"-hide_banner", "-loglevel", "error"}
  155. if strings.HasPrefix(strings.ToLower(s.config.SourceURL), "rtsp://") {
  156. args = append(args, "-rtsp_transport", "tcp")
  157. }
  158. args = append(args,
  159. "-i", s.config.SourceURL,
  160. "-an",
  161. "-vf", fmt.Sprintf("fps=%d", s.config.FPS),
  162. "-q:v", fmt.Sprintf("%d", s.config.Quality),
  163. "-f", "mpjpeg",
  164. "pipe:1",
  165. )
  166. cmd := exec.CommandContext(s.ctx, s.config.FFmpegPath, args...)
  167. if global.GVA_LOG != nil {
  168. global.GVA_LOG.Info("启动共享摄像头 FFmpeg 转码", zap.String("device_code", s.config.DeviceCode), zap.String("ffmpeg", s.config.FFmpegPath), zap.String("source", redactCameraURL(s.config.SourceURL)))
  169. }
  170. stdout, err := cmd.StdoutPipe()
  171. if err != nil {
  172. if global.GVA_LOG != nil {
  173. global.GVA_LOG.Error("创建摄像头 FFmpeg 输出失败", zap.String("device_code", s.config.DeviceCode), zap.Error(err))
  174. }
  175. return
  176. }
  177. cmd.Stderr = io.Discard
  178. if err := cmd.Start(); err != nil {
  179. if global.GVA_LOG != nil {
  180. global.GVA_LOG.Error("摄像头 FFmpeg 启动失败", zap.String("device_code", s.config.DeviceCode), zap.String("ffmpeg", s.config.FFmpegPath), zap.Error(err))
  181. }
  182. return
  183. }
  184. buffer := make([]byte, 32*1024)
  185. for {
  186. n, readErr := stdout.Read(buffer)
  187. if n > 0 {
  188. frame := append([]byte(nil), buffer[:n]...)
  189. markCameraStreamFrame(s.config.DeviceCode)
  190. s.publish(frame)
  191. }
  192. if readErr != nil {
  193. if !errors.Is(readErr, io.EOF) && s.ctx.Err() == nil && global.GVA_LOG != nil {
  194. global.GVA_LOG.Warn("读取摄像头 FFmpeg 输出失败", zap.String("device_code", s.config.DeviceCode), zap.Error(readErr))
  195. }
  196. break
  197. }
  198. }
  199. if err := cmd.Wait(); err != nil && s.ctx.Err() == nil && global.GVA_LOG != nil {
  200. global.GVA_LOG.Error("摄像头 FFmpeg 转码进程退出", zap.String("device_code", s.config.DeviceCode), zap.Error(err))
  201. }
  202. }
  203. func markCameraStreamFrame(deviceCode string) {
  204. now := time.Now()
  205. previous, hadPrevious := cameraLastFrameAt.Load(deviceCode)
  206. cameraLastFrameAt.Store(deviceCode, now)
  207. if (!hadPrevious || now.Sub(previous.(time.Time)) > cameraStreamOnlineTTL) && global.GVA_LOG != nil {
  208. global.GVA_LOG.Info("摄像头视频流已在线", zap.String("device_code", deviceCode))
  209. }
  210. }
  211. func hasRecentCameraStreamFrame(deviceCode string) bool {
  212. value, ok := cameraLastFrameAt.Load(deviceCode)
  213. if !ok {
  214. return false
  215. }
  216. lastFrameAt, ok := value.(time.Time)
  217. return ok && time.Since(lastFrameAt) <= cameraStreamOnlineTTL
  218. }
  219. func isSystemMJPEG(protocol string) bool {
  220. protocol = strings.ToLower(strings.TrimSpace(protocol))
  221. return protocol == "system-mjpeg" || protocol == "rtsp" || protocol == "system"
  222. }
  223. func issueCameraStreamToken(deviceCode string) string {
  224. for {
  225. id, err := uuid.NewV4()
  226. if err != nil {
  227. continue
  228. }
  229. token := id.String()
  230. cameraStreamTokens.Store(token, cameraStreamToken{
  231. DeviceCode: deviceCode,
  232. ExpiresAt: time.Now().Add(cameraStreamTokenTTL),
  233. })
  234. // 每次页面刷新都会签发新令牌;过期项只在验证时才删除,长期运行会
  235. // 无限累积,这里在签发时顺手清理。
  236. now := time.Now()
  237. cameraStreamTokens.Range(func(key, value any) bool {
  238. if entry, ok := value.(cameraStreamToken); ok && now.After(entry.ExpiresAt) {
  239. cameraStreamTokens.Delete(key)
  240. }
  241. return true
  242. })
  243. return token
  244. }
  245. }
  246. func validateCameraStreamToken(deviceCode, token string) bool {
  247. value, ok := cameraStreamTokens.Load(token)
  248. if !ok {
  249. return false
  250. }
  251. entry := value.(cameraStreamToken)
  252. if entry.DeviceCode != deviceCode || time.Now().After(entry.ExpiresAt) {
  253. cameraStreamTokens.Delete(token)
  254. return false
  255. }
  256. return true
  257. }
  258. // StreamCameraMJPEG 在系统端把摄像头原始 RTSP/HTTP 流转换为 multipart MJPEG。
  259. // 同一摄像头的浏览器连接订阅同一个 FFmpeg 会话,避免重复转码耗尽主机资源。
  260. func (s *PassageService) StreamCameraMJPEG(deviceCode, token string, writer http.ResponseWriter, ctx context.Context) error {
  261. if !validateCameraStreamToken(deviceCode, token) {
  262. return errors.New("摄像头播放令牌无效或已过期")
  263. }
  264. if global.GVA_DB == nil {
  265. return errors.New("数据库未初始化")
  266. }
  267. var camera dao.Camera
  268. if err := global.GVA_DB.Where("device_code = ? AND is_active = ?", deviceCode, true).First(&camera).Error; err != nil {
  269. return fmt.Errorf("摄像头不存在或未启用: %w", err)
  270. }
  271. if !isSystemMJPEG(camera.StreamProtocol) {
  272. return errors.New("当前摄像头未启用系统侧转换")
  273. }
  274. sourceURL := strings.TrimSpace(camera.SourceURL)
  275. if sourceURL == "" {
  276. sourceURL = strings.TrimSpace(camera.StreamURL)
  277. }
  278. if sourceURL == "" {
  279. return errors.New("摄像头未配置原始视频地址")
  280. }
  281. config := cameraStreamConfig{
  282. DeviceCode: deviceCode,
  283. SourceURL: sourceURL,
  284. FFmpegPath: resolveFFmpegPath(),
  285. FPS: configuredMJPEGFPS(),
  286. Quality: configuredJPEGQuality(),
  287. }
  288. subscription := cameraMJPEGHub.subscribe(config)
  289. defer subscription.unsubscribe()
  290. writer.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=ffmpeg")
  291. writer.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
  292. writer.Header().Set("X-Content-Type-Options", "nosniff")
  293. if flusher, ok := writer.(http.Flusher); ok {
  294. flusher.Flush()
  295. }
  296. for {
  297. select {
  298. case <-ctx.Done():
  299. return nil
  300. case frame, ok := <-subscription.frames:
  301. if !ok {
  302. return nil
  303. }
  304. if _, err := writer.Write(frame); err != nil {
  305. return nil
  306. }
  307. if flusher, ok := writer.(http.Flusher); ok {
  308. flusher.Flush()
  309. }
  310. }
  311. }
  312. }
  313. func resolveFFmpegPath() string {
  314. ffmpegPath := strings.TrimSpace(global.GVA_CONFIG.Camera.FFmpegPath)
  315. if ffmpegPath == "" {
  316. return "ffmpeg"
  317. }
  318. if info, err := os.Stat(ffmpegPath); err == nil && info.IsDir() {
  319. return filepath.Join(ffmpegPath, "ffmpeg.exe")
  320. }
  321. return ffmpegPath
  322. }
  323. func configuredMJPEGFPS() int {
  324. fps := global.GVA_CONFIG.Camera.MJPEGFPS
  325. if fps < 1 || fps > 25 {
  326. return 8
  327. }
  328. return fps
  329. }
  330. func configuredJPEGQuality() int {
  331. quality := global.GVA_CONFIG.Camera.JPEGQuality
  332. if quality < 2 || quality > 31 {
  333. return 5
  334. }
  335. return quality
  336. }
  337. func redactCameraURL(raw string) string {
  338. u, err := url.Parse(raw)
  339. if err != nil || u.User == nil {
  340. return raw
  341. }
  342. return strings.Replace(raw, u.User.String()+"@", "***@", 1)
  343. }