Frame interpolation, FPS hot-update, timing metrics, KC brightness fixes
- CSS: add frame interpolation option — blends between consecutive captured frames on idle ticks so LED output runs at full target FPS even when capture rate is lower (e.g. capture 30fps, output 60fps) - WledTargetProcessor: re-read stream.target_fps each loop tick so FPS changes to the CSS source take effect without restarting the target - WledTargetProcessor: restore per-stage timing metrics on target card by pulling extract/map/smooth/total from CSS stream get_last_timing() - TargetProcessingState schema: add missing timing_extract_ms, timing_map_leds_ms, timing_smooth_ms, timing_total_ms fields - KC targets: add extraction FPS badge to target card props row - KC targets: fix 500 error when changing brightness — update_fields now accepts (and ignores) WLED-specific kwargs - KC targets: fix partial key_colors_settings update wiping pattern_template_id — update route merges only explicitly-set fields using model_dump(exclude_unset=True) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
|||||||
color=getattr(source, "color", None),
|
color=getattr(source, "color", None),
|
||||||
stops=stops,
|
stops=stops,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
|
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||||
overlay_active=overlay_active,
|
overlay_active=overlay_active,
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
updated_at=source.updated_at,
|
updated_at=source.updated_at,
|
||||||
@@ -134,6 +135,7 @@ async def create_color_strip_source(
|
|||||||
color=data.color,
|
color=data.color,
|
||||||
stops=stops,
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
frame_interpolation=data.frame_interpolation,
|
||||||
)
|
)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
|
|
||||||
@@ -190,6 +192,7 @@ async def update_color_strip_source(
|
|||||||
color=data.color,
|
color=data.color,
|
||||||
stops=stops,
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
frame_interpolation=data.frame_interpolation,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||||
|
|||||||
@@ -214,7 +214,28 @@ async def update_target(
|
|||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||||
|
|
||||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
# Build KC settings with partial-update support: only apply fields that were
|
||||||
|
# explicitly provided in the request body, merging with the existing settings.
|
||||||
|
kc_settings = None
|
||||||
|
if data.key_colors_settings is not None:
|
||||||
|
incoming = data.key_colors_settings.model_dump(exclude_unset=True)
|
||||||
|
try:
|
||||||
|
existing_target = target_store.get_target(target_id)
|
||||||
|
except ValueError:
|
||||||
|
existing_target = None
|
||||||
|
|
||||||
|
if isinstance(existing_target, KeyColorsPictureTarget):
|
||||||
|
ex = existing_target.settings
|
||||||
|
merged = KeyColorsSettingsSchema(
|
||||||
|
fps=incoming.get("fps", ex.fps),
|
||||||
|
interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode),
|
||||||
|
smoothing=incoming.get("smoothing", ex.smoothing),
|
||||||
|
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
|
||||||
|
brightness=incoming.get("brightness", ex.brightness),
|
||||||
|
)
|
||||||
|
kc_settings = _kc_schema_to_settings(merged)
|
||||||
|
else:
|
||||||
|
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
|
||||||
|
|
||||||
# Update in store
|
# Update in store
|
||||||
target = target_store.update_target(
|
target = target_store.update_target(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
# shared
|
# shared
|
||||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
|
||||||
|
|
||||||
|
|
||||||
class ColorStripSourceUpdate(BaseModel):
|
class ColorStripSourceUpdate(BaseModel):
|
||||||
@@ -62,6 +63,7 @@ class ColorStripSourceUpdate(BaseModel):
|
|||||||
# shared
|
# shared
|
||||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||||
|
|
||||||
|
|
||||||
class ColorStripSourceResponse(BaseModel):
|
class ColorStripSourceResponse(BaseModel):
|
||||||
@@ -86,6 +88,7 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
# shared
|
# shared
|
||||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||||
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ class TargetProcessingState(BaseModel):
|
|||||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
||||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||||
|
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
|
||||||
|
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
|
||||||
|
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
|
||||||
|
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
|
||||||
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
||||||
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
||||||
display_index: Optional[int] = Field(None, description="Current display index")
|
display_index: Optional[int] = Field(None, description="Current display index")
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
self._colors_lock = threading.Lock()
|
self._colors_lock = threading.Lock()
|
||||||
self._previous_colors: Optional[np.ndarray] = None
|
self._previous_colors: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
# Frame interpolation state
|
||||||
|
self._frame_interpolation: bool = source.frame_interpolation
|
||||||
|
self._interp_from: Optional[np.ndarray] = None
|
||||||
|
self._interp_to: Optional[np.ndarray] = None
|
||||||
|
self._interp_start: float = 0.0
|
||||||
|
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
|
||||||
|
self._last_capture_time: float = 0.0
|
||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._last_timing: dict = {}
|
self._last_timing: dict = {}
|
||||||
@@ -194,6 +202,9 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
self._thread = None
|
self._thread = None
|
||||||
self._latest_colors = None
|
self._latest_colors = None
|
||||||
self._previous_colors = None
|
self._previous_colors = None
|
||||||
|
self._interp_from = None
|
||||||
|
self._interp_to = None
|
||||||
|
self._last_capture_time = 0.0
|
||||||
logger.info("PictureColorStripStream stopped")
|
logger.info("PictureColorStripStream stopped")
|
||||||
|
|
||||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||||
@@ -236,6 +247,11 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
)
|
)
|
||||||
self._previous_colors = None # Reset smoothing history on calibration change
|
self._previous_colors = None # Reset smoothing history on calibration change
|
||||||
|
|
||||||
|
if source.frame_interpolation != self._frame_interpolation:
|
||||||
|
self._frame_interpolation = source.frame_interpolation
|
||||||
|
self._interp_from = None
|
||||||
|
self._interp_to = None
|
||||||
|
|
||||||
logger.info("PictureColorStripStream params updated in-place")
|
logger.info("PictureColorStripStream params updated in-place")
|
||||||
|
|
||||||
def _processing_loop(self) -> None:
|
def _processing_loop(self) -> None:
|
||||||
@@ -251,10 +267,39 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
frame = self._live_stream.get_latest_frame()
|
frame = self._live_stream.get_latest_frame()
|
||||||
|
|
||||||
if frame is None or frame is cached_frame:
|
if frame is None or frame is cached_frame:
|
||||||
|
if (
|
||||||
|
frame is not None
|
||||||
|
and self._frame_interpolation
|
||||||
|
and self._interp_from is not None
|
||||||
|
and self._interp_to is not None
|
||||||
|
):
|
||||||
|
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
|
||||||
|
alpha = int(t * 256)
|
||||||
|
led_colors = (
|
||||||
|
(256 - alpha) * self._interp_from.astype(np.uint16)
|
||||||
|
+ alpha * self._interp_to.astype(np.uint16)
|
||||||
|
) >> 8
|
||||||
|
led_colors = led_colors.astype(np.uint8)
|
||||||
|
if self._saturation != 1.0:
|
||||||
|
led_colors = _apply_saturation(led_colors, self._saturation)
|
||||||
|
if self._gamma != 1.0:
|
||||||
|
led_colors = self._gamma_lut[led_colors]
|
||||||
|
if self._brightness != 1.0:
|
||||||
|
led_colors = np.clip(
|
||||||
|
led_colors.astype(np.float32) * self._brightness, 0, 255
|
||||||
|
).astype(np.uint8)
|
||||||
|
with self._colors_lock:
|
||||||
|
self._latest_colors = led_colors
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
interval = (
|
||||||
|
loop_start - self._last_capture_time
|
||||||
|
if self._last_capture_time > 0
|
||||||
|
else frame_time
|
||||||
|
)
|
||||||
|
self._last_capture_time = loop_start
|
||||||
cached_frame = frame
|
cached_frame = frame
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
@@ -275,6 +320,13 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
else:
|
else:
|
||||||
led_colors = led_colors[:target_count]
|
led_colors = led_colors[:target_count]
|
||||||
|
|
||||||
|
# Update interpolation buffers (raw colors, before corrections)
|
||||||
|
if self._frame_interpolation:
|
||||||
|
self._interp_from = self._interp_to
|
||||||
|
self._interp_to = led_colors.copy()
|
||||||
|
self._interp_start = loop_start
|
||||||
|
self._interp_duration = max(interval, 0.001)
|
||||||
|
|
||||||
# Temporal smoothing
|
# Temporal smoothing
|
||||||
smoothing = self._smoothing
|
smoothing = self._smoothing
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -234,6 +234,20 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
metrics = self._metrics
|
metrics = self._metrics
|
||||||
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
|
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
|
||||||
|
|
||||||
|
# Pull per-stage timing from the CSS stream (runs in a background thread)
|
||||||
|
css_timing: dict = {}
|
||||||
|
if self._is_running and self._color_strip_stream is not None:
|
||||||
|
css_timing = self._color_strip_stream.get_last_timing()
|
||||||
|
|
||||||
|
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
||||||
|
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
|
||||||
|
map_ms = round(css_timing.get("map_leds_ms", 0), 1) if css_timing else None
|
||||||
|
smooth_ms = round(css_timing.get("smooth_ms", 0), 1) if css_timing else None
|
||||||
|
total_ms = (
|
||||||
|
round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
|
||||||
|
if css_timing else None
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"target_id": self._target_id,
|
"target_id": self._target_id,
|
||||||
"device_id": self._device_id,
|
"device_id": self._device_id,
|
||||||
@@ -245,7 +259,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||||
"fps_current": metrics.fps_current if self._is_running else None,
|
"fps_current": metrics.fps_current if self._is_running else None,
|
||||||
"timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None,
|
"timing_send_ms": send_ms,
|
||||||
|
"timing_extract_ms": extract_ms,
|
||||||
|
"timing_map_leds_ms": map_ms,
|
||||||
|
"timing_smooth_ms": smooth_ms,
|
||||||
|
"timing_total_ms": total_ms,
|
||||||
"display_index": self._resolved_display_index,
|
"display_index": self._resolved_display_index,
|
||||||
"overlay_active": self._overlay_active,
|
"overlay_active": self._overlay_active,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
@@ -342,8 +360,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
async def _processing_loop(self) -> None:
|
async def _processing_loop(self) -> None:
|
||||||
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
||||||
stream = self._color_strip_stream
|
stream = self._color_strip_stream
|
||||||
target_fps = self._resolved_target_fps or 30
|
|
||||||
frame_time = 1.0 / target_fps
|
|
||||||
standby_interval = self._standby_interval
|
standby_interval = self._standby_interval
|
||||||
|
|
||||||
fps_samples: collections.deque = collections.deque(maxlen=10)
|
fps_samples: collections.deque = collections.deque(maxlen=10)
|
||||||
@@ -355,12 +371,15 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Processing loop started for target {self._target_id} "
|
f"Processing loop started for target {self._target_id} "
|
||||||
f"(display={self._resolved_display_index}, fps={target_fps})"
|
f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self._is_running:
|
while self._is_running:
|
||||||
loop_start = now = time.time()
|
loop_start = now = time.time()
|
||||||
|
# Re-read target_fps each tick so hot-updates to the CSS source take effect
|
||||||
|
target_fps = stream.target_fps if stream.target_fps > 0 else 30
|
||||||
|
frame_time = 1.0 / target_fps
|
||||||
|
|
||||||
# Re-fetch device info for runtime changes (test mode, brightness)
|
# Re-fetch device info for runtime changes (test mode, brightness)
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
device_info = self._ctx.get_device_info(self._device_id)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class CSSEditorModal extends Modal {
|
|||||||
saturation: document.getElementById('css-editor-saturation').value,
|
saturation: document.getElementById('css-editor-saturation').value,
|
||||||
gamma: document.getElementById('css-editor-gamma').value,
|
gamma: document.getElementById('css-editor-gamma').value,
|
||||||
color: document.getElementById('css-editor-color').value,
|
color: document.getElementById('css-editor-color').value,
|
||||||
|
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||||
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
|
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
|
||||||
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||||
};
|
};
|
||||||
@@ -194,6 +195,8 @@ export async function showCSSEditor(cssId = null) {
|
|||||||
const gamma = css.gamma ?? 1.0;
|
const gamma = css.gamma ?? 1.0;
|
||||||
document.getElementById('css-editor-gamma').value = gamma;
|
document.getElementById('css-editor-gamma').value = gamma;
|
||||||
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
|
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
|
||||||
|
|
||||||
|
document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
|
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
|
||||||
@@ -214,6 +217,7 @@ export async function showCSSEditor(cssId = null) {
|
|||||||
document.getElementById('css-editor-saturation-value').textContent = '1.00';
|
document.getElementById('css-editor-saturation-value').textContent = '1.00';
|
||||||
document.getElementById('css-editor-gamma').value = 1.0;
|
document.getElementById('css-editor-gamma').value = 1.0;
|
||||||
document.getElementById('css-editor-gamma-value').textContent = '1.00';
|
document.getElementById('css-editor-gamma-value').textContent = '1.00';
|
||||||
|
document.getElementById('css-editor-frame-interpolation').checked = false;
|
||||||
document.getElementById('css-editor-color').value = '#ffffff';
|
document.getElementById('css-editor-color').value = '#ffffff';
|
||||||
document.getElementById('css-editor-led-count').value = 0;
|
document.getElementById('css-editor-led-count').value = 0;
|
||||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||||
@@ -281,6 +285,7 @@ export async function saveCSSEditor() {
|
|||||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||||
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
||||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||||
|
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||||
};
|
};
|
||||||
if (!cssId) payload.source_type = 'picture';
|
if (!cssId) payload.source_type = 'picture';
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
|||||||
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
|
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||||
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
|
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
|
||||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('kc.fps')}">⚡ ${kcSettings.fps ?? 10} fps</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
||||||
<input type="range" class="brightness-slider" min="0" max="255"
|
<input type="range" class="brightness-slider" min="0" max="255"
|
||||||
|
|||||||
@@ -552,6 +552,8 @@
|
|||||||
"color_strip.interpolation.dominant": "Dominant",
|
"color_strip.interpolation.dominant": "Dominant",
|
||||||
"color_strip.smoothing": "Smoothing:",
|
"color_strip.smoothing": "Smoothing:",
|
||||||
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||||
|
"color_strip.frame_interpolation": "Frame Interpolation:",
|
||||||
|
"color_strip.frame_interpolation.hint": "Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.",
|
||||||
"color_strip.color_corrections": "Color Corrections",
|
"color_strip.color_corrections": "Color Corrections",
|
||||||
"color_strip.brightness": "Brightness:",
|
"color_strip.brightness": "Brightness:",
|
||||||
"color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.",
|
"color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.",
|
||||||
|
|||||||
@@ -552,6 +552,8 @@
|
|||||||
"color_strip.interpolation.dominant": "Доминирующий",
|
"color_strip.interpolation.dominant": "Доминирующий",
|
||||||
"color_strip.smoothing": "Сглаживание:",
|
"color_strip.smoothing": "Сглаживание:",
|
||||||
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
|
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
|
||||||
|
"color_strip.frame_interpolation": "Интерполяция кадров:",
|
||||||
|
"color_strip.frame_interpolation.hint": "Смешивает последовательные захваченные кадры для вывода на полной целевой частоте кадров, даже если скорость захвата ниже. Уменьшает заметные ступеньки при плавных переходах.",
|
||||||
"color_strip.color_corrections": "Цветокоррекция",
|
"color_strip.color_corrections": "Цветокоррекция",
|
||||||
"color_strip.brightness": "Яркость:",
|
"color_strip.brightness": "Яркость:",
|
||||||
"color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.",
|
"color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class ColorStripSource:
|
|||||||
interpolation_mode=data.get("interpolation_mode") or "average",
|
interpolation_mode=data.get("interpolation_mode") or "average",
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
led_count=data.get("led_count") or 0,
|
led_count=data.get("led_count") or 0,
|
||||||
|
frame_interpolation=bool(data.get("frame_interpolation", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||||
)
|
)
|
||||||
led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration)
|
led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration)
|
||||||
|
frame_interpolation: bool = False # blend between consecutive captured frames
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
@@ -155,6 +157,7 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
d["interpolation_mode"] = self.interpolation_mode
|
d["interpolation_mode"] = self.interpolation_mode
|
||||||
d["calibration"] = calibration_to_dict(self.calibration)
|
d["calibration"] = calibration_to_dict(self.calibration)
|
||||||
d["led_count"] = self.led_count
|
d["led_count"] = self.led_count
|
||||||
|
d["frame_interpolation"] = self.frame_interpolation
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class ColorStripStore:
|
|||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
stops: Optional[list] = None,
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
|
frame_interpolation: bool = False,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Create a new color strip source.
|
"""Create a new color strip source.
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@ class ColorStripStore:
|
|||||||
interpolation_mode=interpolation_mode,
|
interpolation_mode=interpolation_mode,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
|
frame_interpolation=frame_interpolation,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._sources[source_id] = source
|
self._sources[source_id] = source
|
||||||
@@ -189,6 +191,7 @@ class ColorStripStore:
|
|||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
stops: Optional[list] = None,
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
|
frame_interpolation: Optional[bool] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Update an existing color strip source.
|
"""Update an existing color strip source.
|
||||||
|
|
||||||
@@ -228,6 +231,8 @@ class ColorStripStore:
|
|||||||
source.calibration = calibration
|
source.calibration = calibration
|
||||||
if led_count is not None:
|
if led_count is not None:
|
||||||
source.led_count = led_count
|
source.led_count = led_count
|
||||||
|
if frame_interpolation is not None:
|
||||||
|
source.frame_interpolation = frame_interpolation
|
||||||
elif isinstance(source, StaticColorStripSource):
|
elif isinstance(source, StaticColorStripSource):
|
||||||
if color is not None:
|
if color is not None:
|
||||||
if isinstance(color, list) and len(color) == 3:
|
if isinstance(color, list) and len(color) == 3:
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ class KeyColorsPictureTarget(PictureTarget):
|
|||||||
manager.update_target_source(self.id, self.picture_source_id)
|
manager.update_target_source(self.id, self.picture_source_id)
|
||||||
|
|
||||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
||||||
settings=None, key_colors_settings=None, description=None) -> None:
|
settings=None, key_colors_settings=None, description=None,
|
||||||
|
# WledPictureTarget-specific params — accepted but ignored:
|
||||||
|
color_strip_source_id=None, standby_interval=None,
|
||||||
|
state_check_interval=None) -> None:
|
||||||
"""Apply mutable field updates for KC targets."""
|
"""Apply mutable field updates for KC targets."""
|
||||||
super().update_fields(name=name, description=description)
|
super().update_fields(name=name, description=description)
|
||||||
if picture_source_id is not None:
|
if picture_source_id is not None:
|
||||||
|
|||||||
@@ -78,6 +78,15 @@
|
|||||||
<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)">
|
<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>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-frame-interpolation" data-i18n="color_strip.frame_interpolation">Frame Interpolation:</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.frame_interpolation.hint">Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.</small>
|
||||||
|
<input type="checkbox" id="css-editor-frame-interpolation">
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="form-collapse">
|
<details class="form-collapse">
|
||||||
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
|
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
|
||||||
<div class="form-collapse-body">
|
<div class="form-collapse-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user