Kaynağa Gözat

fix(camera): a camera turned sideways fits the camera window (issue #3362)

- At 90 or 270 degrees the picture was laid out at the unswapped size
  and then CSS-rotated, which doesn't change layout: the floating camera
  window cut it off when wide and boxed it in black bars when tall. The
  window now measures its video area and gives the image the area's
  height as its maximum width and its width as the maximum height, so
  it fits every resize.
- The measuring hook from the newer stream overlay (measured, not
  container units, for older OBS and kiosk browsers) moved to
  hooks/useSidewaysBox.ts and is shared.
- Same fix for Cam Wall tiles (a portrait camera was cropped at the
  top and bottom), the classic stream overlay without a fixed layout,
  and the camera page, which swapped against the viewport instead of
  the space below its header.
maziggy 1 gün önce
ebeveyn
işleme
8da96b492c

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A camera turned sideways fits the camera window (#3362, reported by @BurgerKerman)** — With a camera's rotation set to 90 or 270 degrees, the camera window that floats over the page laid the picture out as if it were still landscape and then turned it, so it was cut off in a wide window and surrounded by black bars in a tall one. It now fits whatever size the window is resized to, as the camera page opened in its own browser window already did. The same happened to Cam Wall tiles, where a portrait camera was cut off at the top and bottom, and to the classic stream overlay without a fixed layout; both are fixed too. The camera page in its own window sized the picture to the screen rather than the space below its header, which cut a little off the top and bottom in a wide window; it fits the space now.
 - **A SpoolBuddy kiosk no longer gets stuck on the login page during a database problem (#3360, reported by @sargonas)** — A kiosk signs in with its API key from the launch URL. When it loaded while Bambuddy's database was unavailable (out of connections, or a read-only replica after a failover), it gave up after three quick tries and showed the login page, which a touch-only screen can't use, until the next reboot. Pages now wait on a "Can't reach Bambuddy. Retrying…" screen and keep trying in the background, then show the page as soon as Bambuddy answers. A key or session Bambuddy reports as invalid, expired or belonging to a deactivated account still goes to the login page. This also covers browser tabs with a saved sign-in, which used to show the login page, or the app as if sign-in were off, when they loaded during an outage. Writing an API key's last-used time no longer refuses a key that has already been checked: if the write fails, the key is accepted and the failure logged. That time is now written at most once a minute instead of on every request, which takes one write and commit off almost every request made with a key. A write that fails is not tried again for a minute either. Camera tokens for the Cam Wall, stream overlays and camera streams get the same treatment: a failed last-used write no longer refuses a valid token.
 - **Slicing for the Cool Plate SuperTack works (#3359, reported by @Akaviiii)** — Picking **Cool Plate SuperTack** in the Slice dialog sent a plate name neither Bambu Studio nor OrcaSlicer knows. The slicer then sliced for the Cool Plate and failed with "Filaments are not compatible with the plate type". **Smooth PEI Plate** had the same problem: the slicers call that plate High Temp Plate, so the dialog now offers it once, as **Smooth PEI Plate / High Temp Plate**. Slicer pipelines saved with either option slice on the right plate without being saved again. A plate name no slicer knows is now refused when slicing through the API or saving a pipeline, instead of being sliced as Cool Plate, and so is OrcaSlicer's Textured Cool Plate when Bambu Studio does the slicing. A pipeline holding a plate the dialog doesn't list shows that plate when it is applied, instead of showing Auto, and a run of a pipeline with a plate no slicer knows fails with a message saying so. Files sliced for OrcaSlicer's Textured Cool Plate now also record their bed temperature.
 - **The MQTT Debug log shows what the printer sent** — Each logged message was the same object Bambuddy then processed, and processing writes Bambuddy's own state into it. An incoming push therefore showed Bambuddy's merged AMS list instead of the printer's AMS block, without fields such as `ams_exist_bits` and still listing an AMS that had been unplugged, and later messages could gain an AMS list they never carried. The log now keeps every incoming message exactly as it arrived.

+ 35 - 0
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -520,3 +520,38 @@ describe('CameraTile', () => {
     });
   });
 });
