Browse Source

Add landscape and portrait streaming overlay outputs (#3209)

Adam Spice 4 hours ago
parent
commit
21a278e676
100 changed files with 578 additions and 199 deletions
  1. 1 0
      CHANGELOG.md
  2. 80 17
      frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx
  3. 38 0
      frontend/src/__tests__/components/UpdatedStreamOverlay.test.tsx
  4. 81 47
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  5. 43 0
      frontend/src/components/OverlayFrame.tsx
  6. 68 35
      frontend/src/components/StreamOverlayBuilder.tsx
  7. 50 52
      frontend/src/components/UpdatedStreamOverlay.css
  8. 23 3
      frontend/src/components/UpdatedStreamOverlay.tsx
  9. 7 0
      frontend/src/i18n/locales/de.ts
  10. 7 0
      frontend/src/i18n/locales/en.ts
  11. 7 0
      frontend/src/i18n/locales/es.ts
  12. 7 0
      frontend/src/i18n/locales/fr.ts
  13. 7 0
      frontend/src/i18n/locales/it.ts
  14. 7 0
      frontend/src/i18n/locales/ja.ts
  15. 7 0
      frontend/src/i18n/locales/ko.ts
  16. 7 0
      frontend/src/i18n/locales/nl.ts
  17. 7 0
      frontend/src/i18n/locales/pt-BR.ts
  18. 7 0
      frontend/src/i18n/locales/ru.ts
  19. 7 0
      frontend/src/i18n/locales/sv.ts
  20. 7 0
      frontend/src/i18n/locales/tr.ts
  21. 7 0
      frontend/src/i18n/locales/uk.ts
  22. 7 0
      frontend/src/i18n/locales/zh-CN.ts
  23. 7 0
      frontend/src/i18n/locales/zh-TW.ts
  24. 36 0
      frontend/src/pages/StreamOverlayPage.css
  25. 47 33
      frontend/src/pages/StreamOverlayPage.tsx
  26. 6 0
      frontend/src/utils/overlayLayout.ts
  27. 0 0
      static/assets/ImagePreviewModal-Cpg4wnqs.js
  28. 0 1
      static/assets/PdfPreviewModal-CKqPOK_d.js
  29. 0 1
      static/assets/SpreadsheetPreviewModal-DNvI9563.js
  30. 0 1
      static/assets/chunk-aKtaBQYM.js
  31. 0 1
      static/assets/index-Bgo1crq7.css
  32. 0 1
      static/assets/index-BpBVgnaG.js
  33. BIN
      static/assets/occt-import-js-BhHfLpto.wasm
  34. 0 0
      static/assets/papaparse.min-CR43Mv99.js
  35. 0 0
      static/assets/pdf-CbcA-ZGz.js
  36. 0 0
      static/assets/pdf.worker.min-0idpi3xP.js
  37. 0 1
      static/assets/pdf.worker.min-DpOxPwGP.js
  38. BIN
      static/assets/pdfjs/cmaps/78-EUC-H.bcmap
  39. BIN
      static/assets/pdfjs/cmaps/78-EUC-V.bcmap
  40. BIN
      static/assets/pdfjs/cmaps/78-H.bcmap
  41. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap
  42. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap
  43. BIN
      static/assets/pdfjs/cmaps/78-V.bcmap
  44. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap
  45. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap
  46. BIN
      static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap
  47. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap
  48. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap
  49. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap
  50. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap
  51. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap
  52. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap
  53. BIN
      static/assets/pdfjs/cmaps/Add-H.bcmap
  54. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap
  55. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap
  56. BIN
      static/assets/pdfjs/cmaps/Add-V.bcmap
  57. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap
  58. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap
  59. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap
  60. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap
  61. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap
  62. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap
  63. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap
  64. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap
  65. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap
  66. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap
  67. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap
  68. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap
  69. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap
  70. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap
  71. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap
  72. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap
  73. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap
  74. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap
  75. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap
  76. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap
  77. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap
  78. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap
  79. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap
  80. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap
  81. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap
  82. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap
  83. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap
  84. BIN
      static/assets/pdfjs/cmaps/B5-H.bcmap
  85. BIN
      static/assets/pdfjs/cmaps/B5-V.bcmap
  86. BIN
      static/assets/pdfjs/cmaps/B5pc-H.bcmap
  87. BIN
      static/assets/pdfjs/cmaps/B5pc-V.bcmap
  88. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap
  89. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap
  90. BIN
      static/assets/pdfjs/cmaps/CNS1-H.bcmap
  91. BIN
      static/assets/pdfjs/cmaps/CNS1-V.bcmap
  92. BIN
      static/assets/pdfjs/cmaps/CNS2-H.bcmap
  93. 0 3
      static/assets/pdfjs/cmaps/CNS2-V.bcmap
  94. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap
  95. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap
  96. BIN
      static/assets/pdfjs/cmaps/ETen-B5-H.bcmap
  97. BIN
      static/assets/pdfjs/cmaps/ETen-B5-V.bcmap
  98. 0 3
      static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap
  99. BIN
      static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap
  100. BIN
      static/assets/pdfjs/cmaps/EUC-H.bcmap

+ 1 - 0
CHANGELOG.md

@@ -33,6 +33,7 @@ All notable changes to Bambuddy will be documented in this file.
   - **Transparency (Version 2 only):** a **Background transparency** slider from 0 to 100% fades the dark backgrounds, so another OBS source can show through. Text, logos and the camera stay fully visible.
   - **URLs:** the settings are added to the overlay URL as `logo=1`, `progressFrom`, `progressTo` and `backgroundTransparency`. URLs without them look as before.
   - **Preview:** it now waits until you stop dragging a colour picker or the slider before reloading, so it no longer opens a new camera stream on every step.
+- **Portrait streaming overlay URLs for Classic and Version 2 (#3185, requested and contributed by @adamspicedev in #3209)** — Settings → API Keys → Streaming Overlay has a new **Layout** choice: **Landscape**, **Portrait** or **Both**. Each layout gets its own URL with Copy and Open buttons, the browser-source size to set in OBS, and its own preview, so **Both** shows the landscape and portrait previews side by side. A portrait URL adds `layout=portrait` and draws a fixed 1080 × 1920 composition that is scaled to fit the browser source, so text, camera and spacing look the same in the preview, in OBS and in a browser tab. Classic gets a portrait arrangement too, with larger text and a wrapping file name. A landscape URL carries no `layout` parameter and looks exactly as before, including Classic's text size in a 1280 × 720 source; Version 2 URLs without `layout` still follow the shape of the browser source. Classic now hides progress, layers, time remaining and temperatures while the printer is offline, as Version 2 already did, and shows "Printer offline" in the status line. **Behaviour change:** Classic no longer adds its own "Printer is idle" or "Printer offline" line below the status; that text now comes only from the **Status** field, so an idle printer no longer shows both "Idle" and "Printer is idle". A Classic URL whose `show=` list leaves out `status` therefore shows no idle or offline text; add `status` to it to get the line back.
 - **Queue cards show the filament each job will print with (#3132, requested and contributed by @bgrr74 in #3184)** — Each job in the print queue now shows a colour swatch and name for every filament it uses, so you can check the next colours without opening each job. When a job is mapped to AMS slots on a specific printer, the card names the slot, the spool bound to it and its colour, for example "A1 · eSUN PLA Basic · Bone White", read from the printer's current AMS contents. Jobs without a slot mapping yet, such as "Any P2S", show the colour chosen when queueing, or the colour from the 3MF for the selected plate. Jobs with more than two filaments show just a row of swatches, with the full list on hover. If a mapped slot has been emptied since the job was queued, the card says so in yellow ("A3 · Empty · Caramel"); a spool without an RFID tag is never reported as empty.
 - **An app shown in the sidebar can match Bambuddy's theme** — An external link that opens inside Bambuddy now tells the framed page which theme is showing: light or dark, background, style and accent. It sends this when the page loads, whenever the theme changes, and when the page asks with a `bambuddy:theme-request` message. The message is `{type: "bambuddy:theme", mode, style, background, accent}` and goes only to the link's own origin. It carries nothing but those names. Pages that don't listen are unaffected.
 - **An app shown in the sidebar can open a Bambuddy page in place** — The frame Bambuddy shows an external link in can't navigate the Bambuddy window, so a link such as Bambuddy Orders' "In Bambuddy" did nothing when clicked inside the sidebar. The framed page can now ask with `{type: "bambuddy:navigate", path: "/queue?batch=12"}`, and Bambuddy opens that page as if its own menu had been used. The request is accepted only from the frame, only from the link's own origin, and only for a path inside Bambuddy; anything else, such as another site or a `javascript:` address, is ignored. The theme message now carries `canNavigate: true`, so a page can tell this Bambuddy from older ones and open a new tab there instead. The frame's sandbox is unchanged.

+ 80 - 17
frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { screen, waitFor, fireEvent, act } from '@testing-library/react';
+import { screen, waitFor, fireEvent, act, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
@@ -30,6 +30,68 @@ describe('StreamOverlayBuilder', () => {
     server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers)), http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })));
   });
 
