diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index ab63593..6140696 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -51,6 +51,7 @@ def _css_to_response(source) -> ColorStripSourceResponse: gamma=getattr(source, "gamma", None), smoothing=getattr(source, "smoothing", None), interpolation_mode=getattr(source, "interpolation_mode", None), + led_count=getattr(source, "led_count", 0), calibration=calibration, description=source.description, created_at=source.created_at, @@ -93,6 +94,7 @@ async def create_color_strip_source( gamma=data.gamma, smoothing=data.smoothing, interpolation_mode=data.interpolation_mode, + led_count=data.led_count, calibration=calibration, description=data.description, ) @@ -143,6 +145,7 @@ async def update_color_strip_source( gamma=data.gamma, smoothing=data.smoothing, interpolation_mode=data.interpolation_mode, + led_count=data.led_count, calibration=calibration, description=data.description, ) diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 2bf141f..7afb5ce 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -20,6 +20,7 @@ class ColorStripSourceCreate(BaseModel): gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") + led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -35,6 +36,7 @@ class ColorStripSourceUpdate(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") + led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -52,6 +54,7 @@ class ColorStripSourceResponse(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction") smoothing: Optional[float] = Field(None, description="Temporal smoothing") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + led_count: int = Field(0, description="Total LED count (0 = auto from calibration)") calibration: Optional[Calibration] = Field(None, description="LED calibration") description: Optional[str] = Field(None, description="Description") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 90fec69..4814ea7 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -142,7 +142,8 @@ class PictureColorStripStream(ColorStripStream): self._pixel_mapper = PixelMapper( self._calibration, interpolation_mode=self._interpolation_mode ) - self._led_count: int = self._calibration.get_total_leds() + cal_leds = self._calibration.get_total_leds() + self._led_count: int = source.led_count if source.led_count > 0 else cal_leds self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma) # Thread-safe color cache @@ -224,10 +225,12 @@ class PictureColorStripStream(ColorStripStream): if ( source.interpolation_mode != self._interpolation_mode or source.calibration != self._calibration + or source.led_count != self._led_count ): self._interpolation_mode = source.interpolation_mode self._calibration = source.calibration - self._led_count = source.calibration.get_total_leds() + cal_leds = source.calibration.get_total_leds() + self._led_count = source.led_count if source.led_count > 0 else cal_leds self._pixel_mapper = PixelMapper( source.calibration, interpolation_mode=source.interpolation_mode ) @@ -263,6 +266,15 @@ class PictureColorStripStream(ColorStripStream): led_colors = self._pixel_mapper.map_border_to_leds(border_pixels) t2 = time.perf_counter() + # Pad or truncate to match the declared led_count + target_count = self._led_count + if target_count > 0 and len(led_colors) != target_count: + if len(led_colors) < target_count: + pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8) + led_colors = np.concatenate([led_colors, pad]) + else: + led_colors = led_colors[:target_count] + # Temporal smoothing smoothing = self._smoothing if ( diff --git a/server/src/wled_controller/static/css/calibration.css b/server/src/wled_controller/static/css/calibration.css index ba68c05..6a55be1 100644 --- a/server/src/wled_controller/static/css/calibration.css +++ b/server/src/wled_controller/static/css/calibration.css @@ -83,6 +83,7 @@ color: #FFC107; } + .inputs-dimmed .edge-led-input { opacity: 0.2; pointer-events: none; diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 85a06b4..ab03a92 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -221,6 +221,47 @@ color: var(--text-primary); } +.form-collapse { + margin-bottom: 12px; + border-top: 1px solid var(--border-color); + padding-top: 8px; +} + +.form-collapse > summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-secondary, #888); + padding: 4px 0; + user-select: none; +} + +.form-collapse > summary::-webkit-details-marker { display: none; } + +.form-collapse > summary::before { + content: '▶'; + font-size: 0.6rem; + opacity: 0.6; + transition: transform 0.15s; + flex-shrink: 0; +} + +.form-collapse[open] > summary::before { + transform: rotate(90deg); +} + +.form-collapse > summary:hover { + color: var(--text-color); +} + +.form-collapse-body { + padding-top: 8px; +} + .error-message { background: rgba(244, 67, 54, 0.1); border: 1px solid var(--danger-color); diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js index 8653142..199be5c 100644 --- a/server/src/wled_controller/static/js/features/calibration.js +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -30,6 +30,7 @@ class CalibrationModal extends Modal { skip_start: this.$('cal-skip-start').value, skip_end: this.$('cal-skip-end').value, border_width: this.$('cal-border-width').value, + led_count: this.$('cal-css-led-count').value, }; } @@ -112,6 +113,7 @@ export async function showCalibration(deviceId) { document.getElementById('calibration-device-id').value = device.id; document.getElementById('cal-device-led-count-inline').textContent = device.led_count; + document.getElementById('cal-css-led-count-group').style.display = 'none'; document.getElementById('cal-start-position').value = calibration.start_position; document.getElementById('cal-layout').value = calibration.layout; @@ -216,6 +218,11 @@ export async function showCSSCalibration(cssId) { const preview = document.querySelector('.calibration-preview'); preview.style.aspectRatio = ''; document.getElementById('cal-device-led-count-inline').textContent = '—'; + const ledCountGroup = document.getElementById('cal-css-led-count-group'); + ledCountGroup.style.display = ''; + const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) + + (calibration.leds_bottom || 0) + (calibration.leds_left || 0); + document.getElementById('cal-css-led-count').value = source.led_count || calLeds || 0; document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left'; document.getElementById('cal-layout').value = calibration.layout || 'clockwise'; @@ -286,8 +293,17 @@ export function updateCalibrationPreview() { parseInt(document.getElementById('cal-left-leds').value || 0); const totalEl = document.querySelector('.preview-screen-total'); const inCSS = _isCSS(); - const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); - const mismatch = !inCSS && total !== deviceCount; + const declaredCount = inCSS + ? parseInt(document.getElementById('cal-css-led-count').value || 0) + : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); + if (inCSS) { + document.getElementById('cal-device-led-count-inline').textContent = declaredCount || '—'; + } + // In device mode: calibration total must exactly equal device LED count + // In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest) + const mismatch = inCSS + ? (declaredCount > 0 && total > declaredCount) + : (total !== declaredCount); document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; if (totalEl) totalEl.classList.toggle('mismatch', mismatch); @@ -831,10 +847,18 @@ export async function saveCalibration() { const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); const total = topLeds + rightLeds + bottomLeds + leftLeds; + const declaredLedCount = cssMode + ? parseInt(document.getElementById('cal-css-led-count').value) || 0 + : parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0; if (!cssMode) { - const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); - if (total !== deviceLedCount) { - error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; + if (total !== declaredLedCount) { + error.textContent = `Total LEDs (${total}) must equal device LED count (${declaredLedCount})`; + error.style.display = 'block'; + return; + } + } else { + if (declaredLedCount > 0 && total > declaredLedCount) { + error.textContent = `Calibrated LEDs (${total}) exceed total LED count (${declaredLedCount})`; error.style.display = 'block'; return; } @@ -862,7 +886,7 @@ export async function saveCalibration() { if (cssMode) { response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { method: 'PUT', - body: JSON.stringify({ calibration }), + body: JSON.stringify({ calibration, led_count: declaredLedCount }), }); } else { response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index ab8c637..949f5bd 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -22,6 +22,7 @@ class CSSEditorModal extends Modal { brightness: document.getElementById('css-editor-brightness').value, saturation: document.getElementById('css-editor-saturation').value, gamma: document.getElementById('css-editor-gamma').value, + led_count: document.getElementById('css-editor-led-count').value, }; } } @@ -35,7 +36,8 @@ export function createColorStripCard(source, pictureSourceMap) { ? pictureSourceMap[source.picture_source_id].name : source.picture_source_id || '—'; const cal = source.calibration || {}; - const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + const ledCount = (source.led_count > 0) ? source.led_count : calLeds; return `
@@ -107,6 +109,8 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-gamma').value = gamma; document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2); + document.getElementById('css-editor-led-count').value = css.led_count ?? 0; + document.getElementById('css-editor-title').textContent = t('color_strip.edit'); } else { document.getElementById('css-editor-id').value = ''; @@ -122,6 +126,7 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-saturation-value').textContent = '1.00'; document.getElementById('css-editor-gamma').value = 1.0; document.getElementById('css-editor-gamma-value').textContent = '1.00'; + document.getElementById('css-editor-led-count').value = 0; document.getElementById('css-editor-title').textContent = t('color_strip.add'); } @@ -160,6 +165,7 @@ export async function saveCSSEditor() { brightness: parseFloat(document.getElementById('css-editor-brightness').value), saturation: parseFloat(document.getElementById('css-editor-saturation').value), gamma: parseFloat(document.getElementById('css-editor-gamma').value), + led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, }; try { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index e906620..5b44aa6 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -100,9 +100,7 @@ export function createDeviceCard(device) { - +
`; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 4db2b40..187dd4e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -552,6 +552,7 @@ "color_strip.interpolation.dominant": "Dominant", "color_strip.smoothing": "Smoothing:", "color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "color_strip.color_corrections": "Color Corrections", "color_strip.brightness": "Brightness:", "color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.", "color_strip.saturation": "Saturation:", @@ -561,6 +562,8 @@ "color_strip.test_device": "Test on Device:", "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", "color_strip.leds": "LED count", + "color_strip.led_count": "LED Count:", + "color_strip.led_count.hint": "Total number of LEDs on the physical strip. Set to 0 to use the sum from calibration. Useful when the strip has LEDs behind the TV that are not mapped to screen edges — those LEDs will be sent black.", "color_strip.created": "Color strip source created", "color_strip.updated": "Color strip source updated", "color_strip.deleted": "Color strip source deleted", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index d1b6189..12a4f05 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -552,6 +552,7 @@ "color_strip.interpolation.dominant": "Доминирующий", "color_strip.smoothing": "Сглаживание:", "color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.", + "color_strip.color_corrections": "Цветокоррекция", "color_strip.brightness": "Яркость:", "color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.", "color_strip.saturation": "Насыщенность:", @@ -561,6 +562,8 @@ "color_strip.test_device": "Тестировать на устройстве:", "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", "color_strip.leds": "Количество светодиодов", + "color_strip.led_count": "Количество LED:", + "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. 0 = взять из калибровки. Укажите явно, если на полосе есть светодиоды за телевизором, не привязанные к краям экрана — им будет отправлен чёрный цвет.", "color_strip.created": "Источник цветовой полосы создан", "color_strip.updated": "Источник цветовой полосы обновлён", "color_strip.deleted": "Источник цветовой полосы удалён", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 3ef6d37..7782252 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -20,7 +20,6 @@ from wled_controller.core.capture.calibration import ( CalibrationConfig, calibration_from_dict, calibration_to_dict, - create_default_calibration, ) @@ -53,6 +52,7 @@ class ColorStripSource: "smoothing": None, "interpolation_mode": None, "calibration": None, + "led_count": None, } @staticmethod @@ -82,7 +82,7 @@ class ColorStripSource: calibration = ( calibration_from_dict(calibration_data) if calibration_data - else create_default_calibration(0) + else CalibrationConfig(layout="clockwise", start_position="bottom_left") ) # Only "picture" type for now; extend with elif branches for future types @@ -97,6 +97,7 @@ class ColorStripSource: smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3, interpolation_mode=data.get("interpolation_mode") or "average", calibration=calibration, + led_count=data.get("led_count") or 0, ) @@ -115,7 +116,10 @@ class PictureColorStripSource(ColorStripSource): gamma: float = 1.0 # 1.0 = no correction; <1 = brighter, >1 = darker mids smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full) interpolation_mode: str = "average" # "average" | "median" | "dominant" - calibration: CalibrationConfig = field(default_factory=lambda: create_default_calibration(0)) + calibration: CalibrationConfig = field( + default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left") + ) + led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration) def to_dict(self) -> dict: d = super().to_dict() @@ -127,4 +131,5 @@ class PictureColorStripSource(ColorStripSource): d["smoothing"] = self.smoothing d["interpolation_mode"] = self.interpolation_mode d["calibration"] = calibration_to_dict(self.calibration) + d["led_count"] = self.led_count return d diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index d506b0c..f204ae2 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional -from wled_controller.core.capture.calibration import calibration_to_dict +from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict from wled_controller.storage.color_strip_source import ( ColorStripSource, PictureColorStripSource, @@ -98,6 +98,7 @@ class ColorStripStore: smoothing: float = 0.3, interpolation_mode: str = "average", calibration=None, + led_count: int = 0, description: Optional[str] = None, ) -> ColorStripSource: """Create a new color strip source. @@ -105,8 +106,6 @@ class ColorStripStore: Raises: ValueError: If validation fails """ - from wled_controller.core.capture.calibration import create_default_calibration - if not name or not name.strip(): raise ValueError("Name is required") @@ -115,7 +114,7 @@ class ColorStripStore: raise ValueError(f"Color strip source with name '{name}' already exists") if calibration is None: - calibration = create_default_calibration(0) + calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") source_id = f"css_{uuid.uuid4().hex[:8]}" now = datetime.utcnow() @@ -135,6 +134,7 @@ class ColorStripStore: smoothing=smoothing, interpolation_mode=interpolation_mode, calibration=calibration, + led_count=led_count, ) self._sources[source_id] = source @@ -155,6 +155,7 @@ class ColorStripStore: smoothing: Optional[float] = None, interpolation_mode: Optional[str] = None, calibration=None, + led_count: Optional[int] = None, description: Optional[str] = None, ) -> ColorStripSource: """Update an existing color strip source. @@ -193,6 +194,8 @@ class ColorStripStore: source.interpolation_mode = interpolation_mode if calibration is not None: source.calibration = calibration + if led_count is not None: + source.led_count = led_count source.updated_at = datetime.utcnow() self._save() diff --git a/server/src/wled_controller/templates/modals/calibration.html b/server/src/wled_controller/templates/modals/calibration.html index c083340..28ef749 100644 --- a/server/src/wled_controller/templates/modals/calibration.html +++ b/server/src/wled_controller/templates/modals/calibration.html @@ -18,6 +18,16 @@ + + +
diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 2d0fbfa..97c3df8 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -63,40 +63,54 @@
-
-
- - +
+ Color Corrections +
+
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
- - -
+
- +
- - -
- -
-
- - -
- - + +