Преглед изворни кода

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.
maziggy пре 3 месеци
родитељ
комит
001e328ce8

+ 6 - 0
CHANGELOG.md

@@ -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
README.md

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

+ 133 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

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

+ 1 - 1
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -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
frontend/src/pages/CameraPage.tsx

@@ -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
frontend/src/pages/StreamOverlayPage.tsx

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-CxQsg2-C.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-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>

Неке датотеке нису приказане због велике количине промена