diff --git a/custom_components/camera_snapshot_processor/frontend/panel.js b/custom_components/camera_snapshot_processor/frontend/panel.js
index 76bb0da..5f7e7a5 100644
--- a/custom_components/camera_snapshot_processor/frontend/panel.js
+++ b/custom_components/camera_snapshot_processor/frontend/panel.js
@@ -17,7 +17,9 @@
let cropResizing = false;
let cropStartX = 0;
let cropStartY = 0;
- let currentTab = 'dimensions';
+ let currentTab = 'crop';
+ let sourceImageData = null; // Store the source image for cropping
+ let cropPreviewDebounceTimer = null;
// ==================== Custom Notification System ====================
@@ -345,10 +347,16 @@
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
- tab.addEventListener('click', () => {
+ tab.addEventListener('click', async () => {
currentTab = tab.dataset.tab;
switchTab(currentTab);
- updateCropVisibility();
+ await updateCropVisibility();
+
+ // Refresh preview when switching away from crop tab
+ // This ensures overlays/state icons show on the cropped result
+ if (currentTab !== 'crop') {
+ schedulePreviewUpdate();
+ }
});
});
@@ -369,13 +377,14 @@
});
// Crop toggle
- document.getElementById('crop_enabled').addEventListener('change', (e) => {
+ document.getElementById('crop_enabled').addEventListener('change', async (e) => {
const isEnabled = e.target.checked;
document.getElementById('crop-controls').style.display = isEnabled ? 'block' : 'none';
// Enable or disable interactive cropping based on whether image is already cropped
updateCropInteractionState();
- updateCropVisibility();
+ await updateCropVisibility();
+ updateEffectiveSourceSize();
});
// Reset crop button
@@ -463,6 +472,23 @@
}
});
+ // Opacity sliders - update value display
+ const overlayBackgroundOpacitySlider = document.getElementById('overlay_background_opacity');
+ const overlayBackgroundOpacityValue = document.getElementById('overlay_background_opacity-value');
+ if (overlayBackgroundOpacitySlider && overlayBackgroundOpacityValue) {
+ overlayBackgroundOpacitySlider.addEventListener('input', (e) => {
+ overlayBackgroundOpacityValue.textContent = e.target.value;
+ });
+ }
+
+ const stateIconBackgroundOpacitySlider = document.getElementById('state_icon_background_opacity');
+ const stateIconBackgroundOpacityValue = document.getElementById('state_icon_background_opacity-value');
+ if (stateIconBackgroundOpacitySlider && stateIconBackgroundOpacityValue) {
+ stateIconBackgroundOpacitySlider.addEventListener('input', (e) => {
+ stateIconBackgroundOpacityValue.textContent = e.target.value;
+ });
+ }
+
// Setup interactive crop
setupCropInteraction();
@@ -1036,6 +1062,7 @@
if (overlayBackgroundPickr) {
overlayBackgroundPickr.setColor(bgColor);
}
+ setInputValue('overlay_background_opacity', config.overlay_background_opacity !== undefined ? config.overlay_background_opacity : 1.0);
// State icon background
const stateIconBgColor = config.state_icon_background || '#00000000';
@@ -1043,6 +1070,7 @@
if (stateIconBackgroundPickr) {
stateIconBackgroundPickr.setColor(stateIconBgColor);
}
+ setInputValue('state_icon_background_opacity', config.state_icon_background_opacity !== undefined ? config.state_icon_background_opacity : 1.0);
// Stream
setInputValue('rtsp_url', config.rtsp_url || '');
@@ -1091,7 +1119,9 @@
text_font_size: parseInt(document.getElementById('text_font_size').value),
overlay_color: hexToRgb(document.getElementById('overlay_color').value),
overlay_background: document.getElementById('overlay_background').value,
+ overlay_background_opacity: parseFloat(document.getElementById('overlay_background_opacity').value),
state_icon_background: document.getElementById('state_icon_background').value,
+ state_icon_background_opacity: parseFloat(document.getElementById('state_icon_background_opacity').value),
rtsp_url: document.getElementById('rtsp_url').value,
state_icons: stateIcons,
source_width: currentConfig.source_width,
@@ -1226,7 +1256,7 @@
// Update interaction state
updateCropInteractionState();
- updateCropVisibility();
+ updateCropVisibility(); // Don't await here as it's in an onload callback
};
previewImage.src = imageUrl;
@@ -1261,6 +1291,22 @@
const blob = await response.blob();
sourceImageSize = blob.size;
+ // Store source image for cropping
+ const imageUrl = URL.createObjectURL(blob);
+ const img = new Image();
+ img.onload = () => {
+ sourceImageData = img;
+ // Update source image element
+ const sourceImg = document.getElementById('source-image');
+ if (sourceImg) {
+ sourceImg.src = imageUrl;
+ }
+ // Update crop preview if crop is enabled
+ updateCropVisibility();
+ updateCropPreview();
+ };
+ img.src = imageUrl;
+
const sourceDim = document.getElementById('source-dimensions');
if (sourceDim) {
sourceDim.textContent = `Source: ${width} Ã ${height}px`;
@@ -1309,6 +1355,45 @@
}
}
+ function updateCropPreview() {
+ // Debounced update of crop preview
+ clearTimeout(cropPreviewDebounceTimer);
+ cropPreviewDebounceTimer = setTimeout(() => {
+ drawCropPreview();
+ }, 100); // 100ms debounce
+ }
+
+ function drawCropPreview() {
+ if (!sourceImageData || !document.getElementById('crop_enabled').checked || currentTab !== 'crop') {
+ return;
+ }
+
+ const canvas = document.getElementById('cropped-preview-canvas');
+ const ctx = canvas.getContext('2d');
+
+ const cropX = parseInt(document.getElementById('crop_x').value) || 0;
+ const cropY = parseInt(document.getElementById('crop_y').value) || 0;
+ const cropWidth = parseInt(document.getElementById('crop_width').value) || sourceImageDimensions.width;
+ const cropHeight = parseInt(document.getElementById('crop_height').value) || sourceImageDimensions.height;
+
+ // Validate crop boundaries
+ const validCropX = Math.max(0, Math.min(cropX, sourceImageDimensions.width));
+ const validCropY = Math.max(0, Math.min(cropY, sourceImageDimensions.height));
+ const validCropWidth = Math.min(cropWidth, sourceImageDimensions.width - validCropX);
+ const validCropHeight = Math.min(cropHeight, sourceImageDimensions.height - validCropY);
+
+ // Set canvas size to crop dimensions
+ canvas.width = validCropWidth;
+ canvas.height = validCropHeight;
+
+ // Draw the cropped portion
+ ctx.drawImage(
+ sourceImageData,
+ validCropX, validCropY, validCropWidth, validCropHeight,
+ 0, 0, validCropWidth, validCropHeight
+ );
+ }
+
function isCropped() {
// Check if current crop settings differ from source dimensions
if (!sourceImageDimensions.width || !sourceImageDimensions.height) {
@@ -1332,48 +1417,63 @@
if (cropEnabled && isCropped()) {
// Image is cropped - show reset button
resetBtn.style.display = 'inline-block';
-
- // Disable crop input fields
- ['crop_x', 'crop_y', 'crop_width', 'crop_height'].forEach(id => {
- document.getElementById(id).disabled = true;
- });
} else if (cropEnabled) {
- // Crop enabled but not yet cropped - hide reset button
+ // Crop enabled but not yet cropped - show reset button (always visible for easy reset)
+ resetBtn.style.display = 'inline-block';
+ } else {
+ // Crop not enabled - hide reset button
resetBtn.style.display = 'none';
+ }
- // Enable crop input fields
+ // Always enable crop input fields when crop is enabled (allow multiple adjustments)
+ if (cropEnabled) {
['crop_x', 'crop_y', 'crop_width', 'crop_height'].forEach(id => {
document.getElementById(id).disabled = false;
});
}
}
- function updateCropVisibility() {
+ async function updateCropVisibility() {
const cropEnabled = document.getElementById('crop_enabled').checked;
const cropOverlay = document.getElementById('crop-overlay');
- const cropMessage = document.getElementById('crop-message');
- const cropBox = cropOverlay.querySelector('.crop-box');
const onCropTab = currentTab === 'crop';
- // Only show crop-related UI when on crop tab
- if (!onCropTab || !cropEnabled) {
- cropOverlay.style.display = 'none';
- cropMessage.style.display = 'none';
- return;
- }
+ const sourceImageSection = document.getElementById('source-image-section');
+ const croppedPreviewSection = document.getElementById('cropped-preview-section');
+ const previewImageWrapper = document.getElementById('preview-image-wrapper');
- // On crop tab with crop enabled
- if (isCropped()) {
- // Image is already cropped - hide overlay, show message
- cropOverlay.style.display = 'none';
- cropMessage.style.display = 'flex';
+ // Show dual-image layout only when on crop tab and crop enabled
+ if (onCropTab && cropEnabled) {
+ // Ensure source image is loaded
+ if (!sourceImageData && currentCameraId) {
+ await loadSourceImage();
+ }
+
+ if (sourceImageData) {
+ // Show dual-image cropping interface
+ sourceImageSection.style.display = 'block';
+ croppedPreviewSection.style.display = 'block';
+ previewImageWrapper.style.display = 'none';
+ cropOverlay.style.display = 'block';
+
+ // Update crop box position
+ updateCropBox();
+
+ // Update cropped preview
+ updateCropPreview();
+ } else {
+ // Fallback to regular preview if source image failed to load
+ sourceImageSection.style.display = 'none';
+ croppedPreviewSection.style.display = 'none';
+ previewImageWrapper.style.display = 'block';
+ cropOverlay.style.display = 'none';
+ }
} else {
- // Image not cropped - show overlay for interactive cropping
- cropOverlay.style.display = 'block';
- cropMessage.style.display = 'none';
- cropBox.style.pointerEvents = 'auto';
- cropBox.style.opacity = '1';
- cropBox.style.cursor = 'move';
+ // Show regular preview image
+ sourceImageSection.style.display = 'none';
+ croppedPreviewSection.style.display = 'none';
+ previewImageWrapper.style.display = 'block';
+ cropOverlay.style.display = 'none';
}
}
@@ -1400,7 +1500,7 @@
// Re-enable interactive cropping
updateCropInteractionState();
- updateCropVisibility();
+ updateCropVisibility(); // Don't await in sync function
// Update dimensions display
updateDimensionsDisplay();
@@ -1429,12 +1529,34 @@
previewDim.style.fontWeight = '600';
}
}
+
+ // Update effective source dimensions display
+ updateEffectiveSourceSize();
+ }
+
+ function updateEffectiveSourceSize() {
+ const effectiveSizeEl = document.getElementById('effective-source-dimensions');
+ if (!effectiveSizeEl || !sourceImageDimensions.width) return;
+
+ const formData = getFormData();
+
+ if (formData.crop_enabled) {
+ // Crop enabled - show cropped dimensions
+ const cropWidth = formData.crop_width;
+ const cropHeight = formData.crop_height;
+ effectiveSizeEl.textContent = `${cropWidth} Ã ${cropHeight}px (cropped from ${sourceImageDimensions.width} Ã ${sourceImageDimensions.height}px)`;
+ effectiveSizeEl.style.color = '#ff9800';
+ } else {
+ // No crop - show original dimensions
+ effectiveSizeEl.textContent = `${sourceImageDimensions.width} Ã ${sourceImageDimensions.height}px (original camera resolution)`;
+ effectiveSizeEl.style.color = '#4caf50';
+ }
}
// ==================== Interactive Crop ====================
function setupCropInteraction() {
- const previewImage = document.getElementById('preview-image');
+ const sourceImage = document.getElementById('source-image');
const cropOverlay = document.getElementById('crop-overlay');
const cropBox = cropOverlay.querySelector('.crop-box');
@@ -1452,29 +1574,22 @@
// Update crop box when crop inputs change
['crop_x', 'crop_y', 'crop_width', 'crop_height'].forEach(id => {
- document.getElementById(id).addEventListener('input', () => {
+ document.getElementById(id).addEventListener('input', async () => {
updateCropBox();
updateCropInteractionState();
- updateCropVisibility();
+ await updateCropVisibility();
+ updateEffectiveSourceSize();
});
});
- // Update crop box when preview loads
- previewImage.addEventListener('load', updateCropBox);
+ // Update crop box when source image loads
+ sourceImage.addEventListener('load', updateCropBox);
}
function startDragCrop(e) {
- // Don't allow dragging if image is already cropped
- if (isCropped()) {
- return;
- }
-
e.preventDefault();
cropDragging = true;
- const previewImage = document.getElementById('preview-image');
- const rect = previewImage.getBoundingClientRect();
-
cropStartX = e.clientX;
cropStartY = e.clientY;
@@ -1488,8 +1603,9 @@
const deltaY = e.clientY - cropStartY;
// Convert pixel delta to source image coordinates
- const scaleX = sourceImageDimensions.width / previewImage.clientWidth;
- const scaleY = sourceImageDimensions.height / previewImage.clientHeight;
+ const sourceImage = document.getElementById('source-image');
+ const scaleX = sourceImageDimensions.width / sourceImage.clientWidth;
+ const scaleY = sourceImageDimensions.height / sourceImage.clientHeight;
const newX = Math.max(0, Math.min(sourceImageDimensions.width - parseInt(document.getElementById('crop_width').value), currentX + deltaX * scaleX));
const newY = Math.max(0, Math.min(sourceImageDimensions.height - parseInt(document.getElementById('crop_height').value), currentY + deltaY * scaleY));
@@ -1497,13 +1613,13 @@
setInputValue('crop_x', Math.round(newX));
setInputValue('crop_y', Math.round(newY));
updateCropBox();
+ updateCropPreview(); // Update crop preview immediately
};
const mouseup = () => {
cropDragging = false;
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);
- schedulePreviewUpdate();
};
document.addEventListener('mousemove', mousemove);
@@ -1511,15 +1627,10 @@
}
function startResizeCrop(e, handlePosition) {
- // Don't allow resizing if image is already cropped
- if (isCropped()) {
- return;
- }
-
e.preventDefault();
cropResizing = true;
- const previewImage = document.getElementById('preview-image');
+ const sourceImage = document.getElementById('source-image');
cropStartX = e.clientX;
cropStartY = e.clientY;
@@ -1535,8 +1646,9 @@
const deltaY = e.clientY - cropStartY;
// Convert pixel delta to source image coordinates
- const scaleX = sourceImageDimensions.width / previewImage.clientWidth;
- const scaleY = sourceImageDimensions.height / previewImage.clientHeight;
+ const sourceImage = document.getElementById('source-image');
+ const scaleX = sourceImageDimensions.width / sourceImage.clientWidth;
+ const scaleY = sourceImageDimensions.height / sourceImage.clientHeight;
let newX = startX;
let newY = startY;
@@ -1573,13 +1685,13 @@
setInputValue('crop_width', Math.round(newWidth));
setInputValue('crop_height', Math.round(newHeight));
updateCropBox();
+ updateCropPreview(); // Update crop preview immediately
};
const mouseup = () => {
cropResizing = false;
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);
- schedulePreviewUpdate();
};
document.addEventListener('mousemove', mousemove);
@@ -1587,11 +1699,13 @@
}
function updateCropBox() {
- const previewImage = document.getElementById('preview-image');
const cropOverlay = document.getElementById('crop-overlay');
const cropBox = cropOverlay.querySelector('.crop-box');
- if (!previewImage.complete || !sourceImageDimensions.width) return;
+ // Use source image when in dual-image crop mode
+ const sourceImage = document.getElementById('source-image');
+
+ if (!sourceImage || !sourceImage.complete || !sourceImageDimensions.width) return;
const cropX = parseInt(document.getElementById('crop_x').value) || 0;
const cropY = parseInt(document.getElementById('crop_y').value) || 0;
@@ -1600,8 +1714,8 @@
// Calculate position and size based on the actual rendered image dimensions
// The image is rendered with natural aspect ratio maintained
- const scaleX = previewImage.clientWidth / sourceImageDimensions.width;
- const scaleY = previewImage.clientHeight / sourceImageDimensions.height;
+ const scaleX = sourceImage.clientWidth / sourceImageDimensions.width;
+ const scaleY = sourceImage.clientHeight / sourceImageDimensions.height;
const left = cropX * scaleX;
const top = cropY * scaleY;
diff --git a/custom_components/camera_snapshot_processor/frontend/styles.css b/custom_components/camera_snapshot_processor/frontend/styles.css
index 1a2b9c2..5dc6d5a 100644
--- a/custom_components/camera_snapshot_processor/frontend/styles.css
+++ b/custom_components/camera_snapshot_processor/frontend/styles.css
@@ -548,7 +548,7 @@ body {
display: block;
}
-#preview-image {
+#preview-image, #source-image {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
@@ -640,6 +640,30 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
+/* Crop Section Titles */
+.crop-section-title {
+ margin: 15px 0 10px 0;
+ font-size: 0.95em;
+ font-weight: 600;
+ color: #667eea;
+ text-align: center;
+}
+
+#source-image-section {
+ margin-bottom: 20px;
+}
+
+#cropped-preview-section {
+ margin-bottom: 20px;
+}
+
+#cropped-preview-canvas {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 8px;
+}
+
/* State Icons List */
#state-icons-list {
margin-bottom: 20px;
diff --git a/custom_components/camera_snapshot_processor/image_processor.py b/custom_components/camera_snapshot_processor/image_processor.py
index 771af1a..3295f7e 100644
--- a/custom_components/camera_snapshot_processor/image_processor.py
+++ b/custom_components/camera_snapshot_processor/image_processor.py
@@ -25,11 +25,13 @@
CONF_HEIGHT,
CONF_KEEP_RATIO,
CONF_OVERLAY_BACKGROUND,
+ CONF_OVERLAY_BACKGROUND_OPACITY,
CONF_OVERLAY_COLOR,
CONF_OVERLAY_FONT_SIZE,
CONF_QUALITY,
CONF_RESIZE_ALGORITHM,
CONF_STATE_ICON_BACKGROUND,
+ CONF_STATE_ICON_BACKGROUND_OPACITY,
CONF_STATE_ICONS,
CONF_TEXT_ENABLED,
CONF_TEXT_FONT_SIZE,
@@ -337,13 +339,16 @@ def _draw_text(
)
bg_color = self.config.get(CONF_OVERLAY_BACKGROUND, DEFAULT_OVERLAY_BACKGROUND)
+ # Get opacity value for background (default to 1.0 = fully opaque)
+ bg_opacity = float(self.config.get(CONF_OVERLAY_BACKGROUND_OPACITY, 1.0))
+
# Normalize colors (convert RGB lists to hex strings if needed)
color = self._normalize_color(color)
bg_color = self._normalize_color(bg_color)
- # Convert hex colors to RGBA
+ # Convert hex colors to RGBA (text is always fully opaque, background uses opacity)
text_color = self._hex_to_rgba(color)
- background_color = self._hex_to_rgba(bg_color)
+ background_color = self._hex_to_rgba(bg_color, opacity=bg_opacity)
# Get text bounding box - bbox gives us the actual visual bounds of the text
# bbox[0], bbox[1] = left, top offset from anchor point (can be negative)
@@ -640,7 +645,11 @@ async def _draw_state_icon(
}
if text:
- text_part = {"text": text, "color": text_color, "font": text_font}
+ text_part = {
+ "text": text,
+ "color": text_color,
+ "font": text_font,
+ }
# Add icon and text in the specified order
if display_order == "text_first":
@@ -680,12 +689,15 @@ def _draw_multicolor_text(
bg_color = self.config.get(
CONF_STATE_ICON_BACKGROUND, DEFAULT_STATE_ICON_BACKGROUND
)
+ bg_opacity = float(self.config.get(CONF_STATE_ICON_BACKGROUND_OPACITY, 1.0))
else:
bg_color = self.config.get(
CONF_OVERLAY_BACKGROUND, DEFAULT_OVERLAY_BACKGROUND
)
+ bg_opacity = float(self.config.get(CONF_OVERLAY_BACKGROUND_OPACITY, 1.0))
+
bg_color = self._normalize_color(bg_color)
- background_color = self._hex_to_rgba(bg_color)
+ background_color = self._hex_to_rgba(bg_color, opacity=bg_opacity)
# Calculate total dimensions - each part uses its own font
# We need to track bbox offsets to align the background properly
@@ -872,8 +884,16 @@ def _normalize_color(color: str | list) -> str:
return color # Already a string
@staticmethod
- def _hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]:
- """Convert hex color to RGBA tuple."""
+ def _hex_to_rgba(hex_color: str, opacity: float = 1.0) -> tuple[int, int, int, int]:
+ """Convert hex color to RGBA tuple with optional opacity override.
+
+ Args:
+ hex_color: Hex color string (e.g., "#RRGGBB" or "#RRGGBBAA")
+ opacity: Opacity value from 0.0 to 1.0 (multiplied with existing alpha)
+
+ Returns:
+ RGBA tuple (r, g, b, a) where each value is 0-255
+ """
hex_color = hex_color.lstrip("#")
if len(hex_color) == 8:
@@ -896,4 +916,7 @@ def _hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]:
# Default to white
r, g, b, a = 255, 255, 255, 255
+ # Apply opacity multiplier to alpha channel
+ a = int(a * opacity)
+
return (r, g, b, a)