Add capture template system with in-memory defaults and split device settings UI
Some checks failed
Validate / validate (push) Failing after 8s

- Generate default templates (MSS, DXcam, WGC) in memory from EngineRegistry at startup
- Only persist user-created templates to JSON, skip defaults on load/save
- Add capture_template_id to Device model and DeviceCreate schema
- Remember last used template in localStorage, use it for new devices with fallback
- Split Device Settings dialog into General Settings and Capture Settings
- Add capture settings button (🎬) to device card
- Separate default and custom templates with visual separator in Templates tab
- Add capture engine integration to ProcessorManager
- Add CLAUDE.md with git commit/push policy and server restart instructions
- Add en/ru localization for all new UI elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 02:43:49 +03:00
parent b5545d3198
commit 5370d80466
15 changed files with 772 additions and 106 deletions

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# Claude Instructions for WLED Screen Controller
## CRITICAL: Git Commit and Push Policy
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
**🚨 NEVER PUSH TO REMOTE WITHOUT EXPLICIT USER APPROVAL 🚨**
### Strict Rules
1. **DO NOT** create commits automatically after making changes
2. **DO NOT** commit without being explicitly instructed by the user
3. **DO NOT** push to remote repository without explicit instruction
4. **ALWAYS WAIT** for the user to review changes and ask you to commit
5. **ALWAYS ASK** if you're unsure whether to commit
### Workflow
1. Make code changes as requested
2. **STOP** - Inform user that changes are complete
3. **WAIT** - User reviews the changes
4. **ONLY IF** user explicitly says "commit" or "create a commit":
- Stage the files with `git add`
- Create the commit with a descriptive message
- **STOP** - Do NOT push
5. **ONLY IF** user explicitly says "push" or "commit and push":
- Push to remote repository
### What Counts as Explicit Approval
**YES - These mean you can commit:**
- "commit"
- "create a commit"
- "commit these changes"
- "git commit"
**YES - These mean you can push:**
- "push"
- "commit and push"
- "push to remote"
- "git push"
**NO - These do NOT mean you should commit:**
- "that looks good"
- "thanks"
- "perfect"
- User silence after you make changes
- Completing a feature/fix
### Example Bad Behavior (DON'T DO THIS)
```
❌ User: "Fix the MSS engine test issue"
❌ Claude: [fixes the issue]
❌ Claude: [automatically commits without asking] <-- WRONG!
```
### Example Good Behavior (DO THIS)
```
✅ User: "Fix the MSS engine test issue"
✅ Claude: [fixes the issue]
✅ Claude: "I've fixed the MSS engine test issue by adding auto-initialization..."
✅ [WAITS FOR USER]
✅ User: "Looks good, commit it"
✅ Claude: [now creates the commit]
```
## Project Structure
This is a monorepo containing:
- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions)
- `/client` - Future frontend client (if applicable)
## Working with Server
For detailed server-specific instructions (restart policy, testing, etc.), see:
- `server/CLAUDE.md`
## General Guidelines
- Always test changes before marking as complete
- Follow existing code style and patterns
- Update documentation when changing behavior
- Write clear, descriptive commit messages when explicitly instructed
- Never make commits or pushes without explicit user approval

View File