+  it.each(['1', '2'])('provides independent orientation URLs and previews for artwork %s', async (artwork) => {
+    const user = userEvent.setup();
+    const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
+    vi.stubGlobal('isSecureContext', true);
+    const { unmount } = render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.selectOptions(screen.getByLabelText('Artwork'), artwork);
+    const layout = screen.getByLabelText('Layout');
+    expect(layout).toHaveValue('landscape');
+    await user.selectOptions(layout, 'both');
+    fireEvent.change(screen.getByLabelText('From colour (hex)'), { target: { value: '#ff0000' } });
+    fireEvent.change(screen.getByLabelText('To colour (hex)'), { target: { value: '#0000ff' } });
+    if (artwork === '2') fireEvent.change(screen.getByRole('slider', { name: /Background transparency/ }), { target: { value: '65' } });
+    expect(document.querySelectorAll('iframe')).toHaveLength(0);
+    await user.selectOptions(screen.getByLabelText('Printer'), '2');
+    await user.type(screen.getByLabelText(/token/i), 'bblt_example');
+    await user.selectOptions(screen.getByLabelText('Text size'), 'large');
+    await user.click(screen.getByLabelText('Nozzle'));
+    await user.click(screen.getByLabelText('Camera feed'));
+    await user.click(screen.getByRole('button', { name: 'Show preview' }));
+    for (const orientation of ['Landscape', 'Portrait']) {
+      const group = within(screen.getByRole('group', { name: `${orientation} URL` }));
+      const url = new URL(group.getByRole('link', { name: 'Open' }).getAttribute('href') ?? '');
+      expect(url.pathname).toBe('/overlay/2');
+      expect(url.searchParams.get('progressFrom')).toBe('#ff0000');
+      expect(url.searchParams.get('progressTo')).toBe('#0000ff');
+      expect(url.searchParams.get('backgroundTransparency')).toBe(artwork === '2' ? '65' : null);
+      expect(url.searchParams.get('layout')).toBe(orientation === 'Portrait' ? 'portrait' : null);
+      expect(url.searchParams.get('token')).toBe('bblt_example');
+      expect(url.searchParams.get('size')).toBe('large');
+      expect(url.searchParams.get('camera')).toBe('false');
+      expect(url.searchParams.get('show')).toContain('nozzle');
+      expect(url.searchParams.get('artwork')).toBe(artwork === '2' ? '2' : null);
+      await waitFor(() => expect(screen.getByTitle(`${orientation} preview`)).toHaveAttribute('src', url.href));
+      await user.click(group.getByRole('button', { name: 'Copy' }));
+      expect(copy).toHaveBeenLastCalledWith(url.href);
+    }
+    await user.click(screen.getByLabelText('Camera feed'));
+    await user.selectOptions(screen.getByLabelText('Text size'), 'small');
+    const fps = screen.getByLabelText('Frame rate');
+    await user.clear(fps);
+    await user.type(fps, '5');
+    await waitFor(() => {
+      for (const iframe of document.querySelectorAll('iframe')) {
+        const params = new URL(iframe.src).searchParams;
+        expect(params.get('camera')).toBeNull();
+        expect(params.get('size')).toBe('small');
+        expect(params.get('fps')).toBe('5');
+      }
+    });
+    await user.selectOptions(layout, 'portrait');
+    expect(document.querySelectorAll('iframe')).toHaveLength(1);
+    expect(screen.queryByTitle('Landscape preview')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Hide preview' }));
+    expect(document.querySelectorAll('iframe')).toHaveLength(0);
+    await user.click(screen.getByRole('button', { name: 'Show preview' }));
+    unmount();
+    expect(document.querySelectorAll('iframe')).toHaveLength(0);
+    copy.mockRestore();
+    vi.unstubAllGlobals();
+  });
+
   it('starts on the first printer with the overlay defaults', async () => {
     render(<StreamOverlayBuilder />);
 
@@ -42,6 +104,7 @@ describe('StreamOverlayBuilder', () => {
     // ?show= is read with includes(), so order is free to be the stable one.
     expect(shownUrl()).toContain('show=filename%2Cstatus%2Cprogress%2Clayers%2Ceta');
     // Defaults are omitted rather than spelled out — a shorter URL to paste.
+    expect(shownUrl()).not.toContain('layout=');
     expect(shownUrl()).not.toContain('size=');
     expect(shownUrl()).not.toContain('fps=');
     expect(shownUrl()).not.toContain('camera=');
@@ -60,12 +123,12 @@ describe('StreamOverlayBuilder', () => {
     await user.click(screen.getByLabelText('Printer name'));
     expect(new URL(shownUrl()).searchParams.get('show')).toBe('printer,model,filename,status,progress,layers,eta');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl()));
 
     await user.click(model);
     await user.click(screen.getByLabelText('Printer name'));
     expect(shownUrl()).toBe(originalUrl);
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', originalUrl));
   });
 
   it('opts into artwork in the URL and preview and restores the original URL', async () => {
@@ -78,10 +141,10 @@ describe('StreamOverlayBuilder', () => {
     await user.selectOptions(artwork, 'Version 2');
     expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl()));
     await user.selectOptions(artwork, 'Classic');
     expect(shownUrl()).toBe(original);
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', original));
   });
 
   it('only offers background transparency for Version 2 and preserves its selection', async () => {
@@ -96,7 +159,7 @@ describe('StreamOverlayBuilder', () => {
     fireEvent.change(slider, { target: { value: '65' } });
     expect(new URL(shownUrl()).searchParams.get('backgroundTransparency')).toBe('65');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl()));
     await user.selectOptions(artwork, 'Classic');
     expect(screen.queryByRole('slider', { name: /Background transparency/ })).not.toBeInTheDocument();
     expect(shownUrl()).not.toContain('backgroundTransparency');
@@ -108,7 +171,7 @@ describe('StreamOverlayBuilder', () => {
     const user = userEvent.setup();
     render(<StreamOverlayBuilder />);
 
-    await waitFor(() => expect(screen.getByLabelText('Printer')).toBeInTheDocument());
+    await screen.findByRole('option', { name: 'P1S' });
     await user.selectOptions(screen.getByLabelText('Printer'), '2');
 
     await waitFor(() => expect(shownUrl()).toContain('/overlay/2'));
@@ -242,17 +305,17 @@ it('uploads a logo, includes it in the URL, and removes it from the preview', as
     expect(await screen.findByRole('img', { name: 'Custom logo' })).toHaveAttribute('src', 'blob:logo-preview');
     expect(new URL(shownUrl()).searchParams.get('logo')).toBe('1');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
-    const previousPreview = screen.getByTitle('Overlay preview');
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl()));
+    const previousPreview = screen.getByTitle('Landscape preview');
     const previousUrl = shownUrl();
     await user.upload(screen.getByLabelText('Upload logo'), new File(['new png'], 'replacement.png', { type: 'image/png' }));
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).not.toBe(previousPreview));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).not.toBe(previousPreview));
     expect(shownUrl()).toBe(previousUrl);
     expect(await screen.findByRole('img', { name: 'Custom logo' })).toBeInTheDocument();
     await user.click(screen.getByRole('button', { name: 'Remove' }));
     await waitFor(() => expect(screen.queryByRole('img', { name: 'Custom logo' })).not.toBeInTheDocument());
     expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
-    await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
+    await waitFor(() => expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl()));
   } finally {
     create.mockRestore();
     revoke.mockRestore();
@@ -268,25 +331,25 @@ it('debounces continuous preview changes and cancels a pending reload when hidde
   await screen.findByRole('option', { name: 'X1 Carbon' });
   fireEvent.change(screen.getByLabelText('Artwork'), { target: { value: '2' } });
   fireEvent.click(screen.getByRole('button', { name: 'Show preview' }));
-  const original = screen.getByTitle('Overlay preview');
+  const original = screen.getByTitle('Landscape preview');
   vi.useFakeTimers();
   try {
     fireEvent.change(screen.getByLabelText('From colour'), { target: { value: '#ff0000' } });
     act(() => vi.advanceTimersByTime(200));
     fireEvent.change(screen.getByRole('slider', { name: /Background transparency/ }), { target: { value: '65' } });
     act(() => vi.advanceTimersByTime(299));
-    expect(screen.getByTitle('Overlay preview')).toBe(original);
+    expect(screen.getByTitle('Landscape preview')).toBe(original);
     expect(original).not.toHaveAttribute('src', shownUrl());
     act(() => vi.advanceTimersByTime(1));
-    expect(screen.getByTitle('Overlay preview')).not.toBe(original);
-    expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
+    expect(screen.getByTitle('Landscape preview')).not.toBe(original);
+    expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl());
 
     fireEvent.change(screen.getByLabelText('From colour'), { target: { value: '#0000ff' } });
     fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }));
     act(() => vi.advanceTimersByTime(300));
