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:
@@ -12,6 +12,7 @@ from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_device_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_processor_manager,
|
||||
get_template_store,
|
||||
)
|
||||
@@ -385,26 +386,44 @@ async def test_template(
|
||||
# ===== FILTER TYPE ENDPOINTS =====
|
||||
|
||||
@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."""
|
||||
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 = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
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(
|
||||
filter_id=filter_cls.filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=[
|
||||
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
|
||||
],
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Filter-related schemas."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -17,11 +17,12 @@ class FilterOptionDefSchema(BaseModel):
|
||||
|
||||
key: str = Field(description="Option key")
|
||||
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")
|
||||
min_value: Any = Field(description="Minimum value")
|
||||
max_value: Any = Field(description="Maximum value")
|
||||
step: Any = Field(description="Step increment")
|
||||
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
|
||||
|
||||
|
||||
class FilterTypeResponse(BaseModel):
|
||||
|
||||
@@ -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.color_correction # noqa: F401
|
||||
import wled_controller.core.filters.frame_interpolation # noqa: F401
|
||||
import wled_controller.core.filters.filter_template # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"FilterOptionDef",
|
||||
|
||||
@@ -13,14 +13,15 @@ class FilterOptionDef:
|
||||
|
||||
key: str
|
||||
label: str
|
||||
option_type: str # "float" | "int"
|
||||
option_type: str # "float" | "int" | "bool" | "select"
|
||||
default: Any
|
||||
min_value: Any
|
||||
max_value: Any
|
||||
step: Any
|
||||
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
d = {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"type": self.option_type,
|
||||
@@ -29,6 +30,9 @@ class FilterOptionDef:
|
||||
"max_value": self.max_value,
|
||||
"step": self.step,
|
||||
}
|
||||
if self.choices is not None:
|
||||
d["choices"] = self.choices
|
||||
return d
|
||||
|
||||
|
||||
class PostprocessingFilter(ABC):
|
||||
@@ -80,10 +84,12 @@ class PostprocessingFilter(ABC):
|
||||
val = int(raw)
|
||||
elif opt_def.option_type == "bool":
|
||||
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:
|
||||
val = raw
|
||||
# Clamp to range (skip for bools)
|
||||
if opt_def.option_type != "bool":
|
||||
# Clamp to range (skip for bools and selects)
|
||||
if opt_def.option_type not in ("bool", "select"):
|
||||
if opt_def.min_value is not None and val < opt_def.min_value:
|
||||
val = opt_def.min_value
|
||||
if opt_def.max_value is not None and val > opt_def.max_value:
|
||||
|
||||
41
server/src/wled_controller/core/filters/filter_template.py
Normal file
41
server/src/wled_controller/core/filters/filter_template.py
Normal 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
|
||||
@@ -226,18 +226,12 @@ class LiveStreamManager:
|
||||
source_stream_id = config.source_stream_id
|
||||
source_live = self.acquire(source_stream_id)
|
||||
|
||||
# Resolve postprocessing filters
|
||||
# Resolve postprocessing filters (recursively expanding filter_template refs)
|
||||
filters = []
|
||||
if config.postprocessing_template_id and self._pp_template_store:
|
||||
try:
|
||||
pp = self._pp_template_store.get_template(config.postprocessing_template_id)
|
||||
for fi in 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}")
|
||||
filters = self._resolve_filters(pp.filters)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
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
|
||||
|
||||
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:
|
||||
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
|
||||
image = self._load_static_image(config.image_source)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
|
||||
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.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
@@ -57,6 +57,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._color_strip_stream = None
|
||||
self._device_state_before: Optional[dict] = None
|
||||
self._overlay_active = False
|
||||
self._needs_keepalive = True # resolved at start from device capabilities
|
||||
|
||||
# Resolved stream metadata (set once stream is acquired)
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
@@ -95,6 +96,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
f"device ({device_info.led_count} LEDs)"
|
||||
)
|
||||
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:
|
||||
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}")
|
||||
@@ -290,6 +292,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"timing_total_ms": total_ms,
|
||||
"display_index": self._resolved_display_index,
|
||||
"overlay_active": self._overlay_active,
|
||||
"needs_keepalive": self._needs_keepalive,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
}
|
||||
@@ -467,8 +470,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
continue
|
||||
|
||||
if colors is prev_colors:
|
||||
# Same frame — send keepalive if interval elapsed
|
||||
if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
||||
# Same frame — send keepalive if interval elapsed (only for devices that need it)
|
||||
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:
|
||||
break
|
||||
kc = prev_colors
|
||||
|
||||
@@ -1175,6 +1175,22 @@ export function renderModalFilterList() {
|
||||
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
|
||||
</label>
|
||||
</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 {
|
||||
html += `<div class="pp-filter-option">
|
||||
<label for="${inputId}">
|
||||
@@ -1245,6 +1261,8 @@ export function updateFilterOption(filterIndex, optionKey, value) {
|
||||
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
|
||||
if (optDef && optDef.type === 'bool') {
|
||||
fi.options[optionKey] = !!value;
|
||||
} else if (optDef && optDef.type === 'select') {
|
||||
fi.options[optionKey] = String(value);
|
||||
} else if (optDef && optDef.type === 'int') {
|
||||
fi.options[optionKey] = parseInt(value);
|
||||
} else {
|
||||
|
||||
@@ -85,7 +85,7 @@ class TargetEditorModal extends Modal {
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
css: document.getElementById('target-editor-css').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 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 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) {
|
||||
@@ -179,8 +179,8 @@ export async function showTargetEditor(targetId = null) {
|
||||
const fps = target.fps ?? 30;
|
||||
document.getElementById('target-editor-fps').value = 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-standby-interval-value').textContent = target.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-keepalive-interval').value = 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');
|
||||
} else {
|
||||
// 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-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-standby-interval').value = 1.0;
|
||||
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
|
||||
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
||||
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
}
|
||||
|
||||
// Auto-name generation
|
||||
_targetNameManuallyEdited = !!targetId;
|
||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||
deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||
deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||
cssSelect.onchange = () => _autoGenerateTargetName();
|
||||
if (!targetId) _autoGenerateTargetName();
|
||||
|
||||
// Show/hide standby interval based on selected device capabilities
|
||||
_updateStandbyVisibility();
|
||||
_updateKeepaliveVisibility();
|
||||
_updateFpsRecommendation();
|
||||
|
||||
targetEditorModal.snapshot();
|
||||
@@ -232,7 +232,7 @@ export async function saveTargetEditor() {
|
||||
const name = document.getElementById('target-editor-name').value.trim();
|
||||
const deviceId = document.getElementById('target-editor-device').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) {
|
||||
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-value">${metrics.frames_processed || 0}</div>
|
||||
</div>
|
||||
${state.needs_keepalive !== false ? `
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.keepalive')}</div>
|
||||
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.errors')}</div>
|
||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
"filters.auto_crop": "Auto Crop",
|
||||
"filters.flip": "Flip",
|
||||
"filters.color_correction": "Color Correction",
|
||||
"filters.filter_template": "Filter Template",
|
||||
"postprocessing.description_label": "Description (optional):",
|
||||
"postprocessing.description_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
@@ -372,8 +373,8 @@
|
||||
"targets.interpolation.dominant": "Dominant",
|
||||
"targets.smoothing": "Smoothing:",
|
||||
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"targets.standby_interval": "Standby 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": "Keep Alive Interval:",
|
||||
"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.updated": "Target updated successfully",
|
||||
"targets.deleted": "Target deleted successfully",
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
"filters.auto_crop": "Авто Обрезка",
|
||||
"filters.flip": "Отражение",
|
||||
"filters.color_correction": "Цветокоррекция",
|
||||
"filters.filter_template": "Шаблон фильтров",
|
||||
"postprocessing.description_label": "Описание (необязательно):",
|
||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||
"postprocessing.created": "Шаблон успешно создан",
|
||||
@@ -372,8 +373,8 @@
|
||||
"targets.interpolation.dominant": "Доминантный",
|
||||
"targets.smoothing": "Сглаживание:",
|
||||
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||
"targets.standby_interval": "Интервал ожидания:",
|
||||
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания устройства в режиме live (0.5-5.0с)",
|
||||
"targets.keepalive_interval": "Интервал поддержания связи:",
|
||||
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
|
||||
"targets.created": "Цель успешно создана",
|
||||
"targets.updated": "Цель успешно обновлена",
|
||||
"targets.deleted": "Цель успешно удалена",
|
||||
|
||||
@@ -48,16 +48,16 @@
|
||||
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-standby-group">
|
||||
<div class="form-group" id="target-editor-keepalive-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-standby-interval">
|
||||
<span data-i18n="targets.standby_interval">Standby Interval:</span>
|
||||
<span id="target-editor-standby-interval-value">1.0</span><span>s</span>
|
||||
<label for="target-editor-keepalive-interval">
|
||||
<span data-i18n="targets.keepalive_interval">Keep Alive Interval:</span>
|
||||
<span id="target-editor-keepalive-interval-value">1.0</span><span>s</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</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>
|
||||
<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">
|
||||
<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-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 id="target-editor-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
Reference in New Issue
Block a user