mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
- 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:
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||||||
### Fixed
|
### Fixed
|
||||||
- **Notification module** - Fixed bug where notifications were sent even when printer was offline.
|
- **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.
|
- **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
|
## [0.1.5] - 2025-12-19
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ async def generate_rtsp_mjpeg_stream(
|
|||||||
# ffmpeg command to output MJPEG stream to stdout
|
# ffmpeg command to output MJPEG stream to stdout
|
||||||
# -rtsp_transport tcp: Use TCP for reliability
|
# -rtsp_transport tcp: Use TCP for reliability
|
||||||
# -rtsp_flags prefer_tcp: Prefer TCP for RTSP
|
# -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
|
# -f mjpeg: Output as MJPEG
|
||||||
# -q:v 5: Quality (lower = better, 2-10 is good range)
|
# -q:v 5: Quality (lower = better, 2-10 is good range)
|
||||||
# -r: Output framerate
|
# -r: Output framerate
|
||||||
@@ -174,6 +177,12 @@ async def generate_rtsp_mjpeg_stream(
|
|||||||
"tcp",
|
"tcp",
|
||||||
"-rtsp_flags",
|
"-rtsp_flags",
|
||||||
"prefer_tcp",
|
"prefer_tcp",
|
||||||
|
"-timeout",
|
||||||
|
"30000000", # 30 seconds in microseconds
|
||||||
|
"-buffer_size",
|
||||||
|
"1024000", # 1MB buffer
|
||||||
|
"-max_delay",
|
||||||
|
"500000", # 0.5 seconds max delay
|
||||||
"-i",
|
"-i",
|
||||||
camera_url,
|
camera_url,
|
||||||
"-f",
|
"-f",
|
||||||
@@ -226,8 +235,8 @@ async def generate_rtsp_mjpeg_stream(
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read chunk from ffmpeg
|
# Read chunk from ffmpeg - use longer timeout for network hiccups
|
||||||
chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=10.0)
|
chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
|
||||||
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
logger.warning("Camera stream ended (no more data)")
|
logger.warning("Camera stream ended (no more data)")
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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';
|
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() {
|
export function CameraPage() {
|
||||||
const { printerId } = useParams<{ printerId: string }>();
|
const { printerId } = useParams<{ printerId: string }>();
|
||||||
const id = parseInt(printerId || '0', 10);
|
const id = parseInt(printerId || '0', 10);
|
||||||
@@ -14,8 +18,13 @@ export function CameraPage() {
|
|||||||
const [imageKey, setImageKey] = useState(Date.now());
|
const [imageKey, setImageKey] = useState(Date.now());
|
||||||
const [transitioning, setTransitioning] = useState(false);
|
const [transitioning, setTransitioning] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = 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 imgRef = useRef<HTMLImageElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(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
|
// Fetch printer info for the title
|
||||||
const { data: printer } = useQuery({
|
const { data: printer } = useQuery({
|
||||||
@@ -91,14 +100,84 @@ export function CameraPage() {
|
|||||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
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 = () => {
|
const handleStreamError = () => {
|
||||||
setStreamError(true);
|
|
||||||
setStreamLoading(false);
|
setStreamLoading(false);
|
||||||
|
|
||||||
|
// Only auto-reconnect for live stream mode
|
||||||
|
if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
attemptReconnect();
|
||||||
|
} else {
|
||||||
|
setStreamError(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStreamLoad = () => {
|
const handleStreamLoad = () => {
|
||||||
setStreamLoading(false);
|
setStreamLoading(false);
|
||||||
setStreamError(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 = () => {
|
const stopStream = () => {
|
||||||
@@ -112,6 +191,15 @@ export function CameraPage() {
|
|||||||
setTransitioning(true);
|
setTransitioning(true);
|
||||||
setStreamLoading(true);
|
setStreamLoading(true);
|
||||||
setStreamError(false);
|
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) {
|
if (imgRef.current) {
|
||||||
imgRef.current.src = '';
|
imgRef.current.src = '';
|
||||||
@@ -134,6 +222,15 @@ export function CameraPage() {
|
|||||||
setTransitioning(true);
|
setTransitioning(true);
|
||||||
setStreamLoading(true);
|
setStreamLoading(true);
|
||||||
setStreamError(false);
|
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) {
|
if (imgRef.current) {
|
||||||
imgRef.current.src = '';
|
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/stream?fps=10&t=${imageKey}`
|
||||||
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
|
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
|
||||||
|
|
||||||
const isDisabled = streamLoading || transitioning;
|
const isDisabled = streamLoading || transitioning || isReconnecting;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
@@ -234,7 +331,7 @@ export function CameraPage() {
|
|||||||
{/* Video area */}
|
{/* Video area */}
|
||||||
<div className="flex-1 flex items-center justify-center p-2">
|
<div className="flex-1 flex items-center justify-center p-2">
|
||||||
<div className="relative w-full h-full flex items-center justify-center">
|
<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="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
|
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
|
||||||
@@ -244,7 +341,24 @@ export function CameraPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="absolute inset-0 flex items-center justify-center bg-black z-10">
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4">
|
||||||
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
|
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<!-- Splash screens for iOS -->
|
<!-- Splash screens for iOS -->
|
||||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cwam9OQ3.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user