Prechádzať zdrojové kódy

Renew overlay camera connections after silent stalls (#3213)

maziggy 16 hodín pred
rodič
commit
5770ab88d7

+ 77 - 0
frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts

@@ -0,0 +1,77 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useOverlayCameraRecovery } from '../../hooks/useOverlayCameraRecovery';
+
+describe('useOverlayCameraRecovery', () => {
+  beforeEach(() => vi.useFakeTimers());
+  afterEach(() => vi.useRealTimers());
+
+  it('renews the connection periodically without an error event', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    act(() => vi.advanceTimersByTime(59_999));
+    expect(result.current.imageKey).toBe(initial);
+    act(() => vi.advanceTimersByTime(1));
+    expect(result.current.imageKey).not.toBe(initial);
+    act(() => vi.advanceTimersByTime(60_000));
+    expect(result.current.imageKey).toBe(initial + 2);
+  });
+
+  it('deduplicates image errors and retries after three seconds', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    act(() => { result.current.handleStreamError(); result.current.handleStreamError(); });
+    act(() => vi.advanceTimersByTime(2_999));
+    expect(result.current.imageKey).toBe(initial);
+    act(() => vi.advanceTimersByTime(1));
+    expect(result.current.imageKey).toBe(initial + 1);
+  });
+
+  it('gives a connection opened by an error retry a full renewal period', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    act(() => vi.advanceTimersByTime(56_000));
+    act(() => result.current.handleStreamError());
+    act(() => vi.advanceTimersByTime(3_000));
+    expect(result.current.imageKey).toBe(initial + 1);
+    act(() => vi.advanceTimersByTime(59_999));
+    expect(result.current.imageKey).toBe(initial + 1);
+    act(() => vi.advanceTimersByTime(1));
+    expect(result.current.imageKey).toBe(initial + 2);
+  });
+
+  it('cancels a pending error retry when periodic renewal happens first', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    act(() => vi.advanceTimersByTime(59_000));
+    act(() => result.current.handleStreamError());
+    act(() => vi.advanceTimersByTime(1_000));
+    expect(result.current.imageKey).toBe(initial + 1);
+    act(() => vi.advanceTimersByTime(3_000));
+    expect(result.current.imageKey).toBe(initial + 1);
+  });
+
+  it('does not schedule recovery while the camera is disabled', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(false));
+    const initial = result.current.imageKey;
+    act(() => result.current.handleStreamError());
+    expect(vi.getTimerCount()).toBe(0);
+    act(() => vi.advanceTimersByTime(120_000));
+    expect(result.current.imageKey).toBe(initial);
+  });
+
+  it('cancels all recovery timers when hidden or unmounted', () => {
+    const { result, rerender, unmount } = renderHook(
+      ({ enabled }) => useOverlayCameraRecovery(enabled), { initialProps: { enabled: true } },
+    );
+    act(() => result.current.handleStreamError());
+    rerender({ enabled: false });
+    expect(vi.getTimerCount()).toBe(0);
+    act(() => result.current.handleStreamError());
+    expect(vi.getTimerCount()).toBe(0);
+    rerender({ enabled: true });
+    act(() => result.current.handleStreamError());
+    unmount();
+    expect(vi.getTimerCount()).toBe(0);
+  });
+});

+ 27 - 1
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { act, screen, waitFor, render as rtlRender } from '@testing-library/react';
 import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
@@ -106,6 +106,32 @@ describe('StreamOverlayPage', () => {
     vi.unstubAllGlobals();
   });
 
+  it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
+    server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
+      ...mockStatusIdle, camera_rotation: 90,
+    })));
+    const timers = vi.spyOn(globalThis, 'setTimeout');
+    const view = renderOverlayPage(1, `?token=overlay-test&fps=7${artwork}`);
+    try {
+      const image = await screen.findByAltText('Camera stream');
+      const source = image.getAttribute('src');
+      expect(source).toContain('token=overlay-test');
+      const renewal = timers.mock.calls.find(([, delay]) => delay === 60_000)?.[0];
+      expect(renewal).toBeTypeOf('function');
+      if (typeof renewal !== 'function') throw new Error('Missing camera renewal timer');
+      act(() => renewal());
+      const renewed = screen.getByAltText('Camera stream');
+      expect(renewed).not.toBe(image);
+      expect(renewed.getAttribute('src')).not.toBe(source);
+      expect(renewed.getAttribute('src')).toContain('token=overlay-test');
+      expect(renewed.getAttribute('src')).toContain('fps=7');
+      expect(renewed.style.transform).toBe(artwork ? 'translate(-50%, -50%) rotate(90deg)' : 'rotate(90deg)');
+    } finally {
+      view.unmount();
+      timers.mockRestore();
+    }
+  });
+
   describe('updated artwork', () => {
     it('maps legacy model codes in version 2', async () => {
       server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));

+ 41 - 0
frontend/src/hooks/useOverlayCameraRecovery.ts

@@ -0,0 +1,41 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+// MJPEG <img> connections can stop delivering frames without firing onError.
+// Renew only the camera connection, keeping the overlay and its credentials.
+// This also renews healthy connections; it does not measure frame freshness.
+const STREAM_RENEWAL_MS = 60_000;
+const ERROR_RETRY_MS = 3_000;
+
+export function useOverlayCameraRecovery(enabled: boolean) {
+  const [imageKey, setImageKey] = useState(Date.now);
+  const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const renew = useCallback(() => {
+    if (retryTimer.current !== null) {
+      clearTimeout(retryTimer.current);
+      retryTimer.current = null;
+    }
+    setImageKey((key) => key + 1);
+  }, []);
+
+  useEffect(() => {
+    if (!enabled) return;
+    // Start a fresh deadline after each retry so a newly opened connection
+    // isn't immediately replaced by the previous connection's renewal timer.
+    const renewalTimer = setTimeout(renew, STREAM_RENEWAL_MS);
+    return () => {
+      clearTimeout(renewalTimer);
+      if (retryTimer.current !== null) {
+        clearTimeout(retryTimer.current);
+        retryTimer.current = null;
+      }
+    };
+  }, [enabled, imageKey, renew]);
+
+  const handleStreamError = useCallback(() => {
+    if (!enabled || retryTimer.current !== null) return;
+    retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
+  }, [enabled, renew]);
+
+  return { imageKey, handleStreamError };
+}

+ 3 - 9
frontend/src/pages/StreamOverlayPage.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -7,6 +7,7 @@ import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { mapModelCode } from '../utils/printerModel';
+import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
@@ -168,7 +169,6 @@ export function StreamOverlayPage() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const id = parseInt(printerId || '0', 10);
-  const [imageKey, setImageKey] = useState(Date.now());
 
   const config = useMemo(() => parseConfig(searchParams), [searchParams]);
   const sizes = getSizeClasses(config.size);
@@ -224,6 +224,7 @@ export function StreamOverlayPage() {
     config.showModel ? mapModelCode(printer?.model ?? null) : null,
   ].filter(Boolean).join(' · ');
   const status = kiosk ? overlay : statusData;
+  const { imageKey, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
   const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
 
   // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
@@ -292,13 +293,6 @@ export function StreamOverlayPage() {
     };
   }, [printer, t]);
 
-  // Refresh stream on error
-  const handleStreamError = () => {
-    setTimeout(() => {
-      setImageKey(Date.now());
-    }, 3000);
-  };
-
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">