- 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
This commit is contained in:
maziggy 2025-12-28 13:16:14 +01:00
parent b1be35ba8c
commit 530a7a4608
5 changed files with 528 additions and 404 deletions

View file

@ -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

View file

@ -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)")

View file

@ -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" />

File diff suppressed because one or more lines are too long

View file

@ -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>