Browse Source

fix(camera): honor ?fps=N URL parameter on /camera/<id>

  CameraPage hard-coded fps=15 in the stream URL and never read the
  URL query string, so /camera/1?fps=5 and similar diagnostic URLs
  were silent no-ops. Sibling StreamOverlayPage already honored ?fps=;
  this brings CameraPage to parity.

  - Read fps via useSearchParams, default 15, clamp 1-30, fallback to
    15 on non-numeric input (mirrors StreamOverlayPage's parser)
  - Thread the parsed value into the stream URL builder
  - 5 new tests in CameraPage.test.tsx pinning default, honored value,
    clamp-above-30, clamp-below-1, and non-numeric fallback

  Surfaced while triaging #1131 (H2D camera freeze). Independent of
  the underlying freeze investigation; restores the diagnostic knob
  the issue thread was relying on.
maziggy 1 month ago
parent
commit
0334f64e37

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 49 - 2
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -32,7 +32,7 @@ const mockPrinter = {
 };
 
 // Custom render for CameraPage which needs specific route params
-function renderCameraPage(printerId: number) {
+function renderCameraPage(printerId: number, search = '') {
   const queryClient = new QueryClient({
     defaultOptions: {
       queries: { retry: false, gcTime: 0 },
@@ -43,7 +43,7 @@ function renderCameraPage(printerId: number) {
   return rtlRender(
     <QueryClientProvider client={queryClient}>
       <I18nextProvider i18n={i18n}>
-        <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
+        <MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
           <ThemeProvider>
             <AuthProvider>
               <ToastProvider>
@@ -177,6 +177,53 @@ describe('CameraPage', () => {
     });
   });
 
+  describe('fps URL parameter (#1131)', () => {
+    it('defaults to fps=15 when no query parameter is provided', async () => {
+      renderCameraPage(1);
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('fps=15');
+      });
+    });
+
+    it('honors fps query parameter from URL', async () => {
+      renderCameraPage(1, '?fps=5');
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('fps=5');
+      });
+    });
+
+    it('clamps fps above 30 to 30', async () => {
+      renderCameraPage(1, '?fps=60');
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('fps=30');
+      });
+    });
+
+    it('clamps fps below 1 to 1', async () => {
+      renderCameraPage(1, '?fps=0');
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('fps=1');
+      });
+    });
+
+    it('falls back to 15 for non-numeric fps', async () => {
+      renderCameraPage(1, '?fps=invalid');
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('fps=15');
+      });
+    });
+  });
+
   describe('invalid printer', () => {
     it('shows invalid printer message for ID 0', async () => {
       renderCameraPage(0);

+ 5 - 2
frontend/src/pages/CameraPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
-import { useParams } from 'react-router-dom';
+import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
@@ -22,6 +22,9 @@ export function CameraPage() {
   const { hasPermission, authEnabled, user } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
+  const [searchParams] = useSearchParams();
+  const fpsParam = parseInt(searchParams.get('fps') || '15', 10);
+  const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
 
   // Subscribe to the stream-token query so this page re-renders once the token
   // arrives. useStreamTokenSync (mounted in App) already owns the fetch; this
@@ -599,7 +602,7 @@ export function CameraPage() {
   const currentUrl = transitioning || waitingForStreamToken
     ? ''
     : streamMode === 'stream'
-      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
+      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=${fps}&t=${imageKey}`)
       : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
 
   const isDisabled = streamLoading || transitioning || isReconnecting;

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DCOWEBQL.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DJk_kz74.js"></script>
+    <script type="module" crossorigin src="/assets/index-DCOWEBQL.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff