Add configurable FPS and status-only mode to streaming overlay

- Add ?fps=N parameter to control camera frame rate (1-30, default 15)
- Add ?camera=false parameter for status-only overlay without camera feed
- Increase default camera FPS from 10 to 15 across all camera views
- Add comprehensive tests for new overlay parameters

Resolves user request on Issue #164 for higher FPS and status-only option.
This commit is contained in:
maziggy
2026-02-01 08:15:54 +01:00
parent 304157133c
commit 001e328ce8
8 changed files with 168 additions and 15 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.7b] - Not released
### Enhancements
- **Streaming Overlay Improvements** (Issue #164):
- **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15)
- **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background
- Increased default camera FPS from 10 to 15 for smoother video across all camera views
## [0.1.6-final] - 2026-01-31
### New Features
+1 -1
View File
@@ -59,7 +59,7 @@
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
- Fan status monitoring (part cooling, auxiliary, chamber)
@@ -207,6 +207,139 @@ describe('StreamOverlayPage', () => {
});
});
describe('FPS configuration', () => {
it('uses default FPS of 15 when not specified', async () => {
renderOverlayPage(1);
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=15');
});
});
it('uses custom FPS when specified in query params', async () => {
renderOverlayPage(1, '?fps=30');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=30');
});
});
it('clamps FPS to maximum of 30', async () => {
renderOverlayPage(1, '?fps=60');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=30');
});
});
it('clamps FPS to minimum of 1', async () => {
renderOverlayPage(1, '?fps=0');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=1');
});
});
it('handles invalid FPS value gracefully', async () => {
renderOverlayPage(1, '?fps=invalid');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
// Should fall back to default of 15
expect(img.src).toContain('fps=15');
});
});
});
describe('camera toggle (status-only mode)', () => {
it('shows camera by default', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
});
});
it('hides camera when camera=false', async () => {
renderOverlayPage(1, '?camera=false');
await waitFor(() => {
// Status should still be visible
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
});
// Camera should not be rendered
expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
});
it('hides camera when camera=0', async () => {
renderOverlayPage(1, '?camera=0');
await waitFor(() => {
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
});
expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
});
it('shows camera when camera=true', async () => {
renderOverlayPage(1, '?camera=true');
await waitFor(() => {
expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
});
});
it('shows camera when camera=1', async () => {
renderOverlayPage(1, '?camera=1');
await waitFor(() => {
expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
});
});
});
describe('combined parameters', () => {
it('supports fps and camera together', async () => {
renderOverlayPage(1, '?fps=25&camera=true');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=25');
});
});
it('supports status-only with custom size', async () => {
renderOverlayPage(1, '?camera=false&size=large');
await waitFor(() => {
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
});
expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
});
it('supports show parameter with fps', async () => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusPrinting);
})
);
renderOverlayPage(1, '?fps=20&show=progress');
await waitFor(() => {
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
expect(img.src).toContain('fps=20');
expect(screen.getByText('45%')).toBeInTheDocument();
});
});
});
describe('offline state', () => {
beforeEach(() => {
server.use(
@@ -461,7 +461,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
}
}, [isDragging, isResizing, dragOffset]);
const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`;
const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`;
return (
<div
+1 -1
View File
@@ -531,7 +531,7 @@ export function CameraPage() {
const currentUrl = transitioning
? ''
: streamMode === 'stream'
? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
const isDisabled = streamLoading || transitioning || isReconnecting;
+23 -9
View File
@@ -9,6 +9,8 @@ type OverlaySize = 'small' | 'medium' | 'large';
interface OverlayConfig {
size: OverlaySize;
fps: number;
showCamera: boolean;
showProgress: boolean;
showLayers: boolean;
showEta: boolean;
@@ -20,8 +22,18 @@ interface OverlayConfig {
function parseConfig(params: URLSearchParams): OverlayConfig {
const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
// Parse FPS (default 15, max 30, min 1)
const fpsParam = parseInt(params.get('fps') || '15', 10);
const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
// Parse camera toggle (default true, set camera=false to hide)
const cameraParam = params.get('camera');
const showCamera = cameraParam !== 'false' && cameraParam !== '0';
return {
size: (params.get('size') as OverlaySize) || 'medium',
fps,
showCamera,
showProgress: show.includes('progress'),
showLayers: show.includes('layers'),
showEta: show.includes('eta'),
@@ -191,18 +203,20 @@ export function StreamOverlayPage() {
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
const progress = status.progress || 0;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
return (
<div className="min-h-screen bg-black relative overflow-hidden">
{/* Camera feed - fullscreen background */}
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
{/* Camera feed - fullscreen background (optional) */}
{config.showCamera && (
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
)}
{/* Bambuddy logo - top right */}
<a
File diff suppressed because one or more lines are too long
+1 -1
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-DAZbTvYK.js"></script>
<script type="module" crossorigin src="/assets/index-CxQsg2-C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-d5ZW47G8.css">
</head>
<body>