Add Picture Streams architecture with postprocessing templates and stream test UI

Introduce Picture Stream abstraction that separates the capture pipeline into
composable layers: raw streams (display + capture engine + FPS) and processed
streams (source stream + postprocessing template). Devices reference a picture
stream instead of managing individual capture settings.

- Add PictureStream and PostprocessingTemplate data models and stores
- Add CRUD API endpoints for picture streams and postprocessing templates
- Add stream chain resolution in ProcessorManager for start_processing
- Add picture stream test endpoint with postprocessing preview support
- Add Stream Settings modal with border_width and interpolation_mode controls
- Add stream test modal with capture preview and performance metrics
- Add full frontend: Picture Streams tab, Processing Templates tab, stream
  selector on device cards, test buttons on stream cards
- Add localization keys for all new features (en, ru)
- Migrate existing devices to picture streams on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 00:00:30 +03:00
parent 3db7ba4b0e
commit 493f14fba9
23 changed files with 2773 additions and 200 deletions

View File

@@ -16,8 +16,9 @@ logger = get_logger(__name__)
class TemplateStore:
"""Storage for capture templates.
Default templates for each available engine are created in memory at startup.
Only user-created templates are persisted to the JSON file.
All templates are persisted to the JSON file.
On startup, if no templates exist, one is auto-created using the
highest-priority available engine.
"""
def __init__(self, file_path: str):
@@ -28,34 +29,40 @@ class TemplateStore:
"""
self.file_path = Path(file_path)
self._templates: Dict[str, CaptureTemplate] = {}
self._ensure_defaults()
self._load()
self._ensure_initial_template()
def _ensure_defaults(self) -> None:
"""Create default templates in memory for all available engines."""
available = EngineRegistry.get_available_engines()
def _ensure_initial_template(self) -> None:
"""Auto-create a template if none exist, using the best available engine."""
if self._templates:
return
best_engine = EngineRegistry.get_best_available_engine()
if not best_engine:
logger.warning("No capture engines available, cannot create initial template")
return
engine_class = EngineRegistry.get_engine(best_engine)
default_config = engine_class.get_default_config()
now = datetime.utcnow()
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
for engine_type in available:
template_id = f"tpl_{engine_type}_default"
engine_class = EngineRegistry.get_engine(engine_type)
default_config = engine_class.get_default_config()
template = CaptureTemplate(
id=template_id,
name=best_engine.upper(),
engine_type=best_engine,
engine_config=default_config,
created_at=now,
updated_at=now,
description=f"Auto-created {best_engine.upper()} template",
)
self._templates[template_id] = CaptureTemplate(
id=template_id,
name=engine_type.upper(),
engine_type=engine_type,
engine_config=default_config,
is_default=True,
created_at=now,
updated_at=now,
description=f"Default {engine_type} capture template",
)
logger.info(f"Created {len(available)} default templates in memory")
self._templates[template_id] = template
self._save()
logger.info(f"Auto-created initial template: {template.name} ({template_id}, engine={best_engine})")
def _load(self) -> None:
"""Load user-created templates from file."""
"""Load templates from file."""
if not self.file_path.exists():
return
@@ -66,9 +73,6 @@ class TemplateStore:
templates_data = data.get("templates", {})
loaded = 0
for template_id, template_dict in templates_data.items():
# Skip any default templates that may exist in old files
if template_dict.get("is_default", False):
continue
try:
template = CaptureTemplate.from_dict(template_dict)
self._templates[template_id] = template
@@ -80,26 +84,23 @@ class TemplateStore:
)
if loaded > 0:
logger.info(f"Loaded {loaded} user templates from storage")
logger.info(f"Loaded {loaded} templates from storage")
except Exception as e:
logger.error(f"Failed to load templates from {self.file_path}: {e}")
raise
total = len(self._templates)
logger.info(f"Template store initialized with {total} templates")
logger.info(f"Template store initialized with {len(self._templates)} templates")
def _save(self) -> None:
"""Save only user-created templates to file."""
"""Save all templates to file."""
try:
# Ensure directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Only persist non-default templates
templates_dict = {
template_id: template.to_dict()
for template_id, template in self._templates.items()
if not template.is_default
}
data = {
@@ -162,7 +163,7 @@ class TemplateStore:
"""
# Check for duplicate name
for template in self._templates.values():
if template.name == name and not template.is_default:
if template.name == name:
raise ValueError(f"Template with name '{name}' already exists")
# Generate new ID
@@ -175,7 +176,6 @@ class TemplateStore:
name=name,
engine_type=engine_type,
engine_config=engine_config,
is_default=False,
created_at=now,
updated_at=now,
description=description,
@@ -209,16 +209,13 @@ class TemplateStore:
Updated template
Raises:
ValueError: If template not found or is a default template
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot modify default templates")
# Update fields
if name is not None:
template.name = name
@@ -244,16 +241,11 @@ class TemplateStore:
template_id: Template ID
Raises:
ValueError: If template not found or is a default template
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot delete default templates")
# Remove and save
del self._templates[template_id]
self._save()