@@ -24,12 +24,34 @@ netstat -an | grep 8080
```
#### How to restart:
1. Stop the current server (if running as background task, use TaskStop with the task ID)
2. Start a new server instance:
1. **Find the task ID** of the running server (look for background bash tasks in conversation)
2. **Stop the server** using TaskStop with the task ID
3. **Check for port conflicts** (port 8080 may still be in use):
```bash
netstat -ano | findstr :8080
```
If a process is still using port 8080, kill it:
```bash
powershell -Command "Stop-Process -Id <PID> -Force"
```
4. **Start a new server instance** in the background:
```bash
cd server && python -m wled_controller.main
```
3. Verify the new server started successfully by checking the output logs
Use `run_in_background: true` parameter in Bash tool
5. **Wait 3 seconds** for server to initialize:
```bash
sleep 3
```
6. **Verify startup** by reading the output file:
- Look for "Uvicorn running on http://0.0.0.0:8080"
- Check for any errors in stderr
- Verify "Application startup complete" message
**Common Issues:**
- **Port 8080 in use**: Old process didn't terminate cleanly - kill it manually
- **Module import errors**: Check that all Python files are syntactically correct
- **Permission errors**: Ensure file permissions allow Python to execute
#### Files that DON'T require restart:
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
@@ -37,24 +59,26 @@ netstat -an | grep 8080
- Documentation files (`*.md`)
- Configuration files in `config/` if server supports hot-reload (check implementation)
### Git Push Policy
### Git Commit and Push Policy
**CRITICAL**: NEVER push commits to the remote repository without explicit user approval.
**CRITICAL**: NEVER commit OR push code changes without explicit user approval.
#### Rules
- You MAY create commits when requested or when appropriate
- You MUST NOT create commits without explicit user instruction
- You MUST NOT push commits unless explicitly instructed by the user
- Wait for the user to review changes and ask you to commit
- If the user says "commit", create a commit but DO NOT push
- If the user says "commit and push", you may push after committing
- Always wait for explicit permission before any push operation
- Always wait for explicit permission before any commit or push operation
#### Workflow
1. Make changes to code
2. Create commit when appropriate (with user consent)
3. **STOP and WAIT** - do not push
4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
2. **STOP and WAIT** - inform the user of changes and wait for instruction
3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit")
4. **STOP and WAIT** - do not push
5. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
### Testing Changes

View File

@@ -48,6 +48,11 @@ dev = [
"black>=24.0.0",
"ruff>=0.6.0",
]
# High-performance screen capture engines (Windows only)
perf = [
"dxcam>=0.0.5; sys_platform == 'win32'",
"windows-capture>=1.5.0; sys_platform == 'win32'",
]
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"

View File

@@ -176,6 +176,7 @@ async def create_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
template_store: TemplateStore = Depends(get_template_store),
):
"""Create and attach a new WLED device."""
try:
@@ -214,11 +215,29 @@ async def create_device(
detail=f"Failed to connect to WLED device at {device_url}: {e}"
)
# Resolve capture template: use requested ID if valid, else first available
capture_template_id = None
if device_data.capture_template_id:
try:
template_store.get_template(device_data.capture_template_id)
capture_template_id = device_data.capture_template_id
except ValueError:
logger.warning(
f"Requested template '{device_data.capture_template_id}' not found, using first available"
)
if not capture_template_id:
all_templates = template_store.get_all_templates()
if all_templates:
capture_template_id = all_templates[0].id
else:
capture_template_id = "tpl_mss_default"
# Create device in storage (LED count auto-detected from WLED)
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=wled_led_count,
capture_template_id=capture_template_id,
)
# Add to processor manager

View File

@@ -55,6 +55,7 @@ class DeviceCreate(BaseModel):
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
capture_template_id: Optional[str] = Field(None, description="Capture template ID (uses first available if not set or invalid)")
class DeviceUpdate(BaseModel):
@@ -63,6 +64,7 @@ class DeviceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
class ColorCorrection(BaseModel):
@@ -153,6 +155,7 @@ class DeviceResponse(BaseModel):
)
settings: ProcessingSettings = Field(description="Processing settings")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
capture_template_id: str = Field(description="ID of assigned capture template")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -210,3 +213,110 @@ class ErrorResponse(BaseModel):
message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
# Capture Template Schemas
class TemplateCreate(BaseModel):
"""Request to create a capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class TemplateUpdate(BaseModel):
"""Request to update a template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class TemplateResponse(BaseModel):
"""Template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
engine_type: str = Field(description="Engine type identifier")
engine_config: Dict = Field(description="Engine-specific configuration")
is_default: bool = Field(description="Whether this is a system default template")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
class TemplateListResponse(BaseModel):
"""List of templates response."""
templates: List[TemplateResponse] = Field(description="List of templates")
count: int = Field(description="Number of templates")
class EngineInfo(BaseModel):
"""Capture engine information."""
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
class EngineListResponse(BaseModel):
"""List of available engines response."""
engines: List[EngineInfo] = Field(description="Available capture engines")
count: int = Field(description="Number of engines")
class TemplateAssignment(BaseModel):
"""Request to assign template to device."""
template_id: str = Field(description="Template ID to assign")
class TemplateTestRequest(BaseModel):
"""Request to test a capture template."""
engine_type: str = Field(description="Capture engine type to test")
engine_config: Dict = Field(default={}, description="Engine configuration")
display_index: int = Field(description="Display index to capture")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels")
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
class CaptureImage(BaseModel):
"""Captured image with metadata."""
image: str = Field(description="Base64-encoded image data")
width: int = Field(description="Image width in pixels")
height: int = Field(description="Image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel):
"""Extracted border images."""
top: str = Field(description="Base64-encoded top border image")
right: str = Field(description="Base64-encoded right border image")
bottom: str = Field(description="Base64-encoded bottom border image")
left: str = Field(description="Base64-encoded left border image")
class PerformanceMetrics(BaseModel):
"""Performance metrics for template test."""
capture_duration_s: float = Field(description="Total capture duration in seconds")
frame_count: int = Field(description="Number of frames captured")
actual_fps: float = Field(description="Actual FPS (frame_count / duration)")
avg_capture_time_ms: float = Field(description="Average time per frame capture in milliseconds")
class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
performance: PerformanceMetrics = Field(description="Performance metrics")

View File

@@ -54,6 +54,7 @@ class StorageConfig(BaseSettings):
"""Storage configuration."""
devices_file: str = "data/devices.json"
templates_file: str = "data/capture_templates.json"
class LoggingConfig(BaseSettings):

View File

@@ -13,8 +13,9 @@ from wled_controller.core.calibration import (
PixelMapper,
create_default_calibration,
)
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
from wled_controller.core.screen_capture import capture_display, extract_border_pixels
from wled_controller.core.screen_capture import extract_border_pixels
from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger
@@ -87,8 +88,10 @@ class ProcessorState:
led_count: int
settings: ProcessingSettings
calibration: CalibrationConfig
capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine
wled_client: Optional[WLEDClient] = None
pixel_mapper: Optional[PixelMapper] = None
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
@@ -122,6 +125,7 @@ class ProcessorManager:
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
):
"""Add a device for processing.
@@ -131,6 +135,7 @@ class ProcessorManager:
led_count: Number of LEDs
settings: Processing settings (uses defaults if None)
calibration: Calibration config (creates default if None)
capture_template_id: Template ID for screen capture engine
"""
if device_id in self._processors:
raise ValueError(f"Device {device_id} already exists")
@@ -147,6 +152,7 @@ class ProcessorManager:
led_count=led_count,
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
)
self._processors[device_id] = state
@@ -270,6 +276,21 @@ class ProcessorManager:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
# Initialize capture engine
# Phase 2: Use MSS engine for all devices (template integration in Phase 5)
try:
# For now, always use MSS engine (Phase 5 will load from template)
engine = EngineRegistry.create_engine("mss", {})
engine.initialize()
state.capture_engine = engine
logger.debug(f"Initialized capture engine for device {device_id}: mss")
except Exception as e:
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
# Cleanup WLED client before raising
if state.wled_client:
await state.wled_client.disconnect()
raise RuntimeError(f"Failed to initialize capture engine: {e}")
# Initialize pixel mapper
state.pixel_mapper = PixelMapper(
state.calibration,
@@ -321,6 +342,11 @@ class ProcessorManager:
await state.wled_client.close()
state.wled_client = None
# Cleanup capture engine
if state.capture_engine:
state.capture_engine.cleanup()
state.capture_engine = None
logger.info(f"Stopped processing for device {device_id}")
async def _processing_loop(self, device_id: str):
@@ -351,8 +377,11 @@ class ProcessorManager:
try:
# Run blocking operations in thread pool to avoid blocking event loop
# Capture screen (blocking I/O)
capture = await asyncio.to_thread(capture_display, settings.display_index)
# Capture screen using engine (blocking I/O)
capture = await asyncio.to_thread(
state.capture_engine.capture_display,
settings.display_index
)
# Extract border pixels (CPU-intensive)
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
@@ -725,3 +754,31 @@ class ProcessorManager:
"wled_led_type": h.wled_led_type,
"error": h.error,
}
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any device.
Args:
display_index: Display index to check
Returns:
True if the display is actively being captured
"""
for state in self._processors.values():
if state.is_running and state.settings.display_index == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display.
Args:
display_index: Display index to check
Returns:
Device ID if locked, None otherwise
"""
for device_id, state in self._processors.items():
if state.is_running and state.settings.display_index == display_index:
return device_id
return None

View File

@@ -15,6 +15,7 @@ from wled_controller.api.routes import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.utils import setup_logging, get_logger
# Initialize logging
@@ -26,6 +27,7 @@ config = get_config()
# Initialize storage and processing
device_store = DeviceStore(config.storage.devices_file)
template_store = TemplateStore(config.storage.templates_file)
processor_manager = ProcessorManager()
@@ -59,7 +61,7 @@ async def lifespan(app: FastAPI):
logger.info("All API requests require valid Bearer token authentication")
# Initialize API dependencies
init_dependencies(device_store, processor_manager)
init_dependencies(device_store, template_store, processor_manager)
# Load existing devices into processor manager
devices = device_store.get_all_devices()
@@ -71,6 +73,7 @@ async def lifespan(app: FastAPI):
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
capture_template_id=device.capture_template_id,
)
logger.info(f"Loaded device: {device.name} ({device.id})")
except Exception as e:
@@ -165,5 +168,5 @@ if __name__ == "__main__":
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
reload=True,
reload=False, # Disabled due to watchfiles infinite reload loop
)

View File

@@ -630,6 +630,9 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
🎬
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
@@ -737,12 +740,7 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
if (deviceResponse.status === 401) {
handle401Error();
@@ -756,48 +754,7 @@ async function showSettings(deviceId) {
const device = await deviceResponse.json();
// Populate display index select
const displaySelect = document.getElementById('settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('settings-capture-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Populate other fields
// Populate fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
@@ -807,9 +764,7 @@ async function showSettings(deviceId) {
settingsInitialValues = {
name: device.name,
url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
@@ -832,9 +787,7 @@ function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
@@ -859,9 +812,7 @@ async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const capture_template_id = document.getElementById('settings-capture-template').value;
const error = document.getElementById('settings-error');
// Validation
@@ -872,11 +823,11 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url, capture_template_id)
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, capture_template_id })
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
@@ -895,7 +846,7 @@ async function saveDeviceSettings() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index, state_check_interval })
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -904,7 +855,7 @@ async function saveDeviceSettings() {
}
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
showToast(t('settings.saved'), 'success');
forceCloseDeviceSettingsModal();
loadDevices();
} else {
@@ -919,6 +870,170 @@ async function saveDeviceSettings() {
}
}
// ===== Capture Settings Modal =====
let captureSettingsInitialValues = {};
async function showCaptureSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load capture settings', 'error');
return;
}
const device = await deviceResponse.json();
// Populate display index select
const displaySelect = document.getElementById('capture-settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('capture-settings-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Store device ID and snapshot initial values
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
const modal = document.getElementById('capture-settings-modal');
modal.style.display = 'flex';
lockBody();
} catch (error) {
console.error('Failed to load capture settings:', error);
showToast('Failed to load capture settings', 'error');
}
}
function isCaptureSettingsDirty() {
return (
document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index ||
document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id
);
}
function forceCloseCaptureSettingsModal() {
const modal = document.getElementById('capture-settings-modal');
const error = document.getElementById('capture-settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
captureSettingsInitialValues = {};
}
async function closeCaptureSettingsModal() {
if (isCaptureSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCaptureSettingsModal();
}
async function saveCaptureSettings() {
const deviceId = document.getElementById('capture-settings-device-id').value;
const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0;
const capture_template_id = document.getElementById('capture-settings-template').value;
const error = document.getElementById('capture-settings-error');
try {
// Update capture template on device
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ capture_template_id })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update capture template: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Update display index in settings
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index })
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
// Remember last used template for new device creation
localStorage.setItem('lastCaptureTemplateId', capture_template_id);
showToast(t('settings.capture.saved'), 'success');
forceCloseCaptureSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save capture settings:', err);
error.textContent = t('settings.capture.failed');
error.style.display = 'block';
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
@@ -971,10 +1086,16 @@ async function handleAddDevice(event) {
}
try {
const body = { name, url };
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
}
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify(body)
});
if (response.status === 401) {
@@ -1928,12 +2049,18 @@ document.addEventListener('click', (e) => {
return;
}
// Settings modal: dirty check
// General settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Capture settings modal: dirty check
if (modalId === 'capture-settings-modal') {
closeCaptureSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
@@ -1977,8 +2104,9 @@ const deviceTutorialSteps = [
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' }
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
function startTutorial(config) {
@@ -2218,7 +2346,10 @@ function renderTemplatesList(templates) {
return;
}
container.innerHTML = templates.map(template => {
const defaultTemplates = templates.filter(t => t.is_default);
const customTemplates = templates.filter(t => !t.is_default);
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
@@ -2258,10 +2389,21 @@ function renderTemplatesList(templates) {
</div>
</div>
`;
}).join('') + `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
};
let html = defaultTemplates.map(renderCard).join('');
if (customTemplates.length > 0) {
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
html += customTemplates.map(renderCard).join('');
}
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
container.innerHTML = html;
}
// Get engine icon

View File

@@ -206,11 +206,11 @@
</div>
</div>
<!-- Device Settings Modal -->
<!-- General Settings Modal -->
<div id="device-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.title">⚙️ Device Settings</h2>
<h2 data-i18n="settings.general.title">⚙️ General Settings</h2>
<button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
@@ -228,18 +228,6 @@
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="settings-display-index"></select>
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group">
<label for="settings-capture-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="settings-capture-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
@@ -256,6 +244,39 @@
</div>
</div>
<!-- Capture Settings Modal -->
<div id="capture-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.capture.title">🎬 Capture Settings</h2>
<button class="modal-close-btn" onclick="closeCaptureSettingsModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="capture-settings-form">
<input type="hidden" id="capture-settings-device-id">
<div class="form-group">
<label for="capture-settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="capture-settings-display-index"></select>
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group">
<label for="capture-settings-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="capture-settings-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div id="capture-settings-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCaptureSettingsModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">

View File

@@ -32,6 +32,61 @@
"displays.loading": "Loading displays...",
"displays.none": "No displays available",
"displays.failed": "Failed to load displays",
"templates.title": "\uD83C\uDFAF Capture Templates",
"templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.",
"templates.loading": "Loading templates...",
"templates.empty": "No capture templates configured",
"templates.add": "Add Capture Template",
"templates.edit": "Edit Capture Template",
"templates.name": "Template Name:",
"templates.name.placeholder": "My Custom Template",
"templates.description.label": "Description (optional):",
"templates.description.placeholder": "Describe this template...",
"templates.engine": "Capture Engine:",
"templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system",
"templates.config": "Engine Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
"templates.default": "Default",
"templates.custom": "Custom Templates",
"templates.default.locked": "Default template (cannot edit/delete)",
"templates.created": "Template created successfully",
"templates.updated": "Template updated successfully",
"templates.deleted": "Template deleted successfully",
"templates.delete.confirm": "Are you sure you want to delete this template?",
"templates.error.load": "Failed to load templates",
"templates.error.engines": "Failed to load engines",
"templates.error.required": "Please fill in all required fields",
"templates.error.delete": "Failed to delete template",
"templates.test.title": "Test Capture",
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:",
"templates.test.display.select": "Select display...",
"templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):",
"templates.test.run": "\uD83E\uDDEA Run Test",
"templates.test.running": "Running test...",
"templates.test.results.preview": "Full Capture Preview",
"templates.test.results.borders": "Border Extraction",
"templates.test.results.top": "Top",
"templates.test.results.right": "Right",
"templates.test.results.bottom": "Bottom",
"templates.test.results.left": "Left",
"templates.test.results.performance": "Performance",
"templates.test.results.capture_time": "Capture",
"templates.test.results.extraction_time": "Extraction",
"templates.test.results.total_time": "Total",
"templates.test.results.max_fps": "Max FPS",
"templates.test.results.duration": "Duration",
"templates.test.results.frame_count": "Frames",
"templates.test.results.actual_fps": "Actual FPS",
"templates.test.results.avg_capture_time": "Avg Capture",
"templates.test.error.no_engine": "Please select a capture engine",
"templates.test.error.no_display": "Please select a display",
"templates.test.error.failed": "Test failed",
"devices.title": "\uD83D\uDCA1 Devices",
"devices.add": "Add New Device",
"devices.loading": "Loading devices...",
@@ -54,7 +109,8 @@
"device.button.add": "Add Device",
"device.button.start": "Start",
"device.button.stop": "Stop",
"device.button.settings": "Settings",
"device.button.settings": "General Settings",
"device.button.capture_settings": "Capture Settings",
"device.button.calibrate": "Calibrate",
"device.button.remove": "Remove",
"device.button.webui": "Open WLED Web UI",
@@ -81,16 +137,23 @@
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
"device.tip.brightness": "Slide to adjust device brightness",
"device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure device settings (display, FPS, health check)",
"device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new WLED device",
"settings.title": "Device Settings",
"settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings",
"settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device",
"settings.display_index": "Display:",
"settings.display_index.hint": "Which screen to capture for this device",
"settings.capture_template": "Capture Template:",
"settings.capture_template.hint": "Screen capture engine and configuration for this device",
"settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",

View File

@@ -32,6 +32,61 @@
"displays.loading": "Загрузка дисплеев...",
"displays.none": "Нет доступных дисплеев",
"displays.failed": "Не удалось загрузить дисплеи",
"templates.title": "\uD83C\uDFAF Шаблоны Захвата",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...",
"templates.empty": "Шаблоны захвата не настроены",
"templates.add": "Добавить Шаблон Захвата",
"templates.edit": "Редактировать Шаблон Захвата",
"templates.name": "Имя Шаблона:",
"templates.name.placeholder": "Мой Пользовательский Шаблон",
"templates.description.label": "Описание (необязательно):",
"templates.description.placeholder": "Опишите этот шаблон...",
"templates.engine": "Движок Захвата:",
"templates.engine.select": "Выберите движок...",
"templates.engine.unavailable": "Недоступен",
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
"templates.config": "Конфигурация Движка",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
"templates.default": "По умолчанию",
"templates.custom": "Пользовательские шаблоны",
"templates.default.locked": "Системный шаблон (нельзя редактировать/удалить)",
"templates.created": "Шаблон успешно создан",
"templates.updated": "Шаблон успешно обновлён",
"templates.deleted": "Шаблон успешно удалён",
"templates.delete.confirm": "Вы уверены, что хотите удалить этот шаблон?",
"templates.error.load": "Не удалось загрузить шаблоны",
"templates.error.engines": "Не удалось загрузить движки",
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
"templates.error.delete": "Не удалось удалить шаблон",
"templates.test.title": "Тест Захвата",
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:",
"templates.test.display.select": "Выберите дисплей...",
"templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "\uD83E\uDDEA Запустить Тест",
"templates.test.running": "Выполняется тест...",
"templates.test.results.preview": "Полный Предпросмотр Захвата",
"templates.test.results.borders": "Извлечение Границ",
"templates.test.results.top": "Сверху",
"templates.test.results.right": "Справа",
"templates.test.results.bottom": "Снизу",
"templates.test.results.left": "Слева",
"templates.test.results.performance": "Производительность",
"templates.test.results.capture_time": "Захват",
"templates.test.results.extraction_time": "Извлечение",
"templates.test.results.total_time": "Всего",
"templates.test.results.max_fps": "Макс. FPS",
"templates.test.results.duration": "Длительность",
"templates.test.results.frame_count": "Кадры",
"templates.test.results.actual_fps": "Факт. FPS",
"templates.test.results.avg_capture_time": "Средн. Захват",
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
"templates.test.error.failed": "Тест не удался",
"devices.title": "\uD83D\uDCA1 Устройства",
"devices.add": "Добавить Новое Устройство",
"devices.loading": "Загрузка устройств...",
@@ -54,7 +109,8 @@
"device.button.add": "Добавить Устройство",
"device.button.start": "Запустить",
"device.button.stop": "Остановить",
"device.button.settings": "Настройки",
"device.button.settings": "Основные настройки",
"device.button.capture_settings": "Настройки захвата",
"device.button.calibrate": "Калибровка",
"device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс WLED",
@@ -81,16 +137,23 @@
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
"device.tip.brightness": "Перетащите для регулировки яркости",
"device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Настройки устройства (дисплей, FPS, интервал проверки)",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
"settings.title": "Настройки Устройства",
"settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата",
"settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.capture_template": "Шаблон Захвата:",
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
"settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",

View File

@@ -1711,6 +1711,24 @@ input:-webkit-autofill:focus {
gap: 20px;
}
.templates-separator {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.templates-separator::before,
.templates-separator::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--border-color);

View File

@@ -30,6 +30,7 @@ class Device:
enabled: bool = True,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -43,6 +44,7 @@ class Device:
enabled: Whether device is enabled
settings: Processing settings
calibration: Calibration configuration
capture_template_id: ID of assigned capture template
created_at: Creation timestamp
updated_at: Last update timestamp
"""
@@ -53,6 +55,7 @@ class Device:
self.enabled = enabled
self.settings = settings or ProcessingSettings()
self.calibration = calibration or create_default_calibration(led_count)
self.capture_template_id = capture_template_id
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
@@ -80,6 +83,7 @@ class Device:
"state_check_interval": self.settings.state_check_interval,
},
"calibration": calibration_to_dict(self.calibration),
"capture_template_id": self.capture_template_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -117,6 +121,12 @@ class Device:
else create_default_calibration(data["led_count"])
)
# Migration: assign default MSS template if no template set
capture_template_id = data.get("capture_template_id")
if not capture_template_id:
capture_template_id = "tpl_mss_default"
logger.info(f"Migrating device {data['id']} to default MSS template")
return cls(
device_id=data["id"],
name=data["name"],
@@ -125,6 +135,7 @@ class Device:
enabled=data.get("enabled", True),
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
@@ -206,6 +217,7 @@ class DeviceStore:
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
) -> Device:
"""Create a new device.
@@ -215,6 +227,7 @@ class DeviceStore:
led_count: Number of LEDs
settings: Processing settings
calibration: Calibration configuration
capture_template_id: ID of assigned capture template
Returns:
Created device
@@ -233,6 +246,7 @@ class DeviceStore:
led_count=led_count,
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
)
# Store
@@ -270,6 +284,7 @@ class DeviceStore:
enabled: Optional[bool] = None,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: Optional[str] = None,
) -> Device:
"""Update device.
@@ -281,6 +296,7 @@ class DeviceStore:
enabled: New enabled state (optional)
settings: New settings (optional)
calibration: New calibration (optional)
capture_template_id: New capture template ID (optional)
Returns:
Updated device
@@ -313,6 +329,8 @@ class DeviceStore:
f"does not match device LED count ({device.led_count})"
)
device.calibration = calibration
if capture_template_id is not None:
device.capture_template_id = capture_template_id
device.updated_at = datetime.utcnow()

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.capture_engines.factory import EngineRegistry
from wled_controller.storage.template import CaptureTemplate
from wled_controller.utils import get_logger
@@ -13,7 +14,11 @@ logger = get_logger(__name__)
class TemplateStore:
"""Storage for capture templates."""
"""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.
"""
def __init__(self, file_path: str):
"""Initialize template store.
@@ -23,13 +28,35 @@ class TemplateStore:
"""
self.file_path = Path(file_path)
self._templates: Dict[str, CaptureTemplate] = {}
self._ensure_defaults()
self._load()
def _ensure_defaults(self) -> None:
"""Create default templates in memory for all available engines."""
available = EngineRegistry.get_available_engines()
now = datetime.utcnow()
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()
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")
def _load(self) -> None:
"""Load templates from file."""
"""Load user-created templates from file."""
if not self.file_path.exists():
logger.warning(f"Templates file not found: {self.file_path}")
self._save() # Create empty file
return
try:
@@ -37,33 +64,42 @@ class TemplateStore:
data = json.load(f)
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
loaded += 1
except Exception as e:
logger.error(
f"Failed to load template {template_id}: {e}",
exc_info=True
)
logger.info(f"Loaded {len(self._templates)} templates from storage")
logger.info(f"Template store initialized with {len(self._templates)} templates")
if loaded > 0:
logger.info(f"Loaded {loaded} user 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")
def _save(self) -> None:
"""Save templates to file."""
"""Save only user-created templates to file."""
try:
# Ensure directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Convert templates to dict format
# 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 = {