Browse Source

- Fix camera stream stopping after a few minutes
- Backend:
- Increase ffmpeg stdout read timeout from 10s to 30s
- Add ffmpeg RTSP stability options: -timeout, -buffer_size, -max_delay
- Frontend:
- Add auto-reconnection with exponential backoff (2s→30s max)
- Show reconnection UI with countdown and attempt counter
- Maximum 5 reconnection attempts before showing error
- "Reconnect now" button to skip countdown
- Reset reconnection state on manual refresh or mode switch

maziggy 5 tháng trước cách đây
mục cha
commit
530a7a46

+ 1 - 0
CHANGELOG.md

@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Fixed
 - **Notification module** - Fixed bug where notifications were sent even when printer was offline.
 - **Attachment uploads** - Fixed file attachments not persisting due to SQLAlchemy JSON column mutation detection.
+- **Camera stream stability** - Fixed stream stopping after a few minutes by increasing ffmpeg read timeout (10s→30s), adding buffer options, and implementing auto-reconnection with exponential backoff in the frontend.
 
 ## [0.1.5] - 2025-12-19
 

+ 11 - 2
backend/app/api/routes/camera.py

@@ -165,6 +165,9 @@ async def generate_rtsp_mjpeg_stream(
     # ffmpeg command to output MJPEG stream to stdout
     # -rtsp_transport tcp: Use TCP for reliability
     # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
+    # -timeout: Connection timeout in microseconds (30 seconds)
+    # -buffer_size: Larger buffer for network jitter
+    # -max_delay: Maximum demuxing delay
     # -f mjpeg: Output as MJPEG
     # -q:v 5: Quality (lower = better, 2-10 is good range)
     # -r: Output framerate
@@ -174,6 +177,12 @@ async def generate_rtsp_mjpeg_stream(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
+        "-timeout",
+        "30000000",  # 30 seconds in microseconds
+        "-buffer_size",
+        "1024000",  # 1MB buffer
+        "-max_delay",
+        "500000",  # 0.5 seconds max delay
         "-i",
         camera_url,
         "-f",
@@ -226,8 +235,8 @@ async def generate_rtsp_mjpeg_stream(
                 break
 
             try:
-                # Read chunk from ffmpeg
-                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=10.0)
+                # Read chunk from ffmpeg - use longer timeout for network hiccups
+                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
 
                 if not chunk:
                     logger.warning("Camera stream ended (no more data)")

+ 120 - 6
frontend/src/pages/CameraPage.tsx

@@ -1,9 +1,13 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff } from 'lucide-react';
 import { api } from '../api/client';
 
+const MAX_RECONNECT_ATTEMPTS = 5;
+const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
+const MAX_RECONNECT_DELAY = 30000; // 30 seconds
+
 export function CameraPage() {
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
@@ -14,8 +18,13 @@ export function CameraPage() {
   const [imageKey, setImageKey] = useState(Date.now());
   const [transitioning, setTransitioning] = useState(false);
   const [isFullscreen, setIsFullscreen] = useState(false);
+  const [reconnectAttempts, setReconnectAttempts] = useState(0);
+  const [isReconnecting, setIsReconnecting] = useState(false);
+  const [reconnectCountdown, setReconnectCountdown] = useState(0);
   const imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
+  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
   // Fetch printer info for the title
   const { data: printer } = useQuery({
@@ -91,14 +100,84 @@ export function CameraPage() {
     return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
   }, []);
 
+  // Clean up reconnect timers on unmount
+  useEffect(() => {
+    return () => {
+      if (reconnectTimerRef.current) {
+        clearTimeout(reconnectTimerRef.current);
+      }
+      if (countdownIntervalRef.current) {
+        clearInterval(countdownIntervalRef.current);
+      }
+    };
+  }, []);
+
+  // Auto-reconnect logic
+  const attemptReconnect = useCallback(() => {
+    if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+      setIsReconnecting(false);
+      setStreamError(true);
+      return;
+    }
+
+    // Calculate delay with exponential backoff
+    const delay = Math.min(
+      INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
+      MAX_RECONNECT_DELAY
+    );
+
+    setIsReconnecting(true);
+    setReconnectCountdown(Math.ceil(delay / 1000));
+
+    // Countdown timer
+    countdownIntervalRef.current = setInterval(() => {
+      setReconnectCountdown((prev) => {
+        if (prev <= 1) {
+          if (countdownIntervalRef.current) {
+            clearInterval(countdownIntervalRef.current);
+          }
+          return 0;
+        }
+        return prev - 1;
+      });
+    }, 1000);
+
+    // Reconnect after delay
+    reconnectTimerRef.current = setTimeout(() => {
+      setReconnectAttempts((prev) => prev + 1);
+      setIsReconnecting(false);
+      setStreamLoading(true);
+      setStreamError(false);
+      if (imgRef.current) {
+        imgRef.current.src = '';
+      }
+      setImageKey(Date.now());
+    }, delay);
+  }, [reconnectAttempts]);
+
   const handleStreamError = () => {
-    setStreamError(true);
     setStreamLoading(false);
+
+    // Only auto-reconnect for live stream mode
+    if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+      attemptReconnect();
+    } else {
+      setStreamError(true);
+    }
   };
 
   const handleStreamLoad = () => {
     setStreamLoading(false);
     setStreamError(false);
+    // Reset reconnect attempts on successful connection
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
   };
 
   const stopStream = () => {
@@ -112,6 +191,15 @@ export function CameraPage() {
     setTransitioning(true);
     setStreamLoading(true);
     setStreamError(false);
+    // Reset reconnect state on mode switch
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
 
     if (imgRef.current) {
       imgRef.current.src = '';
@@ -134,6 +222,15 @@ export function CameraPage() {
     setTransitioning(true);
     setStreamLoading(true);
     setStreamError(false);
+    // Reset reconnect state on manual refresh
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
 
     if (imgRef.current) {
       imgRef.current.src = '';
@@ -165,7 +262,7 @@ export function CameraPage() {
       ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
       : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
 
-  const isDisabled = streamLoading || transitioning;
+  const isDisabled = streamLoading || transitioning || isReconnecting;
 
   if (!id) {
     return (
@@ -234,7 +331,7 @@ export function CameraPage() {
       {/* Video area */}
       <div className="flex-1 flex items-center justify-center p-2">
         <div className="relative w-full h-full flex items-center justify-center">
-          {(streamLoading || transitioning) && (
+          {(streamLoading || transitioning) && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
               <div className="text-center">
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
@@ -244,7 +341,24 @@ export function CameraPage() {
               </div>
             </div>
           )}
-          {streamError && (
+          {isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
+              <div className="text-center p-4">
+                <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
+                <p className="text-white mb-2">Connection lost</p>
+                <p className="text-sm text-bambu-gray mb-3">
+                  Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
+                </p>
+                <button
+                  onClick={refresh}
+                  className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
+                >
+                  Reconnect now
+                </button>
+              </div>
+            </div>
+          )}
+          {streamError && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-4">
                 <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-D3FBMKgQ.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BB4adzNL.js"></script>
+    <script type="module" crossorigin src="/assets/index-D3FBMKgQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Cwam9OQ3.css">
   </head>
   <body>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác