Add composable filter templates, skip keepalive for serial devices

Filter Template meta-filter: reference existing PP templates inside others
for composable, DRY filter chains. Filters are recursively expanded at
pipeline build time with cycle detection. New `select` option type with
dynamic choices populated by the API.

Keepalive optimization: serial devices (Adalight, AmbiLED) don't need
keepalive — they hold last frame indefinitely. Check `standby_required`
capability at processor start, skip keepalive sends for serial targets,
and hide keepalive metrics in the UI. Rename "Standby Interval" to
"Keep Alive Interval" throughout the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:48:23 +03:00
parent a4083764fb
commit 9e555cef2e
12 changed files with 178 additions and 51 deletions

View File

@@ -12,6 +12,7 @@ from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
get_device_store, get_device_store,
get_picture_source_store, get_picture_source_store,
get_pp_template_store,
get_processor_manager, get_processor_manager,
get_template_store, get_template_store,
) )
@@ -385,26 +386,44 @@ async def test_template(
# ===== FILTER TYPE ENDPOINTS ===== # ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) @router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types(_auth: AuthRequired): async def list_filter_types(
_auth: AuthRequired,
pp_store=Depends(get_pp_template_store),
):
"""List all available postprocessing filter types and their options schemas.""" """List all available postprocessing filter types and their options schemas."""
all_filters = FilterRegistry.get_all() all_filters = FilterRegistry.get_all()
# Pre-build template choices for the filter_template filter
template_choices = None
if pp_store:
try:
templates = pp_store.get_all_templates()
template_choices = [{"value": t.id, "label": t.name} for t in templates]
except Exception:
template_choices = []
responses = [] responses = []
for filter_id, filter_cls in all_filters.items(): for filter_id, filter_cls in all_filters.items():
schema = filter_cls.get_options_schema() schema = filter_cls.get_options_schema()
opt_schemas = []
for opt in schema:
choices = opt.choices
# Enrich filter_template choices with current template list
if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None:
choices = template_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse( responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id, filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name, filter_name=filter_cls.filter_name,
options_schema=[ options_schema=opt_schemas,
FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
)
for opt in schema
],
)) ))
return FilterTypeListResponse(filters=responses, count=len(responses)) return FilterTypeListResponse(filters=responses, count=len(responses))

View File

@@ -1,6 +1,6 @@
"""Filter-related schemas.""" """Filter-related schemas."""
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -17,11 +17,12 @@ class FilterOptionDefSchema(BaseModel):
key: str = Field(description="Option key") key: str = Field(description="Option key")
label: str = Field(description="Display label") label: str = Field(description="Display label")
type: str = Field(description="Option type (float or int)") type: str = Field(description="Option type (float, int, bool, or select)")
default: Any = Field(description="Default value") default: Any = Field(description="Default value")
min_value: Any = Field(description="Minimum value") min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value") max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment") step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
class FilterTypeResponse(BaseModel): class FilterTypeResponse(BaseModel):

View File

@@ -19,6 +19,7 @@ import wled_controller.core.filters.auto_crop # noqa: F401
import wled_controller.core.filters.flip # noqa: F401 import wled_controller.core.filters.flip # noqa: F401
import wled_controller.core.filters.color_correction # noqa: F401 import wled_controller.core.filters.color_correction # noqa: F401
import wled_controller.core.filters.frame_interpolation # noqa: F401 import wled_controller.core.filters.frame_interpolation # noqa: F401
import wled_controller.core.filters.filter_template # noqa: F401
__all__ = [ __all__ = [
"FilterOptionDef", "FilterOptionDef",

View File

@@ -13,14 +13,15 @@ class FilterOptionDef:
key: str key: str
label: str label: str
option_type: str # "float" | "int" option_type: str # "float" | "int" | "bool" | "select"
default: Any default: Any
min_value: Any min_value: Any
max_value: Any max_value: Any
step: Any step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"key": self.key, "key": self.key,
"label": self.label, "label": self.label,
"type": self.option_type, "type": self.option_type,
@@ -29,6 +30,9 @@ class FilterOptionDef:
"max_value": self.max_value, "max_value": self.max_value,
"step": self.step, "step": self.step,
} }
if self.choices is not None:
d["choices"] = self.choices
return d
class PostprocessingFilter(ABC): class PostprocessingFilter(ABC):
@@ -80,10 +84,12 @@ class PostprocessingFilter(ABC):
val = int(raw) val = int(raw)
elif opt_def.option_type == "bool": elif opt_def.option_type == "bool":
val = bool(raw) if not isinstance(raw, bool) else raw val = bool(raw) if not isinstance(raw, bool) else raw
elif opt_def.option_type == "select":
val = str(raw) if raw is not None else opt_def.default
else: else:
val = raw val = raw
# Clamp to range (skip for bools) # Clamp to range (skip for bools and selects)
if opt_def.option_type != "bool": if opt_def.option_type not in ("bool", "select"):
if opt_def.min_value is not None and val < opt_def.min_value: if opt_def.min_value is not None and val < opt_def.min_value:
val = opt_def.min_value val = opt_def.min_value
if opt_def.max_value is not None and val > opt_def.max_value: if opt_def.max_value is not None and val > opt_def.max_value:

View File

@@ -0,0 +1,41 @@
"""Filter Template meta-filter — references another postprocessing template.
This filter exists in the registry for UI discovery only. It is never
instantiated at runtime: ``LiveStreamManager`` expands it into the
referenced template's filters when building the processing pipeline.
"""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class FilterTemplateFilter(PostprocessingFilter):
"""Include another filter template's chain at this position."""
filter_id = "filter_template"
filter_name = "Filter Template"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="template_id",
label="Template",
option_type="select",
default="",
min_value=None,
max_value=None,
step=None,
choices=[], # populated dynamically by GET /api/v1/filters
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time by LiveStreamManager.
return None

View File

@@ -226,18 +226,12 @@ class LiveStreamManager:
source_stream_id = config.source_stream_id source_stream_id = config.source_stream_id
source_live = self.acquire(source_stream_id) source_live = self.acquire(source_stream_id)
# Resolve postprocessing filters # Resolve postprocessing filters (recursively expanding filter_template refs)
filters = [] filters = []
if config.postprocessing_template_id and self._pp_template_store: if config.postprocessing_template_id and self._pp_template_store:
try: try:
pp = self._pp_template_store.get_template(config.postprocessing_template_id) pp = self._pp_template_store.get_template(config.postprocessing_template_id)
for fi in pp.filters: filters = self._resolve_filters(pp.filters)
try:
filters.append(
FilterRegistry.create_instance(fi.filter_id, fi.options)
)
except ValueError as e:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
except ValueError: except ValueError:
logger.warning( logger.warning(
f"PP template {config.postprocessing_template_id} not found, no filters applied" f"PP template {config.postprocessing_template_id} not found, no filters applied"
@@ -245,6 +239,46 @@ class LiveStreamManager:
return ProcessedLiveStream(source_live, filters), source_stream_id return ProcessedLiveStream(source_live, filters), source_stream_id
def _resolve_filters(self, filter_instances, _visited=None):
"""Recursively resolve filter instances, expanding filter_template refs.
Args:
filter_instances: List of FilterInstance configs.
_visited: Set of template IDs already in the expansion stack
(prevents circular references).
Returns:
List of instantiated PostprocessingFilter objects.
"""
if _visited is None:
_visited = set()
resolved = []
for fi in filter_instances:
if fi.filter_id == "filter_template":
template_id = fi.options.get("template_id", "")
if not template_id or not self._pp_template_store:
continue
if template_id in _visited:
logger.warning(
f"Circular filter template reference detected: {template_id}, skipping"
)
continue
try:
pp = self._pp_template_store.get_template(template_id)
_visited.add(template_id)
resolved.extend(self._resolve_filters(pp.filters, _visited))
_visited.discard(template_id)
except ValueError:
logger.warning(f"Referenced filter template '{template_id}' not found, skipping")
else:
try:
resolved.append(
FilterRegistry.create_instance(fi.filter_id, fi.options)
)
except ValueError as e:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
return resolved
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream: def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
"""Create a StaticImageLiveStream from a StaticImagePictureSource config.""" """Create a StaticImageLiveStream from a StaticImagePictureSource config."""
image = self._load_static_image(config.image_source) image = self._load_static_image(config.image_source)

View File

@@ -10,7 +10,7 @@ from typing import Optional
import numpy as np import numpy as np
from wled_controller.core.devices.led_client import LEDClient, create_led_client from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.target_processor import ( from wled_controller.core.processing.target_processor import (
DeviceInfo, DeviceInfo,
@@ -57,6 +57,7 @@ class WledTargetProcessor(TargetProcessor):
self._color_strip_stream = None self._color_strip_stream = None
self._device_state_before: Optional[dict] = None self._device_state_before: Optional[dict] = None
self._overlay_active = False self._overlay_active = False
self._needs_keepalive = True # resolved at start from device capabilities
# Resolved stream metadata (set once stream is acquired) # Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None self._resolved_display_index: Optional[int] = None
@@ -95,6 +96,7 @@ class WledTargetProcessor(TargetProcessor):
f"device ({device_info.led_count} LEDs)" f"device ({device_info.led_count} LEDs)"
) )
self._device_state_before = await self._led_client.snapshot_device_state() self._device_state_before = await self._led_client.snapshot_device_state()
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}") logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}") raise RuntimeError(f"Failed to connect to LED device: {e}")
@@ -290,6 +292,7 @@ class WledTargetProcessor(TargetProcessor):
"timing_total_ms": total_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,
"needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "errors": [metrics.last_error] if metrics.last_error else [],
} }
@@ -467,8 +470,8 @@ class WledTargetProcessor(TargetProcessor):
continue continue
if colors is prev_colors: if colors is prev_colors:
# Same frame — send keepalive if interval elapsed # Same frame — send keepalive if interval elapsed (only for devices that need it)
if prev_colors is not None and (loop_start - last_send_time) >= standby_interval: if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
kc = prev_colors kc = prev_colors

View File

@@ -1175,6 +1175,22 @@ export function renderModalFilterList() {
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)"> onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
</label> </label>
</div>`; </div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
// Exclude the template being edited from filter_template choices (prevent self-reference)
const editingId = document.getElementById('pp-template-id')?.value || '';
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId)
: opt.choices;
const options = filteredChoices.map(c =>
`<option value="${escapeHtml(c.value)}"${c.value === currentVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).join('');
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
} else { } else {
html += `<div class="pp-filter-option"> html += `<div class="pp-filter-option">
<label for="${inputId}"> <label for="${inputId}">
@@ -1245,6 +1261,8 @@ export function updateFilterOption(filterIndex, optionKey, value) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey); const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') { if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value; fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') { } else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value); fi.options[optionKey] = parseInt(value);
} else { } else {

View File

@@ -85,7 +85,7 @@ class TargetEditorModal extends Modal {
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value, css: document.getElementById('target-editor-css').value,
fps: document.getElementById('target-editor-fps').value, fps: document.getElementById('target-editor-fps').value,
standby_interval: document.getElementById('target-editor-standby-interval').value, standby_interval: document.getElementById('target-editor-keepalive-interval').value,
}; };
} }
} }
@@ -122,12 +122,12 @@ function _updateFpsRecommendation() {
} }
} }
function _updateStandbyVisibility() { function _updateKeepaliveVisibility() {
const deviceSelect = document.getElementById('target-editor-device'); const deviceSelect = document.getElementById('target-editor-device');
const standbyGroup = document.getElementById('target-editor-standby-group'); const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const caps = selectedDevice?.capabilities || []; const caps = selectedDevice?.capabilities || [];
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none'; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
} }
export async function showTargetEditor(targetId = null) { export async function showTargetEditor(targetId = null) {
@@ -179,8 +179,8 @@ export async function showTargetEditor(targetId = null) {
const fps = target.fps ?? 30; const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
} else { } else {
// Creating new target — first option is selected by default // Creating new target — first option is selected by default
@@ -188,20 +188,20 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-name').value = ''; document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-standby-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
} }
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!targetId; _targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
cssSelect.onchange = () => _autoGenerateTargetName(); cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities // Show/hide standby interval based on selected device capabilities
_updateStandbyVisibility(); _updateKeepaliveVisibility();
_updateFpsRecommendation(); _updateFpsRecommendation();
targetEditorModal.snapshot(); targetEditorModal.snapshot();
@@ -232,7 +232,7 @@ export async function saveTargetEditor() {
const name = document.getElementById('target-editor-name').value.trim(); const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value; const deviceId = document.getElementById('target-editor-device').value;
const cssId = document.getElementById('target-editor-css').value; const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
if (!name) { if (!name) {
targetEditorModal.showError(t('targets.error.name_required')); targetEditorModal.showError(t('targets.error.name_required'));
@@ -595,10 +595,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="metric-label">${t('device.metrics.frames')}</div> <div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div> <div class="metric-value">${metrics.frames_processed || 0}</div>
</div> </div>
${state.needs_keepalive !== false ? `
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div> <div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div> <div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div> </div>
` : ''}
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div> <div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div> <div class="metric-value">${metrics.errors_count || 0}</div>

View File

@@ -297,6 +297,7 @@
"filters.auto_crop": "Auto Crop", "filters.auto_crop": "Auto Crop",
"filters.flip": "Flip", "filters.flip": "Flip",
"filters.color_correction": "Color Correction", "filters.color_correction": "Color Correction",
"filters.filter_template": "Filter Template",
"postprocessing.description_label": "Description (optional):", "postprocessing.description_label": "Description (optional):",
"postprocessing.description_placeholder": "Describe this template...", "postprocessing.description_placeholder": "Describe this template...",
"postprocessing.created": "Template created successfully", "postprocessing.created": "Template created successfully",
@@ -372,8 +373,8 @@
"targets.interpolation.dominant": "Dominant", "targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:", "targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.standby_interval": "Standby Interval:", "targets.keepalive_interval": "Keep Alive Interval:",
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping the device in live mode (0.5-5.0s)", "targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
"targets.created": "Target created successfully", "targets.created": "Target created successfully",
"targets.updated": "Target updated successfully", "targets.updated": "Target updated successfully",
"targets.deleted": "Target deleted successfully", "targets.deleted": "Target deleted successfully",

View File

@@ -297,6 +297,7 @@
"filters.auto_crop": "Авто Обрезка", "filters.auto_crop": "Авто Обрезка",
"filters.flip": "Отражение", "filters.flip": "Отражение",
"filters.color_correction": "Цветокоррекция", "filters.color_correction": "Цветокоррекция",
"filters.filter_template": "Шаблон фильтров",
"postprocessing.description_label": "Описание (необязательно):", "postprocessing.description_label": "Описание (необязательно):",
"postprocessing.description_placeholder": "Опишите этот шаблон...", "postprocessing.description_placeholder": "Опишите этот шаблон...",
"postprocessing.created": "Шаблон успешно создан", "postprocessing.created": "Шаблон успешно создан",
@@ -372,8 +373,8 @@
"targets.interpolation.dominant": "Доминантный", "targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:", "targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.standby_interval": "Интервал ожидания:", "targets.keepalive_interval": "Интервал поддержания связи:",
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания устройства в режиме live (0.5-5.0с)", "targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана", "targets.created": "Цель успешно создана",
"targets.updated": "Цель успешно обновлена", "targets.updated": "Цель успешно обновлена",
"targets.deleted": "Цель успешно удалена", "targets.deleted": "Цель успешно удалена",

View File

@@ -48,16 +48,16 @@
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small> <small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-standby-group"> <div class="form-group" id="target-editor-keepalive-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-standby-interval"> <label for="target-editor-keepalive-interval">
<span data-i18n="targets.standby_interval">Standby Interval:</span> <span data-i18n="targets.keepalive_interval">Keep Alive Interval:</span>
<span id="target-editor-standby-interval-value">1.0</span><span>s</span> <span id="target-editor-keepalive-interval-value">1.0</span><span>s</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small> <small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value"> <input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div> </div>
<div id="target-editor-error" class="error-message" style="display: none;"></div> <div id="target-editor-error" class="error-message" style="display: none;"></div>