+
+// A portrait camera in a 16:9 tile was turned at the tile's size and cut off
+// at the top and bottom (#3362).
+describe('CameraTile with a camera turned sideways', () => {
+  beforeEach(() => {
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+    const isTile = (el: HTMLElement) => el.classList.contains('aspect-video');
+    vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+      return isTile(this) ? 320 : 0;
+    });
+    vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
+      return isTile(this) ? 180 : 0;
+    });
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it.each([90, 270])('lays a camera turned %i degrees out at the tile with its sides swapped', async (rotation) => {
+    render(<CameraTile printerId={42} printerName="A1 Mini" cameraRotation={rotation} mode="live" snapshotIntervalMs={5000} connected />);
+
+    const img = await screen.findByAltText('A1 Mini');
+    expect(img).toHaveStyle({ position: 'absolute', width: '180px', height: '320px' });
+    expect(img.style.transform).toBe(`translate(-50%, -50%) rotate(${rotation}deg)`);
+  });
+
+  it('only turns an upside-down camera', async () => {
+    render(<CameraTile printerId={42} printerName="A1 Mini" cameraRotation={180} mode="live" snapshotIntervalMs={5000} connected />);
+
+    const img = await screen.findByAltText('A1 Mini');
+    expect(img.style.transform).toBe('rotate(180deg)');
+    expect(img.style.width).toBe('');
+  });
+});

+ 49 - 0
frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx

@@ -114,3 +114,52 @@ describe('EmbeddedCameraViewer stream lifetime', () => {
     expect(screen.getByAltText('Camera stream').getAttribute('src')).toContain('/api/v1/printers/7/camera/stream');
   });
 });
+
+// A sideways camera was laid out at the window's unswapped size and then
+// turned: cut off in a wide window, boxed in by black bars in a tall one (#3362).
+describe('EmbeddedCameraViewer with a camera turned sideways', () => {
+  function sizeVideoArea(width: number, height: number) {
+    const isVideoArea = (el: HTMLElement) => el.querySelector(':scope > img[alt="Camera stream"]') !== null;
+    vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+      return isVideoArea(this) ? width : 0;
+    });
+    vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
+      return isVideoArea(this) ? height : 0;
+    });
+  }
+
+  beforeEach(() => {
+    localStorage.clear();
+    server.use(http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })));
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it.each([
+    [270, 400, 260, { maxWidth: '260px', maxHeight: '400px' }],
+    [90, 260, 400, { maxWidth: '400px', maxHeight: '260px' }],
+  ])('fits a camera turned %i degrees into a %ix%i window', async (rotation, width, height, expected) => {
+    sizeVideoArea(width, height);
+    server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ id: 7, name: 'A1 Mini', camera_rotation: rotation })));
+
+    render(viewer());
+
+    const img = await screen.findByAltText('Camera stream');
+    await waitFor(() => expect(img.style.transform).toContain(`rotate(${rotation}deg)`));
+    await waitFor(() => expect(img).toHaveStyle(expected));
+  });
+
+  it('leaves an upright camera alone', async () => {
+    sizeVideoArea(400, 260);
+    server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ id: 7, name: 'A1 Mini', camera_rotation: 180 })));
+
+    render(viewer());
+
+    const img = await screen.findByAltText('Camera stream');
+    await waitFor(() => expect(img.style.transform).toContain('rotate(180deg)'));
+    expect(img.style.maxWidth).toBe('');
+    expect(img.style.maxHeight).toBe('');
+  });
+});

+ 31 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -118,6 +118,37 @@ describe('CameraPage', () => {
     });
   });
 