-    expect(screen.queryByTitle('Overlay preview')).not.toBeInTheDocument();
+    expect(screen.queryByTitle('Landscape preview')).not.toBeInTheDocument();
     fireEvent.click(screen.getByRole('button', { name: 'Show preview' }));
-    expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
+    expect(screen.getByTitle('Landscape preview')).toHaveAttribute('src', shownUrl());
     fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }));
   } finally {
     vi.useRealTimers();

+ 38 - 0
frontend/src/__tests__/components/UpdatedStreamOverlay.test.tsx

@@ -0,0 +1,38 @@
+import { act, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { UpdatedStreamOverlay } from '../../components/UpdatedStreamOverlay';
+
+const props = {
+  size: 'medium' as const, camera: null, name: 'Workshop', model: null,
+  filename: null, status: null, state: null, progress: null, layers: null,
+  remaining: null, eta: null, temperatures: [],
+};
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Version 2 orientation', () => {
+  it('follows viewport changes for legacy URLs and removes its listener on unmount', () => {
+    let change: ((event: { matches: boolean }) => void) | undefined;
+    const removeEventListener = vi.fn();
+    vi.spyOn(window, 'matchMedia').mockReturnValue({
+      matches: true,
+      addEventListener: (_: string, listener: typeof change) => { change = listener; },
+      removeEventListener,
+    } as unknown as MediaQueryList);
+    const view = render(<UpdatedStreamOverlay {...props} />);
+    const overlay = view.container.firstElementChild;
+    expect(overlay).toHaveAttribute('data-layout', 'portrait');
+    act(() => change?.({ matches: false }));
+    expect(overlay).toHaveAttribute('data-layout', 'landscape');
+    act(() => change?.({ matches: true }));
+    expect(overlay).toHaveAttribute('data-layout', 'portrait');
+    view.unmount();
+    expect(removeEventListener).toHaveBeenCalledWith('change', change);
+  });
+
+  it.each(['landscape', 'portrait'] as const)('keeps explicit %s independent of viewport orientation', (layout) => {
+    vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: layout !== 'portrait' } as MediaQueryList);
+    const view = render(<UpdatedStreamOverlay {...props} layout={layout} />);
+    expect(view.container.firstElementChild).toHaveAttribute('data-layout', layout);
+  });
+});

+ 81 - 47
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -106,44 +106,78 @@ describe('StreamOverlayPage', () => {
     vi.unstubAllGlobals();
   });
 
-  it.each(['1', '2'])('uses the requested gradient in artwork %s', async (artwork) => {
-    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
-    const { container } = renderOverlayPage(1, `?artwork=${artwork}&progressFrom=%23ff0000&progressTo=%230000ff`);
-    await screen.findByAltText('Bambuddy');
-    const bar = container.querySelector('[style*="width: 45%"]');
-    expect(bar).toHaveStyle({ width: '45%', background: 'linear-gradient(to right, #ff0000, #0000ff)' });
-    for (const text of [screen.getByText('Progress'), screen.getByText('45%')]) {
-      expect(text).toHaveStyle({
-        'background-image': 'linear-gradient(to right, #ff0000, #0000ff)',
-        'background-clip': 'text',
-        color: 'rgba(0, 0, 0, 0)',
-      });
-    }
+  it.each(['1', '2'])('sizes custom logos against the portrait canvas in artwork %s', async (artwork) => {
+    server.use(http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(new Blob(['png'], { type: 'image/png' }))));
+    renderOverlayPage(1, `?layout=portrait&artwork=${artwork}&logo=1`);
+    expect(await screen.findByAltText('Custom logo')).toHaveStyle({ maxWidth: '216px', maxHeight: '268.8px' });
   });
 
-  it.each(['1', '2'])('ignores malformed branding colours in artwork %s', async (artwork) => {
-    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
-    const { container } = renderOverlayPage(1, `?artwork=${artwork}&progressFrom=red&progressTo=%230000ff`);
-    await screen.findByAltText('Bambuddy');
-    expect(container.querySelector('[style*="linear-gradient"]')).toBeNull();
-  });
+  describe.each(['', 'layout=landscape&', 'layout=portrait&'])('branding with %s', (layout) => {
+    it.each(['1', '2'])('uses the requested gradient in artwork %s', async (artwork) => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
+      const { container } = renderOverlayPage(1, `?${layout}artwork=${artwork}&progressFrom=%23ff0000&progressTo=%230000ff`);
+      await screen.findByAltText('Bambuddy');
+      const bar = container.querySelector('[style*="width: 45%"]');
+      expect(bar).toHaveStyle({ width: '45%', background: 'linear-gradient(to right, #ff0000, #0000ff)' });
+      for (const text of [screen.getByText('Progress'), screen.getByText('45%')]) {
+        expect(text).toHaveStyle({
+          'background-image': 'linear-gradient(to right, #ff0000, #0000ff)',
+          'background-clip': 'text',
+          color: 'rgba(0, 0, 0, 0)',
+        });
+      }
+    });
+
+    it.each(['1', '2'])('ignores malformed branding colours in artwork %s', async (artwork) => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
+      const { container } = renderOverlayPage(1, `?${layout}artwork=${artwork}&progressFrom=red&progressTo=%230000ff`);
+      await screen.findByAltText('Bambuddy');
+      expect(container.querySelector('[style*="linear-gradient"]')).toBeNull();
+    });
+
+    it.each([
+      ['0', '1'], ['50', '0.5'], ['100', '0'], ['-10', '1'], ['150', '0'], ['invalid', '1'], ['', '1'],
+    ])('validates Version 2 background transparency %s', async (value, alpha) => {
+      const { container, unmount } = renderOverlayPage(1, `?${layout}artwork=2&camera=false&backgroundTransparency=${value}`);
+      await screen.findByAltText('Bambuddy');
+      expect(container.querySelector('.updated-overlay')).toHaveStyle({ '--overlay-background-alpha': alpha });
+      if (alpha !== '1') expect(document.body.style.backgroundColor).toBe('transparent');
+      unmount();
+      expect(document.body.style.backgroundColor).not.toBe('transparent');
+    });
+
+    it('ignores background transparency in Classic', async () => {
+      const { container } = renderOverlayPage(1, '?backgroundTransparency=100');
+      await screen.findByAltText('Bambuddy');
+      expect(container.querySelector('[style*="--overlay-background-alpha"]')).toBeNull();
+      expect(document.body.style.backgroundColor).not.toBe('transparent');
+    });
 
-  it.each([
-    ['0', '1'], ['50', '0.5'], ['100', '0'], ['-10', '1'], ['150', '0'], ['invalid', '1'], ['', '1'],
-  ])('validates Version 2 background transparency %s', async (value, alpha) => {
-    const { container, unmount } = renderOverlayPage(1, `?artwork=2&camera=false&backgroundTransparency=${value}`);
-    await screen.findByAltText('Bambuddy');
-    expect(container.querySelector('.updated-overlay')).toHaveStyle({ '--overlay-background-alpha': alpha });
-    if (alpha !== '1') expect(document.body.style.backgroundColor).toBe('transparent');
-    unmount();
-    expect(document.body.style.backgroundColor).not.toBe('transparent');
   });
 
-  it('ignores background transparency in Classic', async () => {
-    const { container } = renderOverlayPage(1, '?backgroundTransparency=100');
-    await screen.findByAltText('Bambuddy');
-    expect(container.querySelector('[style*="--overlay-background-alpha"]')).toBeNull();
-    expect(document.body.style.backgroundColor).not.toBe('transparent');
+  describe.each(['', 'layout=landscape&', 'layout=portrait&'])('consistent status for %s', (layout) => {
+    it.each(['FINISH', 'FAILED', 'IDLE'])('honors the hidden status field for Classic %s', async (state) => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state })));
+      renderOverlayPage(1, `?${layout}show=filename`);
+      expect(await screen.findByText('Benchy')).toBeInTheDocument();
+      expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
+      expect(screen.queryByText('Finished')).not.toBeInTheDocument();
+      expect(screen.queryByText('Failed')).not.toBeInTheDocument();
+    });
+
+    it.each(['1', '2'])('keeps disconnected and finished states truthful in artwork %s', async (artwork) => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false, temperatures: { nozzle: 210 } })));
+      const view = renderOverlayPage(1, `?${layout}artwork=${artwork}&show=filename,status,progress,layers,eta,nozzle`);
+      await screen.findAllByText('Printer offline');
+      expect(screen.queryByText('45%')).not.toBeInTheDocument();
+      expect(screen.queryByText('210°C')).not.toBeInTheDocument();
+      expect(screen.queryByText('Printing')).not.toBeInTheDocument();
+      view.unmount();
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state: 'FINISH' })));
+      renderOverlayPage(1, `?${layout}artwork=${artwork}&show=filename,status,progress,layers,eta,nozzle`);
+      expect(await screen.findByText('Finished')).toBeInTheDocument();
+      expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
+    });
   });
 
   it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
