mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user