+  // The viewport was the box a sideways camera was swapped against, but the
+  // header and padding leave the video area smaller, so a wide window cut the
+  // picture off at the top and bottom (#3362).
+  describe('a camera turned sideways', () => {
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it.each([
+      [90, { maxWidth: '1000px', maxHeight: '1880px' }],
+      [270, { maxWidth: '1000px', maxHeight: '1880px' }],
+    ])('fits a camera turned %i degrees into the video area', async (rotation, expected) => {
+      const isVideoArea = (el: HTMLElement) => el.querySelector(':scope > img') !== null && el.classList.contains('h-full');
+      vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+        return isVideoArea(this) ? 1880 : 0;
+      });
+      vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
+        return isVideoArea(this) ? 1000 : 0;
+      });
+      server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
+
+      renderCameraPage(1);
+
+      await waitFor(() => {
+        const img = document.querySelector('img') as HTMLImageElement;
+        expect(img.style.transform).toContain(`rotate(${rotation}deg)`);
+        expect(img).toHaveStyle(expected);
+      });
+    });
+  });
+
   describe('camera controls', () => {
     it('renders without crashing', async () => {
       renderCameraPage(1);

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

@@ -331,6 +331,33 @@ describe('StreamOverlayPage', () => {
       }
     });
 
+    it.each([
+      [90, { width: '720px', height: '1280px' }],
+      [0, null],
+    ])('fits a classic overlay camera turned %i degrees into the window (#3362)', async (rotation, expected) => {
+      const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+        return this.classList.contains('classic-overlay') ? 1280 : 0;
+      });
+      const height = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
+        return this.classList.contains('classic-overlay') ? 720 : 0;
+      });
+      try {
+        server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
+        renderOverlayPage(1, '');
+        const camera = await screen.findByAltText('Camera stream');
+        if (expected) {
+          await waitFor(() => expect(camera).toHaveStyle(expected));
+          expect(camera.style.transform).toBe(`translate(-50%, -50%) rotate(${rotation}deg)`);
+        } else {
+          await waitFor(() => expect(camera.style.transform).toBe(''));
+          expect(camera.style.width).toBe('');
+        }
+      } finally {
+        width.mockRestore();
+        height.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');

+ 25 - 5
frontend/src/components/CameraTile.tsx

@@ -1,10 +1,11 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
 import { useTranslation } from 'react-i18next';
 import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
 import { formatDuration } from '../utils/date';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
+import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 
 export type CameraTileMode = 'live' | 'snapshot' | 'paused';
 export type CameraTileStatusMode = 'off' | 'compact' | 'full';
@@ -236,7 +237,26 @@ export function CameraTile({
   // clickable and then does nothing.
   const interactive = onClick != null;
 
-  const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
+  // A sideways camera is laid out at the tile's sides swapped and centred, or
+  // the rotated picture is cut off at the tile's top and bottom (#3362).
+  // One ref for either root: the tile is a button when it can be clicked.
+  const tileRef = useRef<HTMLDivElement & HTMLButtonElement>(null);
+  const sideways = isSideways(cameraRotation);
+  const tile = useSidewaysBox(tileRef, sideways);
+  const imgStyle: CSSProperties | undefined =
+    sideways && tile
+      ? {
+          position: 'absolute',
+          left: '50%',
+          top: '50%',
+          width: tile.height,
+          height: tile.width,
+          maxWidth: 'none',
+          transform: `translate(-50%, -50%) rotate(${cameraRotation}deg)`,
+        }
+      : cameraRotation
+        ? { transform: `rotate(${cameraRotation}deg)` }
+        : undefined;
 
   const bucket = classifyState(printerState, hmsErrorCount);
   // Hide chip for idle to keep cold walls clean; always show when something
@@ -277,7 +297,7 @@ export function CameraTile({
           draggable={false}
           loading="lazy"
           className="h-full w-full select-none object-contain"
-          style={{ transform }}
+          style={imgStyle}
           onLoad={() => {
             clearFirstFrameTimer();
             renewalRef.current = 'none';
@@ -365,14 +385,14 @@ export function CameraTile({
 
   if (!interactive) {
     return (
-      <div className={rootClass} title={printerName}>
+      <div ref={tileRef} className={rootClass} title={printerName}>
         {content}
       </div>
     );
   }
 
   return (
-    <button type="button" onClick={onClick} className={rootClass} title={printerName}>
+    <button ref={tileRef} type="button" onClick={onClick} className={rootClass} title={printerName}>
       {content}
     </button>
   );

+ 13 - 1
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -10,6 +10,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
+import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 
 interface EmbeddedCameraViewerProps {
   printerId: number;
@@ -118,6 +119,12 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     enabled: printerId > 0,
   });
 
+  // A camera turned sideways is laid out with the video area's sides swapped,
+  // or it is cut off in a wide window and boxed in a tall one (#3362).
+  const videoAreaRef = useRef<HTMLDivElement>(null);
+  const sideways = isSideways(printer?.camera_rotation);
+  const videoArea = useSidewaysBox(videoAreaRef, sideways && !isMinimized);
+
   // Fetch printer status for light toggle and skip objects
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printerId],
@@ -656,6 +663,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
       {/* Video area */}
       {!isMinimized && (
         <div
+          ref={videoAreaRef}
           className={`relative w-full bg-black flex items-center justify-center overflow-hidden ${isFullscreen ? 'h-[calc(100%-40px)]' : 'h-[calc(100%-40px)]'}`}
           onWheel={handleWheel}
           onMouseMove={handleImageMouseMove}
@@ -711,7 +719,11 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             className="max-w-full max-h-full object-contain select-none"
             style={{
               transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
-              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100%', maxHeight: '100%' } : {}),
+              ...(sideways
+                ? videoArea
+                  ? { maxWidth: videoArea.height, maxHeight: videoArea.width }
+                  : { maxWidth: '100%', maxHeight: '100%' }
+                : {}),
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
             }}
             onError={handleStreamError}

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

@@ -1,8 +1,9 @@
-import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref, type RefObject, type SyntheticEvent } from 'react';
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref, type SyntheticEvent } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import './UpdatedStreamOverlay.css';
 import { overlayProgressTextStyle } from '../utils/overlayBranding';
+import { useSidewaysBox } from '../hooks/useSidewaysBox';
 
 import type { OverlayLayout } from '../utils/overlayLayout';
 
@@ -31,38 +32,6 @@ interface UpdatedStreamOverlayProps {
   temperatures: { key: string; icon: ReactNode; label: string; current: number; target: number | null }[];
 }
 
-// The camera slot's size in px, measured only while the camera is turned
-// sideways. Measured rather than written as 100cqh/100cqw because container
-// units need Chromium 105+, which older OBS browser sources don't have, and
-// the viewport units they would fall back to are wrong for the portrait
-// layout, where the camera fills only the middle row.
-function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
-  const [box, setBox] = useState<{ width: number; height: number } | null>(null);
-  // Layout effect: measured before paint, so a sideways camera never shows a
-  // frame at the unswapped size.
-  useLayoutEffect(() => {
-    const el = ref.current;
-    if (!active || !el) return;
-    const measure = () => {
-      // 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 }));
-      }
-    };
-    measure();
-    if (typeof ResizeObserver === 'undefined') {
-      window.addEventListener('resize', measure);
-      return () => window.removeEventListener('resize', measure);
-    }
-    const observer = new ResizeObserver(measure);
-    observer.observe(el);
-    return () => observer.disconnect();
-  }, [ref, active]);
-  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);
@@ -106,7 +75,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   const rotation = camera?.rotation ?? 0;
   const sideways = Math.abs(rotation % 180) === 90;
   const cameraRef = useRef<HTMLDivElement>(null);
-  const cameraBox = useCameraBox(cameraRef, sideways && camera != null);
+  const cameraBox = useSidewaysBox(cameraRef, sideways && camera != null);
   const stats = [
     { key: 'layers', icon: <Layers />, label: t('streamOverlay.layer'), value: layers },
     { key: 'remaining', icon: <Timer />, label: t('streamOverlay.remaining'), value: remaining },

+ 43 - 0
frontend/src/hooks/useSidewaysBox.ts

@@ -0,0 +1,43 @@
+import { useLayoutEffect, useState, type RefObject } from 'react';
+
+/**
+ * The size in px of the box a camera image is shown in, measured only while
+ * the camera is turned sideways (90 or 270 degrees). CSS rotate() does not
+ * change layout, so a quarter-turned image must be laid out with the box's
+ * height as its width and vice versa (#3362).
+ *
+ * Measured rather than written as 100cqh/100cqw because container units need
+ * Chromium 105+, which older OBS browser sources and kiosk browsers don't
+ * have, and viewport units are wrong for anything smaller than the window.
+ */
+export function useSidewaysBox(ref: RefObject<HTMLElement | null>, active: boolean) {
+  const [box, setBox] = useState<{ width: number; height: number } | null>(null);
+  // Layout effect: measured before paint, so a sideways camera never shows a
+  // frame at the unswapped size.
+  useLayoutEffect(() => {
+    const el = ref.current;
+    if (!active || !el) return;
+    const measure = () => {
+      // Layout dimensions, so a transform scale on an ancestor is left out.
+      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 }));
+      }
+    };
+    measure();
+    if (typeof ResizeObserver === 'undefined') {
+      window.addEventListener('resize', measure);
+      return () => window.removeEventListener('resize', measure);
+    }
+    const observer = new ResizeObserver(measure);
+    observer.observe(el);
+    return () => observer.disconnect();
+  }, [ref, active]);
+  return active ? box : null;
+}
+
+/** Whether a camera rotation turns the image on its side. */
+export function isSideways(rotation: number | null | undefined): boolean {
+  return Math.abs((rotation ?? 0) % 180) === 90;
+}

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

@@ -12,6 +12,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
 import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
+import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 // The server's Retry-After for a snapshot while a live view starts.
@@ -659,6 +660,13 @@ export function CameraPage() {
 
   const isDisabled = streamLoading || transitioning || isReconnecting;
 
+  // A sideways camera is laid out with the video area's sides swapped. The
+  // viewport is the wrong box: the header and padding leave the area smaller,
+  // and a wide window cut the picture off at the top and bottom (#3362).
+  const videoAreaRef = useRef<HTMLDivElement>(null);
+  const sideways = isSideways(printer?.camera_rotation);
+  const videoArea = useSidewaysBox(videoAreaRef, sideways && id > 0);
+
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
@@ -764,7 +772,7 @@ export function CameraPage() {
         onTouchEnd={handleTouchEnd}
         style={{ touchAction: 'none' }}
       >
-        <div className="relative w-full h-full flex items-center justify-center">
+        <div ref={videoAreaRef} className="relative w-full h-full flex items-center justify-center">
           {(streamLoading || transitioning) && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
               <div className="text-center">
@@ -825,7 +833,11 @@ export function CameraPage() {
             className="max-w-full max-h-full object-contain select-none"
             style={{
               transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
-              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100vh', maxHeight: '100vw' } : {}),
+              ...(sideways
+                ? videoArea
+                  ? { maxWidth: videoArea.height, maxHeight: videoArea.width }
+                  : { maxWidth: '100vh', maxHeight: '100vw' }
+                : {}),
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
             }}
             onError={currentUrl ? handleStreamError : undefined}

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

@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -13,6 +13,7 @@ import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { mapModelCode } from '../utils/printerModel';
 import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
+import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
@@ -326,6 +327,14 @@ export function StreamOverlayPage() {
     };
   }, [printer, t]);
 
+  // The classic artwork without a fixed layout fills the window: a sideways
+  // camera is laid out at the window's sides swapped, or it is cut off (#3362).
+  const artworkRef = useRef<HTMLDivElement>(null);
+  const sideways = isSideways(printer?.camera_rotation);
+  // Measured once the artwork is on screen, which is after the status loads.
+  const fitsWindow = sideways && !config.layout && !config.updatedArtwork && config.showCamera && status != null;
+  const windowBox = useSidewaysBox(artworkRef, fitsWindow);
+
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
@@ -437,9 +446,8 @@ export function StreamOverlayPage() {
 
   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}>
+    <div ref={artworkRef} 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
@@ -453,6 +461,11 @@ export function StreamOverlayPage() {
             width: sideways ? dimensions.height : dimensions.width,
             height: sideways ? dimensions.width : dimensions.height,
             transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
+          } : windowBox ? {
+            left: '50%', top: '50%', maxWidth: 'none',
+            width: windowBox.height,
+            height: windowBox.width,
+            transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
           } : rotation ? { transform: `rotate(${rotation}deg)` } : undefined}
           onError={handleStreamError}
         />