Introduce ColorStripSource as first-class entity

Extracts color processing and calibration out of WledPictureTarget into a
new PictureColorStripSource entity, enabling multiple LED targets to share
one capture/processing pipeline.

New entities & processing:
- storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models
- storage/color_strip_store.py: JSON-backed CRUD store (prefix css_)
- core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread)
- core/processing/color_strip_stream_manager.py: ref-counted shared stream manager

Modified storage/processing:
- WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval
- Device model: calibration field removed
- WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline
- ProcessorManager: wires ColorStripStreamManager into TargetContext

API layer:
- New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test
- Removed calibration endpoints from /devices
- Updated /picture-targets CRUD for new target structure

Frontend:
- New color-strips.js module with CSS editor modal and card rendering
- Calibration modal extended with CSS mode (css-id hidden field + device picker)
- targets.js: Color Strip Sources section added to LED tab; target editor/card updated
- app.js: imports and window globals for CSS + showCSSCalibration
- en.json / ru.json: color_strip.* and targets.section.color_strips keys added

Data migration runs at startup: existing WledPictureTargets are converted to
reference a new PictureColorStripSource created from their old settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

@@ -0,0 +1,110 @@
<!-- Color Strip Source Editor Modal -->
<div id="css-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="css-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="css-editor-title" data-i18n="color_strip.add">🎞️ Add Color Strip Source</h2>
<button class="modal-close-btn" onclick="closeCSSEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="css-editor-form">
<input type="hidden" id="css-editor-id">
<div class="form-group">
<label for="css-editor-name" data-i18n="color_strip.name">Name:</label>
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.picture_source.hint">Which screen capture source to use as input for LED color calculation</small>
<select id="css-editor-picture-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-fps">
<span data-i18n="color_strip.fps">Target FPS:</span>
<span id="css-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.fps.hint">Target frames per second for LED color updates (10-90)</small>
<div class="slider-row">
<input type="range" id="css-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('css-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.interpolation.hint">How to calculate LED color from sampled border pixels</small>
<select id="css-editor-interpolation">
<option value="average" data-i18n="color_strip.interpolation.average">Average</option>
<option value="median" data-i18n="color_strip.interpolation.median">Median</option>
<option value="dominant" data-i18n="color_strip.interpolation.dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-smoothing">
<span data-i18n="color_strip.smoothing">Smoothing:</span>
<span id="css-editor-smoothing-value">0.30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-brightness">
<span data-i18n="color_strip.brightness">Brightness:</span>
<span id="css-editor-brightness-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-saturation">
<span data-i18n="color_strip.saturation">Saturation:</span>
<span id="css-editor-saturation-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gamma">
<span data-i18n="color_strip.gamma">Gamma:</span>
<span id="css-editor-gamma-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, &lt;1=brighter midtones, &gt;1=darker midtones)</small>
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div id="css-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCSSEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>