Add camera image rotation option (#672)

Per-printer camera rotation (0°/90°/180°/270°) for cameras mounted
  in portrait or upside-down. CSS rotation for live views, Pillow
  rotation for notification snapshots. Setting visible in external
  camera config when enabled.
This commit is contained in:
maziggy
2026-03-17 13:59:01 +01:00
parent aeae5cfa23
commit fe3c1983af
19 changed files with 129 additions and 69 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.3b1] - Unreleased
### New Features
- **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
- **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
### Fixed
+7 -3
View File
@@ -1481,13 +1481,17 @@ async def run_migrations(conn):
# Legacy migration: Add notify_print_stopped column (for any existing partial tables)
try:
await conn.execute(
text(
"ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1"
)
text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
)
except OperationalError:
pass # Column already exists or table created with full schema
# Migration: Add camera_rotation column to printers
try:
await conn.execute(text("ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0"))
except OperationalError:
pass # Already applied
# Seed default settings keys that must exist on fresh install
default_settings = [
("advanced_auth_enabled", "false"),
+27 -3
View File
@@ -1033,7 +1033,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
if frame_data and len(frame_data) <= 2_500_000:
logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
return frame_data
return _apply_camera_rotation(frame_data, printer, logger)
# Try buffered frame from active stream
from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
@@ -1045,7 +1045,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
if (active_for_printer or active_chamber) and buffered_frame:
logger.info("[SNAPSHOT] Using buffered frame for printer %s: %s bytes", printer_id, len(buffered_frame))
if len(buffered_frame) <= 2_500_000:
return buffered_frame
return _apply_camera_rotation(buffered_frame, printer, logger)
# Fresh capture from printer camera
logger.info("[SNAPSHOT] Capturing fresh frame for printer %s", printer_id)
@@ -1056,7 +1056,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
)
if frame_data and len(frame_data) <= 2_500_000:
logger.info("[SNAPSHOT] Fresh camera frame: %s bytes", len(frame_data))
return frame_data
return _apply_camera_rotation(frame_data, printer, logger)
except Exception as e:
logger.warning("[SNAPSHOT] Failed to capture snapshot for printer %s: %s", printer_id, e)
@@ -1064,6 +1064,30 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
return None
def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
"""Apply camera rotation to snapshot image if configured."""
rotation = getattr(printer, "camera_rotation", 0)
if not rotation or rotation == 0:
return image_data
try:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(image_data))
# PIL rotate is counter-clockwise, so negate for clockwise rotation
img = img.rotate(-rotation, expand=True)
buf = BytesIO()
img.save(buf, format="JPEG", quality=90)
rotated = buf.getvalue()
logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
return rotated
except Exception as e:
logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
return image_data
async def _send_print_start_notification(
printer_id: int,
data: dict,
+1
View File
@@ -28,6 +28,7 @@ class Printer(Base):
external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # mjpeg, rtsp, snapshot
external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
camera_rotation: Mapped[int] = mapped_column(default=0) # 0, 90, 180, 270 degrees
# Plate detection - check if build plate is empty before starting print
plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# ROI for plate detection (percentages: 0.0-1.0)
+4
View File
@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
external_camera_url: str | None = None
external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb"
external_camera_enabled: bool = False
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
class PrinterCreate(PrinterBase):
@@ -49,6 +50,7 @@ class PrinterUpdate(BaseModel):
external_camera_url: str | None = None
external_camera_type: str | None = None
external_camera_enabled: bool | None = None
camera_rotation: int | None = None # 0, 90, 180, 270 degrees
plate_detection_enabled: bool | None = None
plate_detection_roi: PlateDetectionROI | None = None
@@ -61,6 +63,7 @@ class PrinterResponse(PrinterBase):
external_camera_url: str | None = None
external_camera_type: str | None = None
external_camera_enabled: bool = False
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
plate_detection_enabled: bool = False
plate_detection_roi: PlateDetectionROI | None = None
created_at: datetime
@@ -84,6 +87,7 @@ class PrinterResponse(PrinterBase):
"external_camera_url": printer.external_camera_url,
"external_camera_type": printer.external_camera_type,
"external_camera_enabled": printer.external_camera_enabled,
"camera_rotation": printer.camera_rotation,
"is_active": printer.is_active,
"nozzle_count": printer.nozzle_count,
"print_hours_offset": printer.print_hours_offset,
+2
View File
@@ -99,6 +99,7 @@ export interface Printer {
external_camera_url: string | null;
external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot"
external_camera_enabled: boolean;
camera_rotation: number; // 0, 90, 180, 270 degrees
plate_detection_enabled: boolean; // Check plate before print
plate_detection_roi?: PlateDetectionROI; // ROI for plate detection
created_at: string;
@@ -279,6 +280,7 @@ export interface PrinterCreate {
external_camera_url?: string | null;
external_camera_type?: string | null;
external_camera_enabled?: boolean;
camera_rotation?: number;
plate_detection_enabled?: boolean;
plate_detection_roi?: PlateDetectionROI;
}
@@ -686,7 +686,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
alt="Camera stream"
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
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%' } : {}),
cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
}}
onError={handleStreamError}
+1
View File
@@ -1763,6 +1763,7 @@ export default {
cameraTypeRtsp: 'RTSP-Stream',
cameraTypeSnapshot: 'HTTP-Snapshot',
cameraTypeUsb: 'USB-Kamera (V4L2)',
cameraRotation: 'Drehung',
test: 'Testen',
connected: 'Verbunden',
disconnected: 'Getrennt',
+1
View File
@@ -1763,6 +1763,7 @@ export default {
cameraTypeRtsp: 'RTSP Stream',
cameraTypeSnapshot: 'HTTP Snapshot',
cameraTypeUsb: 'USB Camera (V4L2)',
cameraRotation: 'Rotation',
test: 'Test',
connected: 'Connected',
disconnected: 'Disconnected',
+1
View File
@@ -1760,6 +1760,7 @@ export default {
cameraTypeRtsp: 'Flux RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Caméra USB (V4L2)',
cameraRotation: 'Rotation',
test: 'Tester',
connected: 'Connecté',
disconnected: 'Déconnecté',
+1
View File
@@ -1759,6 +1759,7 @@ export default {
cameraTypeRtsp: 'Stream RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Fotocamera USB (V4L2)',
cameraRotation: 'Rotazione',
test: 'Test',
connected: 'Connesso',
disconnected: 'Disconnesso',
+1
View File
@@ -1762,6 +1762,7 @@ export default {
cameraTypeRtsp: 'RTSPストリーム',
cameraTypeSnapshot: 'HTTPスナップショット',
cameraTypeUsb: 'USBカメラ (V4L2)',
cameraRotation: '回転',
test: 'テスト',
connected: '接続済み',
disconnected: '未接続',
+1
View File
@@ -1759,6 +1759,7 @@ export default {
cameraTypeRtsp: 'Stream RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Câmera USB (V4L2)',
cameraRotation: 'Rotação',
test: 'Testar',
connected: 'Conectado',
disconnected: 'Desconectado',
+1
View File
@@ -1759,6 +1759,7 @@ export default {
cameraTypeRtsp: 'RTSP 流',
cameraTypeSnapshot: 'HTTP 快照',
cameraTypeUsb: 'USB 摄像头 (V4L2)',
cameraRotation: '旋转',
test: '测试',
connected: '已连接',
disconnected: '未连接',
+2 -1
View File
@@ -732,7 +732,8 @@ export function CameraPage() {
alt={t('camera.cameraStream')}
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
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' } : {}),
cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
}}
onError={currentUrl ? handleStreamError : undefined}
+17 -3
View File
@@ -673,7 +673,7 @@ export function SettingsPage() {
});
const updatePrinterMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean }> }) =>
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>
api.updatePrinter(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
@@ -892,10 +892,11 @@ export function SettingsPage() {
}, 800);
};
const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean }) => {
const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean }> = {};
const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
if (updates.type !== undefined) data.external_camera_type = updates.type || null;
if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;
updatePrinterMutation.mutate({ id: printerId, data });
};
@@ -1479,6 +1480,19 @@ export function SettingsPage() {
)}
</div>
)}
<div className="flex items-center gap-2">
<label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
<select
value={printer.camera_rotation || 0}
onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
>
<option value={0}>0°</option>
<option value={90}>90°</option>
<option value={180}>180°</option>
<option value={270}>270°</option>
</select>
</div>
</div>
)}
</div>
+1
View File
@@ -202,6 +202,7 @@ 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}
onError={handleStreamError}
/>
)}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CCS49h9H.js"></script>
<script type="module" crossorigin src="/assets/index-knbatSiK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hKLgfdEQ.css">
</head>
<body>