Просмотр исходного кода

Streaming overlay page for OBS integration (#164)

Add a dedicated overlay page at /overlay/:printerId that combines
camera feed with real-time print status for live streaming use cases.

Features:
- Camera feed as fullscreen background
- Status overlay at bottom with gradient for readability
- Real-time updates via WebSocket with polling fallback
- Customizable via query params (?size=large&show=progress,eta)

Closes #164
maziggy 3 месяцев назад
Родитель
Сommit
534f2dcc91

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 ## [0.1.6-final] - Not released
 
 
 ### New Features
 ### New Features
+- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
+  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
+  - Real-time print progress, ETA, layer count, and filename display
+  - Bambuddy logo branding (links to GitHub)
+  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
+  - No authentication required - designed for OBS browser source embedding
+  - Gradient overlay at bottom for readable text over camera feed
+  - Auto-reconnect on camera stream errors
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions
   - Useful for users who prefer to manage firmware manually or have network restrictions

+ 1 - 0
README.md

@@ -58,6 +58,7 @@
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - 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)
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)

+ 4 - 0
frontend/src/App.tsx

@@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
+import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { LoginPage } from './pages/LoginPage';
@@ -109,6 +110,9 @@ function App() {
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 <Route path="/camera/:printerId" element={<CameraPage />} />
                 <Route path="/camera/:printerId" element={<CameraPage />} />
 
 
+                {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
+                <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+
                 {/* Main app with WebSocket for real-time updates */}
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
                   <Route index element={<PrintersPage />} />

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

@@ -0,0 +1,230 @@
+/**
+ * Tests for the StreamOverlayPage component.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+};
+
+const mockStatusIdle = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  current_print: null,
+  remaining_time: null,
+  layer_num: null,
+  total_layers: null,
+  stg_cur_name: null,
+};
+
+const mockStatusPrinting = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'RUNNING',
+  progress: 45,
+  current_print: 'Benchy.gcode.3mf',
+  remaining_time: 82,
+  layer_num: 150,
+  total_layers: 300,
+  stg_cur_name: null,
+};
+
+// Custom render for StreamOverlayPage
+function renderOverlayPage(printerId: number, queryParams = '') {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false, gcTime: 0 },
+      mutations: { retry: false },
+    },
+  });
+
+  return rtlRender(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
+        <ThemeProvider>
+          <ToastProvider>
+            <Routes>
+              <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+            </Routes>
+          </ToastProvider>
+        </ThemeProvider>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('StreamOverlayPage', () => {
+  const originalTitle = document.title;
+
+  beforeEach(() => {
+    // Mock WebSocket
+    vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
+      close: vi.fn(),
+      onmessage: null,
+      onerror: null,
+    })));
+
+    server.use(
+      http.get('/api/v1/printers/:id', () => {
+        return HttpResponse.json(mockPrinter);
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json(mockStatusIdle);
+      })
+    );
+  });
+
+  afterEach(() => {
+    document.title = originalTitle;
+    vi.unstubAllGlobals();
+  });
+
+  describe('rendering', () => {
+    it('renders overlay page for printer', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Bambuddy logo', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('logo links to GitHub', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        const logo = screen.getByAltText('Bambuddy');
+        const link = logo.closest('a');
+        expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
+      });
+    });
+  });
+
+  describe('printing state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+    });
+
+    it('shows filename when printing', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+    });
+
+    it('shows progress percentage', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('45%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows layer count', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('150')).toBeInTheDocument();
+        expect(screen.getByText('300')).toBeInTheDocument();
+      });
+    });
+
+    it('shows status text', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('invalid printer', () => {
+    it('shows invalid printer message for ID 0', async () => {
+      renderOverlayPage(0);
+
+      await waitFor(() => {
+        expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('query parameters', () => {
+    it('respects size parameter', async () => {
+      renderOverlayPage(1, '?size=large');
+
+      await waitFor(() => {
+        // Just verify it renders without error
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('respects show parameter to hide elements', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?show=progress');
+
+      await waitFor(() => {
+        // Progress should be visible
+        expect(screen.getByText('45%')).toBeInTheDocument();
+        // Status text should be hidden when not in show list
+        expect(screen.queryByText('Printing')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('offline state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            ...mockStatusIdle,
+            connected: false,
+          });
+        })
+      );
+    });
+
+    it('shows offline message when printer disconnected', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer offline')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 308 - 0
frontend/src/pages/StreamOverlayPage.tsx

@@ -0,0 +1,308 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Layers, Clock, Timer, Printer } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrinterStatus } from '../api/client';
+
+type OverlaySize = 'small' | 'medium' | 'large';
+
+interface OverlayConfig {
+  size: OverlaySize;
+  showProgress: boolean;
+  showLayers: boolean;
+  showEta: boolean;
+  showFilename: boolean;
+  showStatus: boolean;
+  showPrinter: boolean;
+}
+
+function parseConfig(params: URLSearchParams): OverlayConfig {
+  const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
+
+  return {
+    size: (params.get('size') as OverlaySize) || 'medium',
+    showProgress: show.includes('progress'),
+    showLayers: show.includes('layers'),
+    showEta: show.includes('eta'),
+    showFilename: show.includes('filename'),
+    showStatus: show.includes('status'),
+    showPrinter: show.includes('printer'),
+  };
+}
+
+function formatTime(seconds: number): string {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}
+
+function formatETA(remainingMinutes: number): string {
+  const now = new Date();
+  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const etaDay = new Date(eta);
+  etaDay.setHours(0, 0, 0, 0);
+
+  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+  if (etaDay.getTime() === today.getTime()) {
+    return timeStr;
+  } else if (etaDay.getTime() === today.getTime() + 86400000) {
+    return `Tomorrow ${timeStr}`;
+  } else {
+    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
+  }
+}
+
+function getStatusText(status: PrinterStatus): string {
+  if (status.stg_cur_name) return status.stg_cur_name;
+
+  switch (status.state) {
+    case 'RUNNING': return 'Printing';
+    case 'PAUSE': return 'Paused';
+    case 'FINISH': return 'Finished';
+    case 'FAILED': return 'Failed';
+    case 'IDLE': return 'Idle';
+    default: return status.state || 'Unknown';
+  }
+}
+
+function getSizeClasses(size: OverlaySize) {
+  switch (size) {
+    case 'small':
+      return {
+        container: 'p-3',
+        text: 'text-sm',
+        textLarge: 'text-lg',
+        progressHeight: 'h-2',
+        icon: 'w-3 h-3',
+        gap: 'gap-2',
+        logoHeight: 'h-12',
+      };
+    case 'large':
+      return {
+        container: 'p-6',
+        text: 'text-xl',
+        textLarge: 'text-3xl',
+        progressHeight: 'h-4',
+        icon: 'w-6 h-6',
+        gap: 'gap-4',
+        logoHeight: 'h-24',
+      };
+    case 'medium':
+    default:
+      return {
+        container: 'p-4',
+        text: 'text-base',
+        textLarge: 'text-xl',
+        progressHeight: 'h-3',
+        icon: 'w-4 h-4',
+        gap: 'gap-3',
+        logoHeight: 'h-16',
+      };
+  }
+}
+
+export function StreamOverlayPage() {
+  const { printerId } = useParams<{ printerId: string }>();
+  const [searchParams] = useSearchParams();
+  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);
+
+  // Fetch printer info
+  const { data: printer } = useQuery({
+    queryKey: ['printer', id],
+    queryFn: () => api.getPrinter(id),
+    enabled: id > 0,
+  });
+
+  // Fetch printer status with polling
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', id],
+    queryFn: () => api.getPrinterStatus(id),
+    enabled: id > 0,
+    refetchInterval: 2000,
+  });
+
+  // WebSocket for real-time updates
+  useEffect(() => {
+    if (!id) return;
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
+    const ws = new WebSocket(wsUrl);
+
+    ws.onmessage = (event) => {
+      try {
+        const data = JSON.parse(event.data);
+        if (data.type === 'printer_status' && data.printer_id === id) {
+          queryClient.setQueryData(['printerStatus', id], data.status);
+        }
+      } catch {
+        // Ignore parse errors
+      }
+    };
+
+    ws.onerror = () => {
+      // WebSocket error - polling will continue as fallback
+    };
+
+    return () => {
+      ws.close();
+    };
+  }, [id, queryClient]);
+
+  // Update document title
+  useEffect(() => {
+    document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
+    return () => {
+      document.title = 'Bambuddy';
+    };
+  }, [printer]);
+
+  // 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">
+        <p className="text-white">Invalid printer ID</p>
+      </div>
+    );
+  }
+
+  if (!status) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-gray-400">Loading...</p>
+      </div>
+    );
+  }
+
+  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}`;
+
+  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}
+      />
+
+      {/* Bambuddy logo - top right */}
+      <a
+        href="https://github.com/maziggy/bambuddy"
+        target="_blank"
+        rel="noopener noreferrer"
+        className="absolute top-4 right-4 z-10"
+      >
+        <img
+          src="/img/bambuddy_logo_dark_transparent.png"
+          alt="Bambuddy"
+          className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
+        />
+      </a>
+
+      {/* Status overlay - bottom */}
+      <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
+        <div className={`${sizes.container}`}>
+          {/* Printer name */}
+          {config.showPrinter && printer && (
+            <div className={`flex items-center ${sizes.gap} mb-2`}>
+              <Printer className={`${sizes.icon} text-white/70`} />
+              <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
+            </div>
+          )}
+
+          {/* Filename */}
+          {config.showFilename && status.current_print && (
+            <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
+              {status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
+            </div>
+          )}
+
+          {/* Status text */}
+          {config.showStatus && (
+            <div className={`${sizes.text} text-white/70 mb-2`}>
+              {getStatusText(status)}
+            </div>
+          )}
+
+          {/* Progress bar */}
+          {config.showProgress && isPrinting && (
+            <div className="mb-3">
+              <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
+                <span className="text-white/70">Progress</span>
+                <span className="text-white font-bold">{Math.round(progress)}%</span>
+              </div>
+              <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
+                <div
+                  className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
+                  style={{ width: `${progress}%` }}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Stats row */}
+          {isPrinting && (config.showLayers || config.showEta) && (
+            <div className={`flex items-center ${sizes.gap} flex-wrap`}>
+              {/* Layers */}
+              {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                  <Layers className={sizes.icon} />
+                  <span className={sizes.text}>
+                    <span className="text-white">{status.layer_num}</span>
+                    <span className="mx-1">/</span>
+                    <span>{status.total_layers}</span>
+                  </span>
+                </div>
+              )}
+
+              {/* Remaining time */}
+              {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
+                <>
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Timer className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      {formatTime(status.remaining_time * 60)}
+                    </span>
+                  </div>
+
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Clock className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      ETA {formatETA(status.remaining_time)}
+                    </span>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Idle state */}
+          {!isPrinting && (
+            <div className={`${sizes.text} text-white/70 py-2`}>
+              {status.connected ? 'Printer is idle' : 'Printer offline'}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BTpjfCpx.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DFuUL8IF.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-wxwVsx5u.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CKmo1TxA.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DFuUL8IF.css">
+    <script type="module" crossorigin src="/assets/index-wxwVsx5u.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BTpjfCpx.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов