mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1763,6 +1763,7 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP-Stream',
|
||||
cameraTypeSnapshot: 'HTTP-Snapshot',
|
||||
cameraTypeUsb: 'USB-Kamera (V4L2)',
|
||||
cameraRotation: 'Drehung',
|
||||
test: 'Testen',
|
||||
connected: 'Verbunden',
|
||||
disconnected: 'Getrennt',
|
||||
|
||||
@@ -1763,6 +1763,7 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP Stream',
|
||||
cameraTypeSnapshot: 'HTTP Snapshot',
|
||||
cameraTypeUsb: 'USB Camera (V4L2)',
|
||||
cameraRotation: 'Rotation',
|
||||
test: 'Test',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -1759,6 +1759,7 @@ export default {
|
||||
cameraTypeRtsp: 'Stream RTSP',
|
||||
cameraTypeSnapshot: 'Snapshot HTTP',
|
||||
cameraTypeUsb: 'Fotocamera USB (V4L2)',
|
||||
cameraRotation: 'Rotazione',
|
||||
test: 'Test',
|
||||
connected: 'Connesso',
|
||||
disconnected: 'Disconnesso',
|
||||
|
||||
@@ -1762,6 +1762,7 @@ export default {
|
||||
cameraTypeRtsp: 'RTSPストリーム',
|
||||
cameraTypeSnapshot: 'HTTPスナップショット',
|
||||
cameraTypeUsb: 'USBカメラ (V4L2)',
|
||||
cameraRotation: '回転',
|
||||
test: 'テスト',
|
||||
connected: '接続済み',
|
||||
disconnected: '未接続',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1759,6 +1759,7 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP 流',
|
||||
cameraTypeSnapshot: 'HTTP 快照',
|
||||
cameraTypeUsb: 'USB 摄像头 (V4L2)',
|
||||
cameraRotation: '旋转',
|
||||
test: '测试',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user