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 (
|
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))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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_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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Цель успешно удалена",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user