diff --git a/frontend/public/img/bambuddy_logo_powered_by.png b/frontend/public/img/bambuddy_logo_powered_by.png new file mode 100644 index 000000000..9a87c672a Binary files /dev/null and b/frontend/public/img/bambuddy_logo_powered_by.png differ diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index e2ef21c36..3f448d90c 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/frontend/scripts/check-i18n-parity.mjs @@ -176,6 +176,7 @@ const DE_COGNATES = [ 'Diagnose', // DE: same spelling/meaning as EN — camera diagnostic button label '{{filament}} @ {{temp}}°C', // drying badge: filament code + universal °C 'Score', // #1546 AI detection modal — established DE loanword (Duden) + 'Version 2', // stream overlay artwork picker (#3177) — same word in German ]; // French cognates — many UI labels overlap with English exactly. @@ -225,6 +226,7 @@ const FR_COGNATES = [ 'Simple', 'Expert', // slicer settings visibility tiers — identical words in French 'Support', // same word in French 'Photos', '{{count}} photo', '{{count}} photos', // file details photo strip (#3077) — same word in French + 'Version 2', // stream overlay artwork picker (#3177) — same word in French ]; // Italian cognates. @@ -393,6 +395,7 @@ const SV_COGNATES = [ 'Port', '(System)', 'Autologin', 'Process', 'Filament {{n}}', 'Region', 'Global', 'Normal', 'Version', 'max {{n}}', 'Expert', 'Filament {{index}} ({{type}})', 'Material:', '(Inv)', 'Original', 'Commit', 'Extruder', 'Gradient', 'Proxy', 'Metadata', '{{count}} filament', 'Temp', 'Min', 'ntfy, Pushover, Discord, etc.', 'Hex: #{{hex}}', 'Designer', 'Prefix', 'Trend', 'max(global {{global}}d, SKU {{sku}}d)', 'Slicer', + 'Version 2', // stream overlay artwork picker (#3177) — same word in Swedish ]; // Turkish cognates — technical UI labels that Turkish speakers use verbatim diff --git a/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx index 921a2baac..3026ba432 100644 --- a/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx +++ b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx @@ -68,6 +68,22 @@ describe('StreamOverlayBuilder', () => { expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl); }); + it('opts into artwork in the URL and preview and restores the original URL', async () => { + const user = userEvent.setup(); + render(); + const artwork = await screen.findByLabelText('Artwork'); + expect(artwork).toHaveValue('1'); + const original = shownUrl(); + expect(new URL(original).searchParams.has('artwork')).toBe(false); + await user.selectOptions(artwork, 'Version 2'); + expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2'); + await user.click(screen.getByRole('button', { name: 'Show preview' })); + expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()); + await user.selectOptions(artwork, 'Classic'); + expect(shownUrl()).toBe(original); + expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original); + }); + it('switches printer', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index bb0e093a0..78d70e053 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -106,6 +106,95 @@ describe('StreamOverlayPage', () => { vi.unstubAllGlobals(); }); + 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' }))); + renderOverlayPage(1, '?artwork=2&show=model'); + expect(await screen.findByText('X1C')).toBeInTheDocument(); + expect(screen.queryByText('BL-P001')).not.toBeInTheDocument(); + }); + + it.each(['', '?artwork=other', '?artwork=updated'])('retains the original renderer for %s', async (query) => { + const { container } = renderOverlayPage(1, query); + await screen.findByAltText('Bambuddy'); + expect(container.querySelector('.updated-overlay')).not.toBeInTheDocument(); + expect(screen.getByAltText('Camera stream')).toHaveClass('object-contain'); + }); + it('uses token data, rotation and both nozzles without inventing missing temperatures', async () => { + server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({ + ...mockStatusPrinting, name: 'Token printer', model: 'O1D', camera_rotation: 90, + time_format: '24h', temperatures: { nozzle: 210, nozzle_target: 220, nozzle_2: 215, bed: 45 }, + }))); + renderOverlayPage(1, '?artwork=2&token=bblt_test&show=model,nozzle,bed,chamber&size=large'); + expect(await screen.findByText('H2D')).toBeInTheDocument(); + expect(screen.queryByText('Token printer')).not.toBeInTheDocument(); + expect(screen.getByText('Nozzle 2')).toBeInTheDocument(); + expect(screen.queryByText('Chamber')).not.toBeInTheDocument(); + const camera = screen.getByAltText('Camera stream'); + expect(camera).toHaveAttribute('src', expect.stringContaining('token=bblt_test')); + expect(camera).toHaveStyle({ transform: 'translate(-50%, -50%) rotate(90deg)' }); + expect(camera.closest('.updated-overlay')).toHaveAttribute('data-size', 'large'); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it.each([ + [90, { width: '640px', height: '360px' }], + [0, null], + ])('sizes a camera turned %i degrees from its slot, not the viewport', async (rotation, expected) => { + // A portrait source: the camera fills a 360 x 640 middle row, so the + // viewport (the old vw/vh fallback) would be the wrong box to swap. + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('updated-overlay__camera') + ? ({ width: 360, height: 640, top: 0, left: 0, right: 360, bottom: 640, x: 0, y: 0, toJSON: () => ({}) } as DOMRect) + : ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect); + }); + try { + server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation }))); + renderOverlayPage(1, '?artwork=2'); + const camera = await screen.findByAltText('Camera stream'); + await waitFor(() => expect(camera).toHaveStyle({ transform: `translate(-50%, -50%) rotate(${rotation}deg)` })); + if (expected) { + await waitFor(() => expect(camera).toHaveStyle(expected)); + } else { + expect(camera.style.width).toBe(''); + expect(camera.style.height).toBe(''); + } + } finally { + rect.mockRestore(); + } + }); + + it('renders selected fields and accessible progress', async () => { + server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting))); + renderOverlayPage(1, '?artwork=2&show=printer,model,filename,status,progress,layers,eta'); + expect(await screen.findByRole('progressbar')).toHaveAttribute('aria-valuenow', '45'); + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.getByText('X1C')).toBeInTheDocument(); + expect(screen.getByText('Benchy')).toBeInTheDocument(); + expect(screen.getByText('150 / 300')).toBeInTheDocument(); + }); + it('hides all unselected information, including idle status', async () => { + const { container } = renderOverlayPage(1, '?artwork=2&show=&camera=false'); + await screen.findByAltText('Bambuddy'); + expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument(); + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + expect(container.querySelector('.updated-overlay__panel')).not.toBeInTheDocument(); + }); + it.each(['FINISH', 'FAILED'])('shows %s without contradictory idle text', async (state) => { + server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state }))); + renderOverlayPage(1, '?artwork=2&show=status,progress'); + expect(await screen.findByText(state === 'FINISH' ? 'Finished' : 'Failed')).toBeInTheDocument(); + expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + it('hides stale progress when disconnected', async () => { + server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false }))); + renderOverlayPage(1, '?artwork=2&show=status,progress,layers,eta'); + expect(await screen.findByText('Printer offline')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + }); + describe('rendering', () => { it('renders overlay page for printer', async () => { renderOverlayPage(1); diff --git a/frontend/src/components/StreamOverlayBuilder.tsx b/frontend/src/components/StreamOverlayBuilder.tsx index c9c7810c0..02be20e4a 100644 --- a/frontend/src/components/StreamOverlayBuilder.tsx +++ b/frontend/src/components/StreamOverlayBuilder.tsx @@ -47,6 +47,9 @@ export function StreamOverlayBuilder() { const [fields, setFields] = useState(DEFAULT_FIELDS); const [size, setSize] = useState('medium'); const [fps, setFps] = useState(DEFAULT_FPS); + // '1' is the original overlay; the renderer is picked by version, not by a + // name like "updated" that stops being true once there's a newer one. + const [artwork, setArtwork] = useState<'1' | '2'>('1'); const [showCamera, setShowCamera] = useState(true); const [token, setToken] = useState(''); const [preview, setPreview] = useState(false); @@ -80,10 +83,11 @@ export function StreamOverlayBuilder() { params.set('show', selected.join(',')); if (size !== 'medium') params.set('size', size); if (fps !== DEFAULT_FPS) params.set('fps', String(fps)); + if (artwork !== '1') params.set('artwork', artwork); if (!showCamera) params.set('camera', 'false'); if (token.trim()) params.set('token', token.trim()); return `${window.location.origin}/overlay/${id}?${params.toString()}`; - }, [printerId, fields, size, fps, showCamera, token]); + }, [printerId, fields, size, fps, showCamera, token, artwork]); const toggleField = (key: string) => { setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key])); @@ -162,6 +166,21 @@ export function StreamOverlayBuilder() { +
+ + +
+