@@ -209,10 +243,11 @@ describe('StreamOverlayPage', () => {
     ])('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);
+      const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+        return this.classList.contains('updated-overlay__camera') ? 360 : 0;
+      });
+      const height = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
+        return this.classList.contains('updated-overlay__camera') ? 640 : 0;
       });
       try {
         server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
@@ -226,7 +261,8 @@ describe('StreamOverlayPage', () => {
           expect(camera.style.height).toBe('');
         }
       } finally {
-        rect.mockRestore();
+        width.mockRestore();
+        height.mockRestore();
       }
     });
 
@@ -254,7 +290,7 @@ describe('StreamOverlayPage', () => {
       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 })));
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false, temperatures: { nozzle: 210 } })));
       renderOverlayPage(1, '?artwork=2&show=status,progress,layers,eta');
       expect(await screen.findByText('Printer offline')).toBeInTheDocument();
       expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
@@ -266,7 +302,7 @@ describe('StreamOverlayPage', () => {
       renderOverlayPage(1);
 
       await waitFor(() => {
-        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+        expect(screen.getByText('Idle')).toBeInTheDocument();
       });
     });
 
@@ -292,7 +328,7 @@ describe('StreamOverlayPage', () => {
   describe('printer model', () => {
     it.each(['', '?show=printer', '?show='])('hides the model unless selected (%s)', async (query) => {
       renderOverlayPage(1, query);
-      await screen.findByText('Printer is idle');
+      await screen.findByAltText('Bambuddy');
       expect(screen.queryByText(/X1C/)).not.toBeInTheDocument();
     });
 
@@ -485,7 +521,7 @@ describe('StreamOverlayPage', () => {
 
       await waitFor(() => {
         // Status should still be visible
-        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+        expect(screen.getByText('Idle')).toBeInTheDocument();
       });
 
       // Camera should not be rendered
@@ -496,7 +532,7 @@ describe('StreamOverlayPage', () => {
       renderOverlayPage(1, '?camera=0');
 
       await waitFor(() => {
-        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+        expect(screen.getByText('Idle')).toBeInTheDocument();
       });
 
       expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
@@ -533,7 +569,7 @@ describe('StreamOverlayPage', () => {
       renderOverlayPage(1, '?camera=false&size=large');
 
       await waitFor(() => {
-        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+        expect(screen.getByText('Idle')).toBeInTheDocument();
       });
 
       expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
@@ -745,9 +781,7 @@ describe('StreamOverlayPage', () => {
       );
       renderOverlayPage(1, '?show=bed');
 
-      await waitFor(() => {
-        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
-      });
+      await screen.findByText('45°C');
       // A preheating printer is exactly when the readings are worth watching,
       // so they are not gated behind a running print.
       expect(screen.getByText('45°C')).toBeInTheDocument();

+ 43 - 0
frontend/src/components/OverlayFrame.tsx

@@ -0,0 +1,43 @@
+import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+
+import { OVERLAY_DIMENSIONS, type OverlayLayout } from '../utils/overlayLayout';
+
+// Render at the browser-source resolution, then scale the whole composition.
+// Text, camera and spacing therefore match in settings, OBS and standalone tabs.
+export function OverlayFrame({ layout, preview = false, children }: {
+  layout: OverlayLayout;
+  preview?: boolean;
+  children: ReactNode;
+}) {
+  const ref = useRef<HTMLDivElement>(null);
+  const [scale, setScale] = useState(0);
+  const { width, height } = OVERLAY_DIMENSIONS[layout];
+  useLayoutEffect(() => {
+    const element = ref.current;
+    if (!element) return;
+    const measure = () => setScale(Math.min(element.clientWidth / width, element.clientHeight / height));
+    measure();
+    if (typeof ResizeObserver === 'undefined') {
+      window.addEventListener('resize', measure);
+      return () => window.removeEventListener('resize', measure);
+    }
+    const observer = new ResizeObserver(measure);
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [width, height]);
+
+  const canvasStyle: CSSProperties & Record<'--overlay-vmin' | '--overlay-height', string> = {
+    width, height,
+    '--overlay-vmin': '10.8px',
+    '--overlay-height': `${height}px`,
+    position: 'absolute', top: '50%', left: '50%',
+    transform: `translate(-50%, -50%) scale(${scale})`,
+    transformOrigin: 'center',
+  };
+  return (
+    <div ref={ref} className={preview ? 'relative w-full overflow-hidden' : 'fixed inset-0 overflow-hidden'}
+      style={preview ? { aspectRatio: `${width} / ${height}` } : undefined}>
+      <div style={canvasStyle}>{children}</div>
+    </div>
+  );
+}

+ 68 - 35
frontend/src/components/StreamOverlayBuilder.tsx

@@ -16,6 +16,8 @@ import { useToast } from '../contexts/ToastContext';
 import { OverlayBrandingControls } from './OverlayBrandingControls';
 import { DEFAULT_BRANDING } from '../utils/overlayBranding';
 import { NumberInput } from './NumberInput';
+import { OverlayFrame } from './OverlayFrame';
+import { OVERLAY_DIMENSIONS, type OverlayLayout } from '../utils/overlayLayout';
 
 type OverlaySize = 'small' | 'medium' | 'large';
 
@@ -56,6 +58,7 @@ export function StreamOverlayBuilder() {
   const [showCamera, setShowCamera] = useState(true);
   const [token, setToken] = useState('');
   const [branding, setBranding] = useState(DEFAULT_BRANDING);
+  const [layout, setLayout] = useState<OverlayLayout | 'both'>('landscape');
   const [preview, setPreview] = useState(false);
 
   useEffect(() => {
@@ -78,7 +81,7 @@ export function StreamOverlayBuilder() {
     };
   }, []);
 
-  const url = useMemo(() => {
+  const outputs = useMemo(() => {
     const id = printerId ?? 1;
     const params = new URLSearchParams();
     // Emit ?show= in the canonical field order rather than click order, so the
@@ -98,14 +101,19 @@ export function StreamOverlayBuilder() {
       params.set('progressTo', branding.to);
     }
     if (token.trim()) params.set('token', token.trim());
-    return `${window.location.origin}/overlay/${id}?${params.toString()}`;
-  }, [printerId, fields, size, fps, showCamera, token, artwork, branding, backgroundTransparency]);
+    const layouts: OverlayLayout[] = layout === 'both' ? ['landscape', 'portrait'] : [layout];
+    return layouts.map((orientation) => {
+      if (orientation === 'portrait') params.set('layout', orientation);
+      else params.delete('layout');
+      return { layout: orientation, url: `${window.location.origin}/overlay/${id}?${params.toString()}` };
+    });
+  }, [printerId, fields, size, fps, showCamera, token, artwork, layout, branding, backgroundTransparency]);
 
   const toggleField = (key: string) => {
     setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
   };
 
-  const copyUrl = async () => {
+  const copyUrl = async (url: string) => {
     try {
       // Same fallback as the token dialog: the clipboard API needs a secure
       // context, and plenty of Bambuddy installs are plain HTTP on a LAN.
@@ -178,6 +186,22 @@ export function StreamOverlayBuilder() {
           </select>
         </div>
 
+        <div>
+          <label htmlFor="overlay-builder-layout" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.layout')}
+          </label>
+          <select id="overlay-builder-layout" value={layout}
+            onChange={(e) => {
+              const value = e.target.value;
+              if (value === 'landscape' || value === 'portrait' || value === 'both') setLayout(value);
+            }}
+            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none">
+            <option value="landscape">{t('streamOverlay.builder.landscape')}</option>
+            <option value="portrait">{t('streamOverlay.builder.portrait')}</option>
+            <option value="both">{t('streamOverlay.builder.both')}</option>
+          </select>
+        </div>
+
         <div>
           <label htmlFor="overlay-builder-artwork" className="block text-sm font-medium text-white mb-1">
             {t('streamOverlay.builder.artwork', 'Artwork')}
@@ -293,31 +317,31 @@ export function StreamOverlayBuilder() {
       <OverlayBrandingControls value={branding} onChange={setBranding} />
 
       <div className="mt-4">
-        <p className="text-sm font-medium text-white mb-1">
-          {t('streamOverlay.builder.urlTitle', 'Overlay URL')}
-        </p>
-        <div className="flex items-center gap-2">
-          <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
-            {url}
-          </code>
-          <button
-            type="button"
-            onClick={() => void copyUrl()}
-            className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
-          >
-            <Copy className="w-4 h-4" />
-            {t('cameraTokens.created.copy', 'Copy')}
-          </button>
-          <a
-            href={url}
-            target="_blank"
-            rel="noopener noreferrer"
-            className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
-          >
-            <ExternalLink className="w-4 h-4" />
-            {t('streamOverlay.builder.open', 'Open')}
-          </a>
-        </div>
+        {outputs.map(({ layout: orientation, url }) => (
+          <fieldset key={orientation} className="min-w-0 mb-3">
+            <legend className="text-sm font-medium text-white mb-1">
+              {t('streamOverlay.builder.orientationUrl', { orientation: t(`streamOverlay.builder.${orientation}`) })}
+            </legend>
+            <p className="text-xs text-bambu-gray mb-2">
+              {t('streamOverlay.builder.sourceDimensions', OVERLAY_DIMENSIONS[orientation])}
+            </p>
+            <div className="flex flex-wrap items-center gap-2">
+              <code className="w-full px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+                {url}
+              </code>
+              <button type="button" onClick={() => void copyUrl(url)}
+                className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90">
+                <Copy className="w-4 h-4" />
+                {t('cameraTokens.created.copy', 'Copy')}
+              </button>
+              <a href={url} target="_blank" rel="noopener noreferrer"
+                className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80">
+                <ExternalLink className="w-4 h-4" />
+                {t('streamOverlay.builder.open', 'Open')}
+              </a>
+            </div>
+          </fieldset>
+        ))}
         {token.trim() && (
           <p className="text-xs text-bambu-gray mt-2">
             {t(
@@ -344,11 +368,20 @@ export function StreamOverlayBuilder() {
             : t('streamOverlay.builder.showPreview', 'Show preview')}
         </button>
         {preview && (
-          <OverlayPreview
-            url={url}
-            logoRevision={branding.logoRevision}
-            title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
-          />
+          <div className="mt-3 flex flex-wrap items-start gap-4">
+            {outputs.map(({ layout: orientation, url }) => (
+              <div key={orientation} className="min-w-0 flex-1 basis-64"
+                style={orientation === 'portrait' ? { maxWidth: 360 } : undefined}>
+                <p className="text-sm text-white mb-2">{t(`streamOverlay.builder.${orientation}`)}</p>
+                <div className="overflow-hidden rounded-md border border-bambu-dark-tertiary">
+                  <OverlayFrame layout={orientation} preview>
+                    <OverlayPreview url={url} logoRevision={branding.logoRevision}
+                      title={t('streamOverlay.builder.orientationPreview', { orientation: t(`streamOverlay.builder.${orientation}`) })} />
+                  </OverlayFrame>
+                </div>
+              </div>
+            ))}
+          </div>
         )}
       </div>
     </div>
@@ -369,6 +402,6 @@ function OverlayPreview({ url, logoRevision, title }: { url: string; logoRevisio
     key={`${source.url}:${source.logoRevision}`}
     src={source.url}
     title={title}
-    className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
+    className="w-full h-full border-0"
   />;
 }

+ 50 - 52
frontend/src/components/UpdatedStreamOverlay.css

@@ -1,16 +1,16 @@
 /* Scoped to the opt-in renderer. Existing OBS sources retain their styling. */
 .updated-overlay {
   --overlay-scale: 1;
-  --overlay-space: clamp(12px, 2.5vmin, 32px);
+  --overlay-space: clamp(12px, calc(2.5 * var(--overlay-vmin, 1vmin)), 32px);
   position: relative;
   isolation: isolate;
   display: flex;
   flex-direction: column;
   justify-content: space-between;
-  min-height: 100vh;
+  min-height: var(--overlay-height, 100vh);
   color: #fff;
   background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
-  font-size: calc(clamp(14px, 2.2vmin, 28px) * var(--overlay-scale));
+  font-size: calc(clamp(14px, calc(2.2 * var(--overlay-vmin, 1vmin)), 28px) * var(--overlay-scale));
   line-height: 1.3;
   font-variant-numeric: tabular-nums;
 }
@@ -59,7 +59,7 @@
   margin-left: auto;
 }
 .updated-overlay__logo > a img {
-  width: clamp(100px, 19vmin, 230px);
+  width: clamp(100px, calc(19 * var(--overlay-vmin, 1vmin)), 230px);
   height: auto;
 }
 .updated-overlay__camera {
@@ -189,54 +189,52 @@
   font-size: 0.65em;
   color: #b8c0c7;
 }
-@media (orientation: portrait) {
-  .updated-overlay {
-    display: grid;
-    font-size: calc(clamp(14px, 3vmin, 36px) * var(--overlay-scale));
-    grid-template-columns: minmax(0, 1fr);
-    grid-template-rows: auto 1fr auto;
-    min-height: 100vh;
-  }
-  .updated-overlay__header {
-    grid-row: 1;
-    align-items: center;
-    background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
-  }
-  .updated-overlay__identity {
-    background: none;
-    padding: 0;
-  }
-  .updated-overlay__camera {
-    position: relative;
-    grid-row: 2;
-    min-height: 30vh;
-    z-index: 0;
-  }
-  .updated-overlay__panel {
-    grid-row: 3;
-    background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
-  }
-  .updated-overlay__summary {
-    flex-direction: column-reverse;
-    align-items: stretch;
-    gap: 0.65em;
-  }
-  .updated-overlay__progress {
-    flex-wrap: wrap;
-    justify-content: space-between;
-    gap: 0.3em;
-  }
-  .updated-overlay__progress > div {
-    flex: 0 0 100%;
-    order: 1;
-  }
-  .updated-overlay__readings {
-    flex-direction: column;
-  }
-  .updated-overlay__temperatures:not(:first-child) {
-    border-top: 1px solid #ffffff35;
-    padding-top: 0.8em;
-  }
+.updated-overlay[data-layout='portrait'] {
+  display: grid;
+  font-size: calc(clamp(14px, calc(3 * var(--overlay-vmin, 1vmin)), 36px) * var(--overlay-scale));
+  grid-template-columns: minmax(0, 1fr);
+  grid-template-rows: auto 1fr auto;
+  min-height: var(--overlay-height, 100vh);
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__header {
+  grid-row: 1;
+  align-items: center;
+  background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__identity {
+  background: none;
+  padding: 0;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__camera {
+  position: relative;
+  grid-row: 2;
+  min-height: calc(var(--overlay-height, 100vh) * 0.3);
+  z-index: 0;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__panel {
+  grid-row: 3;
+  background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__summary {
+  flex-direction: column-reverse;
+  align-items: stretch;
+  gap: 0.65em;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__progress {
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 0.3em;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__progress > div {
+  flex: 0 0 100%;
+  order: 1;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__readings {
+  flex-direction: column;
+}
+.updated-overlay[data-layout='portrait'] .updated-overlay__temperatures:not(:first-child) {
+  border-top: 1px solid #ffffff35;
+  padding-top: 0.8em;
 }
 @media (prefers-reduced-motion: reduce) {
   .updated-overlay__progress > div > div {

+ 23 - 3
frontend/src/components/UpdatedStreamOverlay.tsx

@@ -1,10 +1,13 @@
-import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import './UpdatedStreamOverlay.css';
 import { overlayProgressTextStyle } from '../utils/overlayBranding';
 
+import type { OverlayLayout } from '../utils/overlayLayout';
+
 interface UpdatedStreamOverlayProps {
+  layout?: OverlayLayout;
   backgroundTransparency?: number;
   customLogo?: ReactNode;
   progressBackground?: string;
@@ -35,7 +38,9 @@ function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
     const el = ref.current;
     if (!active || !el) return;
     const measure = () => {
-      const { width, height } = el.getBoundingClientRect();
+      // Layout dimensions exclude the scale applied by OverlayFrame.
+      const width = el.clientWidth;
+      const height = el.clientHeight;
       if (width > 0 && height > 0) {
         setBox((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height }));
       }
@@ -52,6 +57,20 @@ function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
   return active ? box : null;
 }
 
+// Explicit sources keep their orientation; legacy URLs follow the viewport.
+function useOverlayOrientation(layout?: OverlayLayout): OverlayLayout {
+  const [portrait, setPortrait] = useState(() => window.matchMedia('(orientation: portrait)').matches);
+  useEffect(() => {
+    if (layout) return;
+    const media = window.matchMedia('(orientation: portrait)');
+    const update = (event: MediaQueryListEvent) => setPortrait(event.matches);
+    setPortrait(media.matches);
+    media.addEventListener('change', update);
+    return () => media.removeEventListener('change', update);
+  }, [layout]);
+  return layout ?? (portrait ? 'portrait' : 'landscape');
+}
+
 export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   const { t } = useTranslation();
   const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
@@ -72,6 +91,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
     document.body.style.backgroundColor = 'transparent';
     return () => { document.body.style.backgroundColor = previous; };
   }, [backgroundAlpha]);
+  const layout = useOverlayOrientation(props.layout);
   const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
     props;
   const progressTextStyle = overlayProgressTextStyle(props.progressBackground);
@@ -88,7 +108,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   ].filter((stat) => stat.value != null);
 
   return (
-    <div className="updated-overlay" data-size={props.size} data-state={state} style={backgroundStyle}>
+    <div className="updated-overlay" data-size={props.size} data-layout={layout} data-state={state} style={backgroundStyle}>
       <header className="updated-overlay__header">
         {(name || model) && (
           <div className="updated-overlay__identity">

+ 7 - 0
frontend/src/i18n/locales/de.ts

@@ -3632,6 +3632,13 @@ export default {
     builder: {
       backgroundTransparency: 'Hintergrundtransparenz',
       backgroundTransparencyHint: 'Nur die dunklen Hintergründe ausblenden. Text, Logos und Kamera bleiben sichtbar.',
+      layout: 'Anordnung',
+      landscape: 'Querformat',
+      portrait: 'Hochformat',
+      both: 'Beide',
+      orientationUrl: 'URL für {{orientation}}',
+      orientationPreview: 'Vorschau: {{orientation}}',
+      sourceDimensions: 'Browserquelle: {{width}} × {{height}} px',
       artwork: 'Gestaltung',
       artworkClassic: 'Klassisch',
       artworkV2: 'Version 2',

+ 7 - 0
frontend/src/i18n/locales/en.ts

@@ -3662,6 +3662,13 @@ export default {
     builder: {
       backgroundTransparency: 'Background transparency',
       backgroundTransparencyHint: 'Fade the dark backgrounds only. Text, logos and camera stay visible.',
+      layout: 'Layout',
+      landscape: 'Landscape',
+      portrait: 'Portrait',
+      both: 'Both',
+      orientationUrl: '{{orientation}} URL',
+      orientationPreview: '{{orientation}} preview',
+      sourceDimensions: 'Browser source: {{width}} × {{height}} px',
       artwork: 'Artwork',
       artworkClassic: 'Classic',
       artworkV2: 'Version 2',

+ 7 - 0
frontend/src/i18n/locales/es.ts

@@ -3634,6 +3634,13 @@ export default {
     builder: {
       backgroundTransparency: 'Transparencia del fondo',
       backgroundTransparencyHint: 'Atenúa solo los fondos oscuros. El texto, los logotipos y la cámara siguen visibles.',
+      layout: 'Diseño',
+      landscape: 'Horizontal',
+      portrait: 'Vertical',
+      both: 'Ambos',
+      orientationUrl: 'URL {{orientation}}',
+      orientationPreview: 'Vista previa {{orientation}}',
+      sourceDimensions: 'Fuente de navegador: {{width}} × {{height}} px',
       artwork: 'Diseño',
       artworkClassic: 'Clásico',
       artworkV2: 'Versión 2',

+ 7 - 0
frontend/src/i18n/locales/fr.ts

@@ -3620,6 +3620,13 @@ export default {
     builder: {
       backgroundTransparency: 'Transparence du fond',
       backgroundTransparencyHint: 'Estompe uniquement les fonds sombres. Le texte, les logos et la caméra restent visibles.',
+      layout: 'Disposition',
+      landscape: 'Paysage',
+      portrait: 'Format portrait',
+      both: 'Les deux',
+      orientationUrl: 'URL {{orientation}}',
+      orientationPreview: 'Aperçu {{orientation}}',
+      sourceDimensions: 'Source navigateur : {{width}} × {{height}} px',
       artwork: 'Habillage',
       artworkClassic: 'Classique',
       artworkV2: 'Version 2',

+ 7 - 0
frontend/src/i18n/locales/it.ts

@@ -3619,6 +3619,13 @@ export default {
     builder: {
       backgroundTransparency: 'Trasparenza dello sfondo',
       backgroundTransparencyHint: 'Sfuma solo gli sfondi scuri. Testo, loghi e videocamera restano visibili.',
+      layout: 'Disposizione',
+      landscape: 'Orizzontale',
+      portrait: 'Verticale',
+      both: 'Entrambi',
+      orientationUrl: 'URL {{orientation}}',
+      orientationPreview: 'Anteprima {{orientation}}',
+      sourceDimensions: 'Sorgente browser: {{width}} × {{height}} px',
       artwork: 'Grafica',
       artworkClassic: 'Classica',
       artworkV2: 'Versione 2',

+ 7 - 0
frontend/src/i18n/locales/ja.ts

@@ -3632,6 +3632,13 @@ export default {
     builder: {
       backgroundTransparency: '背景の透明度',
       backgroundTransparencyHint: '暗い背景のみを薄くします。テキスト、ロゴ、カメラ映像はそのまま表示されます。',
+      layout: 'レイアウト',
+      landscape: '横向き',
+      portrait: '縦向き',
+      both: '両方',
+      orientationUrl: '{{orientation}}のURL',
+      orientationPreview: '{{orientation}}のプレビュー',
+      sourceDimensions: 'ブラウザソース: {{width}} × {{height}} ピクセル',
       artwork: 'デザイン',
       artworkClassic: 'クラシック',
       artworkV2: 'バージョン 2',

+ 7 - 0
frontend/src/i18n/locales/ko.ts

@@ -3455,6 +3455,13 @@ export default {
     builder: {
       backgroundTransparency: '배경 투명도',
       backgroundTransparencyHint: '어두운 배경만 흐리게 합니다. 텍스트, 로고와 카메라 영상은 그대로 표시됩니다.',
+      layout: '레이아웃',
+      landscape: '가로',
+      portrait: '세로',
+      both: '둘 다',
+      orientationUrl: '{{orientation}} URL 주소',
+      orientationPreview: '{{orientation}} 미리보기',
+      sourceDimensions: '브라우저 소스: {{width}} × {{height}} 픽셀',
       artwork: '디자인',
       artworkClassic: '클래식',
       artworkV2: '버전 2',

+ 7 - 0
frontend/src/i18n/locales/nl.ts

@@ -3662,6 +3662,13 @@ export default {
     builder: {
       backgroundTransparency: 'Achtergrondtransparantie',
       backgroundTransparencyHint: 'Vervaag alleen de donkere achtergronden. Tekst, logo’s en camera blijven zichtbaar.',
+      layout: 'Indeling',
+      landscape: 'Liggend',
+      portrait: 'Staand',
+      both: 'Beide',
+      orientationUrl: 'URL voor {{orientation}}',
+      orientationPreview: 'Voorbeeld: {{orientation}}',
+      sourceDimensions: 'Browserbron: {{width}} × {{height}} px',
       artwork: 'Vormgeving',
       artworkClassic: 'Klassiek',
       artworkV2: 'Versie 2',

+ 7 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3619,6 +3619,13 @@ export default {
     builder: {
       backgroundTransparency: 'Transparência do fundo',
       backgroundTransparencyHint: 'Atenua apenas os fundos escuros. Texto, logotipos e câmera continuam visíveis.',
+      layout: 'Disposição',
+      landscape: 'Paisagem',
+      portrait: 'Retrato',
+      both: 'Ambos',
+      orientationUrl: 'URL em {{orientation}}',
+      orientationPreview: 'Prévia em {{orientation}}',
+      sourceDimensions: 'Fonte do navegador: {{width}} × {{height}} px',
       artwork: 'Visual',
       artworkClassic: 'Clássico',
       artworkV2: 'Versão 2',

+ 7 - 0
frontend/src/i18n/locales/ru.ts

@@ -3447,6 +3447,13 @@ export default {
     builder: {
       backgroundTransparency: "Прозрачность фона",
       backgroundTransparencyHint: "Изменяет только тёмный фон. Текст, логотипы и изображение камеры остаются видимыми.",
+      layout: 'Макет',
+      landscape: 'Альбомный',
+      portrait: 'Портретный',
+      both: 'Оба',
+      orientationUrl: 'URL: {{orientation}}',
+      orientationPreview: 'Предпросмотр: {{orientation}}',
+      sourceDimensions: 'Источник браузера: {{width}} × {{height}} пикс.',
       artwork: 'Оформление',
       artworkClassic: 'Классическое',
       artworkV2: 'Версия 2',

+ 7 - 0
frontend/src/i18n/locales/sv.ts

@@ -3661,6 +3661,13 @@ errors: {
     builder: {
       backgroundTransparency: 'Bakgrundens genomskinlighet',
       backgroundTransparencyHint: 'Tona bara ned de mörka bakgrunderna. Text, logotyper och kamerabild förblir synliga.',
+      layout: 'Utformning',
+      landscape: 'Liggande',
+      portrait: 'Stående',
+      both: 'Båda',
+      orientationUrl: 'URL för {{orientation}}',
+      orientationPreview: 'Förhandsvisning: {{orientation}}',
+      sourceDimensions: 'Webbläsarkälla: {{width}} × {{height}} px',
       artwork: 'Utseende',
       artworkClassic: 'Klassisk',
       artworkV2: 'Version 2',

+ 7 - 0
frontend/src/i18n/locales/tr.ts

@@ -3634,6 +3634,13 @@ export default {
     builder: {
       backgroundTransparency: 'Arka plan saydamlığı',
       backgroundTransparencyHint: 'Yalnızca koyu arka planları soldurur. Metin, logolar ve kamera görünür kalır.',
+      layout: 'Düzen',
+      landscape: 'Yatay',
+      portrait: 'Dikey',
+      both: 'Her ikisi',
+      orientationUrl: '{{orientation}} URL adresi',
+      orientationPreview: '{{orientation}} önizleme',
+      sourceDimensions: 'Tarayıcı kaynağı: {{width}} × {{height}} piksel',
       artwork: 'Tasarım',
       artworkClassic: 'Klasik',
       artworkV2: 'Sürüm 2',

+ 7 - 0
frontend/src/i18n/locales/uk.ts

@@ -3659,6 +3659,13 @@ export default {
     builder: {
       backgroundTransparency: "Прозорість тла",
       backgroundTransparencyHint: "Змінює лише темне тло. Текст, логотипи та зображення камери залишаються видимими.",
+      layout: 'Компонування',
+      landscape: 'Альбомне',
+      portrait: 'Портретне',
+      both: 'Обидва',
+      orientationUrl: 'URL: {{orientation}}',
+      orientationPreview: 'Попередній перегляд: {{orientation}}',
+      sourceDimensions: 'Джерело браузера: {{width}} × {{height}} пікс.',
       artwork: 'Оформлення',
       artworkClassic: 'Класичне',
       artworkV2: 'Версія 2',

+ 7 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3619,6 +3619,13 @@ export default {
     builder: {
       backgroundTransparency: '背景透明度',
       backgroundTransparencyHint: '仅淡化深色背景。文字、徽标和摄像头画面保持可见。',
+      layout: '布局',
+      landscape: '横屏',
+      portrait: '竖屏',
+      both: '两者',
+      orientationUrl: '{{orientation}}链接',
+      orientationPreview: '{{orientation}}预览',
+      sourceDimensions: '浏览器源:{{width}} × {{height}} 像素',
       artwork: '外观',
       artworkClassic: '经典',
       artworkV2: '第 2 版',

+ 7 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3619,6 +3619,13 @@ export default {
     builder: {
       backgroundTransparency: '背景透明度',
       backgroundTransparencyHint: '僅淡化深色背景。文字、標誌和攝影機畫面保持可見。',
+      layout: '版面配置',
+      landscape: '橫向',
+      portrait: '直向',
+      both: '兩者',
+      orientationUrl: '{{orientation}}連結',
+      orientationPreview: '{{orientation}}預覽',
+      sourceDimensions: '瀏覽器來源:{{width}} × {{height}} 像素',
       artwork: '外觀',
       artworkClassic: '經典',
       artworkV2: '第 2 版',

+ 36 - 0
frontend/src/pages/StreamOverlayPage.css

@@ -0,0 +1,36 @@
+/* Explicit layout URLs use a fixed canvas. Legacy URLs keep viewport styling. */
+.classic-overlay[data-layout] {
+  --text-primary: #fff;
+  height: var(--overlay-height);
+  min-height: var(--overlay-height);
+}
+.classic-overlay[data-layout='portrait'] {
+  --classic-scale: 1;
+}
+.classic-overlay[data-layout='portrait'][data-size='small'] {
+  --classic-scale: 0.8;
+}
+.classic-overlay[data-layout='portrait'][data-size='large'] {
+  --classic-scale: 1.2;
+}
+.classic-overlay[data-layout='portrait'] .classic-overlay__text {
+  font-size: calc(30px * var(--classic-scale));
+  line-height: 1.4;
+}
+.classic-overlay[data-layout='portrait'] .classic-overlay__filename {
+  font-size: calc(42px * var(--classic-scale));
+  line-height: 1.2;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+.classic-overlay[data-layout='portrait'] .classic-overlay__panel {
+  padding: 32px;
+}
+.classic-overlay[data-layout='portrait'] .classic-overlay__icon {
+  width: 30px;
+  height: 30px;
+  flex-shrink: 0;
+}
+.classic-overlay[data-layout='portrait'] .classic-overlay__logo {
+  height: 120px;
+}

+ 47 - 33
frontend/src/pages/StreamOverlayPage.tsx

@@ -5,6 +5,9 @@ import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
 import { useOverlayLogo } from '../hooks/useOverlayLogo';
 import { overlayGradient, overlayProgressTextStyle } from '../utils/overlayBranding';
+import { OverlayFrame } from '../components/OverlayFrame';
+import { OVERLAY_DIMENSIONS, type OverlayLayout } from '../utils/overlayLayout';
+import './StreamOverlayPage.css';
 import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
@@ -16,6 +19,7 @@ type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type OverlaySize = 'small' | 'medium' | 'large';
 
 interface OverlayConfig {
+  layout: OverlayLayout | null;
   size: OverlaySize;
   updatedArtwork: boolean;
   backgroundTransparency: number;
@@ -62,7 +66,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
     ? Math.min(100, Math.max(0, transparencyParam))
     : 0;
 
+  const layout = params.get('layout');
   return {
+    layout: layout === 'portrait' || layout === 'landscape' ? layout : null,
     backgroundTransparency,
     size: (params.get('size') as OverlaySize) || 'medium',
     fps,
@@ -122,7 +128,7 @@ function TempReading({ icon, label, current, target, sizes }: TempReadingProps)
   return (
     <div className={`flex items-center ${sizes.gap} text-white/70`}>
       {icon}
-      <span className={sizes.text}>
+      <span className={`classic-overlay__text ${sizes.text}`}>
         <span className="mr-1">{label}</span>
         <span className="text-white">{Math.round(current)}°C</span>
         {heating && (
@@ -194,7 +200,11 @@ export function StreamOverlayPage() {
     <img
       src={logo}
       alt={t('streamOverlay.branding.logo')}
-      className="mb-2 max-h-[14vh] max-w-[20vw] object-contain"
+      className="mb-2 object-contain"
+      style={{
+        maxHeight: config.layout ? OVERLAY_DIMENSIONS[config.layout].height * 0.14 : '14vh',
+        maxWidth: config.layout ? OVERLAY_DIMENSIONS[config.layout].width * 0.2 : '20vw',
+      }}
     />
   ) : null;
 
@@ -328,7 +338,7 @@ export function StreamOverlayPage() {
     );
   }
 
-  const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
+  const isPrinting = status.connected && (status.state === 'RUNNING' || status.state === 'PAUSE');
   const progress = status.progress || 0;
 
   // Temperature readings the URL asked for, in a fixed order, skipping any the
@@ -348,7 +358,7 @@ export function StreamOverlayPage() {
     if (nozzle != null) {
       tempReadings.push({
         key: 'nozzle',
-        icon: <Flame className={sizes.icon} />,
+        icon: <Flame className={`classic-overlay__icon ${sizes.icon}`} />,
         label: t('printers.heaterHistory.nozzle', 'Nozzle'),
         current: nozzle,
         target: readTemp(temps, 'nozzle_target'),
@@ -357,7 +367,7 @@ export function StreamOverlayPage() {
     if (nozzle2 != null) {
       tempReadings.push({
         key: 'nozzle_2',
-        icon: <Flame className={sizes.icon} />,
+        icon: <Flame className={`classic-overlay__icon ${sizes.icon}`} />,
         label: t('printers.heaterHistory.nozzle2', 'Nozzle 2'),
         current: nozzle2,
         target: readTemp(temps, 'nozzle_2_target'),
@@ -369,7 +379,7 @@ export function StreamOverlayPage() {
     if (bed != null) {
       tempReadings.push({
         key: 'bed',
-        icon: <Square className={sizes.icon} />,
+        icon: <Square className={`classic-overlay__icon ${sizes.icon}`} />,
         label: t('printers.heaterHistory.bed', 'Bed'),
         current: bed,
         target: readTemp(temps, 'bed_target'),
@@ -381,7 +391,7 @@ export function StreamOverlayPage() {
     if (chamber != null) {
       tempReadings.push({
         key: 'chamber',
-        icon: <Box className={sizes.icon} />,
+        icon: <Box className={`classic-overlay__icon ${sizes.icon}`} />,
         label: t('printers.heaterHistory.chamber', 'Chamber'),
         current: chamber,
         target: readTemp(temps, 'chamber_target'),
@@ -400,7 +410,8 @@ export function StreamOverlayPage() {
     const active = status.connected && isPrinting;
     const remainingTime = status.remaining_time;
     const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
-    return <UpdatedStreamOverlay
+    const artwork = <UpdatedStreamOverlay
+      layout={config.layout ?? undefined}
       backgroundTransparency={config.backgroundTransparency}
       customLogo={customLogo}
       progressBackground={progressBackground}
@@ -417,10 +428,14 @@ export function StreamOverlayPage() {
       eta={hasRemaining ? formatETA(remainingTime, timeFormat, t) : null}
       temperatures={status.connected ? tempReadings : []}
     />;
+    return config.layout ? <OverlayFrame layout={config.layout}>{artwork}</OverlayFrame> : artwork;
   }
 
-  return (
-    <div className="min-h-screen bg-black relative overflow-hidden">
+  const dimensions = config.layout ? OVERLAY_DIMENSIONS[config.layout] : null;
+  const rotation = printer?.camera_rotation ?? 0;
+  const sideways = Math.abs(rotation % 180) === 90;
+  const artwork = (
+    <div className="classic-overlay min-h-screen bg-black relative overflow-hidden" data-layout={config.layout ?? undefined} data-size={config.size}>
       {/* Camera feed - fullscreen background (optional) */}
       {config.showCamera && (
         <img
@@ -428,7 +443,12 @@ export function StreamOverlayPage() {
           src={streamUrl}
           alt={t('streamOverlay.cameraStream')}
           className="absolute inset-0 w-full h-full object-contain"
-          style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}
+          style={dimensions ? {
+            left: '50%', top: '50%', maxWidth: 'none',
+            width: sideways ? dimensions.height : dimensions.width,
+            height: sideways ? dimensions.width : dimensions.height,
+            transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
+          } : rotation ? { transform: `rotate(${rotation}deg)` } : undefined}
           onError={handleStreamError}
         />
       )}
@@ -444,40 +464,40 @@ export function StreamOverlayPage() {
           <img
             src="/img/bambuddy_logo_dark_transparent.png"
             alt="Bambuddy"
-            className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
+            className={`classic-overlay__logo ${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
           />
         </a>
       </div>
 
       {/* 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}`}>
+        <div className={`classic-overlay__panel ${sizes.container}`}>
           {/* Printer name and model can each be selected independently. */}
           {printerIdentity && (
             <div className={`flex items-center ${sizes.gap} mb-2`}>
-              <Printer className={`${sizes.icon} shrink-0 text-white/70`} />
-              <span className={`${sizes.text} min-w-0 truncate text-white font-medium`}>{printerIdentity}</span>
+              <Printer className={`classic-overlay__icon ${sizes.icon} shrink-0 text-white/70`} />
+              <span className={`classic-overlay__text ${sizes.text} min-w-0 truncate text-white font-medium`}>{printerIdentity}</span>
             </div>
           )}
 
           {/* Filename */}
           {config.showFilename && status.current_print && (
-            <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
+            <div className={`classic-overlay__filename ${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
               {formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t)}
             </div>
           )}
 
           {/* Status text */}
           {config.showStatus && (
-            <div className={`${sizes.text} text-white/70 mb-2`}>
-              {getStatusText(status, t)}
+            <div className={`classic-overlay__text ${sizes.text} text-white/70 mb-2`}>
+              {!status.connected ? t('streamOverlay.printerOffline') : getStatusText(status, t)}
             </div>
           )}
 
           {/* Progress bar */}
           {config.showProgress && isPrinting && (
             <div className="mb-3">
-              <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
+              <div className={`flex items-center justify-between mb-1 classic-overlay__text ${sizes.text}`}>
                 <span className="text-white/70" style={progressTextStyle}>{t('streamOverlay.progress')}</span>
                 <span className="text-white font-bold" style={progressTextStyle}>{Math.round(progress)}%</span>
               </div>
@@ -496,8 +516,8 @@ export function StreamOverlayPage() {
               {/* 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}>
+                  <Layers className={`classic-overlay__icon ${sizes.icon}`} />
+                  <span className={`classic-overlay__text ${sizes.text}`}>
                     <span className="text-white">{status.layer_num}</span>
                     <span className="mx-1">/</span>
                     <span>{status.total_layers}</span>
@@ -509,15 +529,15 @@ export function StreamOverlayPage() {
               {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`}>
+                    <Timer className={`classic-overlay__icon ${sizes.icon}`} />
+                    <span className={`classic-overlay__text ${sizes.text} text-white`}>
                       {formatDuration(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`}>
+                    <Clock className={`classic-overlay__icon ${sizes.icon}`} />
+                    <span className={`classic-overlay__text ${sizes.text} text-white`}>
                       {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
                     </span>
                   </div>
@@ -526,20 +546,13 @@ export function StreamOverlayPage() {
             </div>
           )}
 
-          {/* Idle state */}
-          {!isPrinting && (
-            <div className={`${sizes.text} text-white/70 py-2`}>
-              {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
-            </div>
-          )}
-
           {/* Temperatures (#1422). Rendered whether or not a print is running —
               a preheating or cooling printer is exactly when these are worth
               watching. Each reading appears only when the printer reports it,
               so a single-nozzle machine shows one nozzle and a model without a
               chamber sensor shows no chamber row even if `chamber` is in
               ?show= (the backend omits the reading entirely for those). */}
-          {tempReadings.length > 0 && (
+          {tempReadings.length > 0 && status.connected && (
             <div className={`flex items-center ${sizes.gap} flex-wrap mt-2`}>
               {tempReadings.map((reading) => (
                 <TempReading
@@ -557,4 +570,5 @@ export function StreamOverlayPage() {
       </div>
     </div>
   );
+  return config.layout ? <OverlayFrame layout={config.layout}>{artwork}</OverlayFrame> : artwork;
 }

+ 6 - 0
frontend/src/utils/overlayLayout.ts

@@ -0,0 +1,6 @@
+export type OverlayLayout = 'landscape' | 'portrait';
+
+export const OVERLAY_DIMENSIONS = {
+  landscape: { width: 1920, height: 1080 },
+  portrait: { width: 1080, height: 1920 },
+};

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


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-CKqPOK_d.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-DNvI9563.js


+ 0 - 1
static/assets/chunk-aKtaBQYM.js

@@ -1 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/index-Bgo1crq7.css


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


BIN
static/assets/occt-import-js-BhHfLpto.wasm


File diff suppressed because it is too large
+ 0 - 0
static/assets/papaparse.min-CR43Mv99.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-CbcA-ZGz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf.worker.min-0idpi3xP.js


+ 0 - 1
static/assets/pdf.worker.min-DpOxPwGP.js

@@ -1 +0,0 @@
-var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};

BIN
static/assets/pdfjs/cmaps/78-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-V.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-V.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS2-H.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/CNS2-V.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEáCNS2-H

BIN
static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-V.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEá	ETen-B5-H` ^

BIN
static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/EUC-H.bcmap


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