Add Key Colors target type for extracting colors from screen regions

Introduce a new "key_colors" target type alongside WLED targets, enabling
real-time color extraction from configurable screen rectangles with
average/median/dominant modes, temporal smoothing, and WebSocket streaming.

- Split WledPictureTarget into its own module, add KeyColorsPictureTarget
- Add KC target lifecycle to ProcessorManager (register, start/stop, processing loop)
- Extend API routes and schemas for KC targets (CRUD, settings, state, metrics, colors)
- Add WebSocket endpoint for real-time color updates with auth
- Add KC sub-tab in Targets UI with editor modal and live color swatches
- Add EN and RU translations for all key colors strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 16:43:09 +03:00
parent 3d2393e474
commit 5f9bc9a37e
13 changed files with 1525 additions and 111 deletions

View File

@@ -3998,7 +3998,7 @@ async function loadTargetsTab() {
})
);
// Fetch state + metrics for each target
// Fetch state + metrics for each target (+ colors for KC targets)
const targetsWithState = await Promise.all(
targets.map(async (target) => {
try {
@@ -4006,7 +4006,14 @@ async function loadTargetsTab() {
const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics };
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
}
@@ -4017,14 +4024,16 @@ async function loadTargetsTab() {
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type (currently only WLED)
// Group by type
const wledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
@@ -4054,7 +4063,21 @@ async function loadTargetsTab() {
</div>
</div>`;
container.innerHTML = tabBar + wledPanel;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + wledPanel + kcPanel;
// Attach event listeners and fetch WLED brightness for device cards
devicesWithState.forEach(device => {
@@ -4062,6 +4085,21 @@ async function loadTargetsTab() {
fetchDeviceBrightness(device.id);
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
} catch (error) {
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
@@ -4203,3 +4241,425 @@ async function deleteTarget(targetId) {
showToast('Failed to delete target', 'error');
}
}
// ===== KEY COLORS TARGET CARD =====
function createKCTargetCard(target, sourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
const isProcessing = state.processing || false;
const source = sourceMap[target.picture_source_id];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const rectCount = (kcSettings.rectangles || []).length;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
<span class="badge">KEY COLORS</span>
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="card-content">
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('targets.metrics.frames')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('targets.metrics.errors')}</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS EDITOR =====
let kcEditorRectangles = [];
let kcEditorInitialValues = {};
let _kcNameManuallyEdited = false;
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
const sourceSelect = document.getElementById('kc-editor-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
const rectCount = kcEditorRectangles.length;
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
}
function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
renderKCRectangles();
_autoGenerateKCName();
}
function removeKCRectangle(index) {
kcEditorRectangles.splice(index, 1);
renderKCRectangles();
_autoGenerateKCName();
}
function renderKCRectangles() {
const container = document.getElementById('kc-rect-list');
if (!container) return;
if (kcEditorRectangles.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('kc.rect.empty')}</div>`;
return;
}
const labels = `<div class="kc-rect-labels">
<span>${t('kc.rect.name')}</span>
<span>${t('kc.rect.x')}</span>
<span>${t('kc.rect.y')}</span>
<span>${t('kc.rect.width')}</span>
<span>${t('kc.rect.height')}</span>
<span></span>
</div>`;
const rows = kcEditorRectangles.map((rect, i) => `
<div class="kc-rect-row">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('kc.rect.name')}" onchange="kcEditorRectangles[${i}].name = this.value">
<input type="number" value="${rect.x}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].x = parseFloat(this.value) || 0">
<input type="number" value="${rect.y}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].y = parseFloat(this.value) || 0">
<input type="number" value="${rect.width}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].width = parseFloat(this.value) || 0.01">
<input type="number" value="${rect.height}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].height = parseFloat(this.value) || 0.01">
<button type="button" class="kc-rect-remove-btn" onclick="removeKCRectangle(${i})" title="${t('kc.rect.remove')}">&#x2715;</button>
</div>
`).join('');
container.innerHTML = labels + rows;
}
async function showKCEditor(targetId = null) {
try {
// Load sources for dropdown
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
// Populate source select (no empty option — source is required for KC targets)
const sourceSelect = document.getElementById('kc-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
const kcSettings = target.key_colors_settings || {};
document.getElementById('kc-editor-id').value = target.id;
document.getElementById('kc-editor-name').value = target.name;
sourceSelect.value = target.picture_source_id || '';
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-title').textContent = t('kc.edit');
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
} else {
// Creating new target
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
document.getElementById('kc-editor-fps').value = 10;
document.getElementById('kc-editor-fps-value').textContent = '10';
document.getElementById('kc-editor-interpolation').value = 'average';
document.getElementById('kc-editor-smoothing').value = 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
document.getElementById('kc-editor-title').textContent = t('kc.add');
kcEditorRectangles = [];
}
renderKCRectangles();
// Auto-name: reset flag and wire listeners
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
// Trigger auto-name after dropdowns are populated (create mode only)
if (!targetId) _autoGenerateKCName();
kcEditorInitialValues = {
name: document.getElementById('kc-editor-name').value,
source: sourceSelect.value,
fps: document.getElementById('kc-editor-fps').value,
interpolation: document.getElementById('kc-editor-interpolation').value,
smoothing: document.getElementById('kc-editor-smoothing').value,
rectangles: JSON.stringify(kcEditorRectangles),
};
const modal = document.getElementById('kc-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeKCEditorModal);
document.getElementById('kc-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open KC editor:', error);
showToast('Failed to open key colors editor', 'error');
}
}
function isKCEditorDirty() {
return (
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
);
}
async function closeKCEditorModal() {
if (isKCEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseKCEditorModal();
}
function forceCloseKCEditorModal() {
document.getElementById('kc-editor-modal').style.display = 'none';
document.getElementById('kc-editor-error').style.display = 'none';
unlockBody();
kcEditorInitialValues = {};
kcEditorRectangles = [];
}
async function saveKCEditor() {
const targetId = document.getElementById('kc-editor-id').value;
const name = document.getElementById('kc-editor-name').value.trim();
const sourceId = document.getElementById('kc-editor-source').value;
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
const interpolation = document.getElementById('kc-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
const errorEl = document.getElementById('kc-editor-error');
if (!name) {
errorEl.textContent = t('kc.error.required');
errorEl.style.display = 'block';
return;
}
if (kcEditorRectangles.length === 0) {
errorEl.textContent = t('kc.error.no_rectangles');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
picture_source_id: sourceId,
key_colors_settings: {
fps,
interpolation_mode: interpolation,
smoothing,
rectangles: kcEditorRectangles.map(r => ({
name: r.name,
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})),
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
forceCloseKCEditorModal();
await loadTargets();
} catch (error) {
console.error('Error saving KC target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('kc.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete key colors target', 'error');
}
}
// ===== KEY COLORS WEBSOCKET =====
const kcWebSockets = {};
function connectKCWebSocket(targetId) {
// Disconnect existing connection if any
disconnectKCWebSocket(targetId);
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateKCColorSwatches(targetId, data.colors || {});
} catch (e) {
console.error('Failed to parse KC WebSocket message:', e);
}
};
ws.onclose = () => {
delete kcWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`KC WebSocket error for ${targetId}:`, error);
};
kcWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
}
}
function disconnectKCWebSocket(targetId) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
function updateKCColorSwatches(targetId, colors) {
const container = document.getElementById(`kc-swatches-${targetId}`);
if (!container) return;
const entries = Object.entries(colors);
if (entries.length === 0) {
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
return;
}
container.innerHTML = entries.map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
}

View File

@@ -314,6 +314,90 @@
</div>
</div>
<!-- Key Colors Editor Modal -->
<div id="kc-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="kc-editor-form">
<input type="hidden" id="kc-editor-id">
<div class="form-group">
<label for="kc-editor-name" data-i18n="kc.name">Target Name:</label>
<input type="text" id="kc-editor-name" data-i18n-placeholder="kc.name.placeholder" placeholder="My Key Colors Target" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-source" data-i18n="kc.source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.source.hint">Which picture source to extract colors from</small>
<select id="kc-editor-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-fps" data-i18n="kc.fps">Extraction FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.fps.hint">How many times per second to extract colors (1-60)</small>
<div class="slider-row">
<input type="range" id="kc-editor-fps" min="1" max="60" value="10" oninput="document.getElementById('kc-editor-fps-value').textContent = this.value">
<span id="kc-editor-fps-value" class="slider-value">10</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-interpolation" data-i18n="kc.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.interpolation.hint">How to compute the key color from pixels in each rectangle</small>
<select id="kc-editor-interpolation">
<option value="average">Average</option>
<option value="median">Median</option>
<option value="dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-smoothing">
<span data-i18n="kc.smoothing">Smoothing:</span>
<span id="kc-editor-smoothing-value">0.3</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.smoothing.hint">Temporal blending between extractions (0=none, 1=full)</small>
<input type="range" id="kc-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('kc-editor-smoothing-value').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="kc.rectangles">Color Rectangles</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.rectangles.hint">Define named rectangles in relative coordinates (0.0-1.0) on the captured image</small>
<div id="kc-rect-list" class="kc-rect-list"></div>
<button type="button" class="btn btn-secondary kc-add-rect-btn" onclick="addKCRectangle()">
+ <span data-i18n="kc.rect.add">Add Rectangle</span>
</button>
</div>
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Actual FPS",
"targets.metrics.target_fps": "Target FPS",
"targets.metrics.frames": "Frames",
"targets.metrics.errors": "Errors"
"targets.metrics.errors": "Errors",
"targets.subtab.key_colors": "Key Colors",
"targets.section.key_colors": "🎨 Key Colors Targets",
"kc.add": "Add Key Colors Target",
"kc.edit": "Edit Key Colors Target",
"kc.name": "Target Name:",
"kc.name.placeholder": "My Key Colors Target",
"kc.source": "Picture Source:",
"kc.source.hint": "Which picture source to extract colors from",
"kc.source.none": "-- No source assigned --",
"kc.fps": "Extraction FPS:",
"kc.fps.hint": "How many times per second to extract colors (1-60)",
"kc.interpolation": "Color Mode:",
"kc.interpolation.hint": "How to compute the key color from pixels in each rectangle",
"kc.interpolation.average": "Average",
"kc.interpolation.median": "Median",
"kc.interpolation.dominant": "Dominant",
"kc.smoothing": "Smoothing:",
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
"kc.rectangles": "Color Rectangles",
"kc.rectangles.hint": "Define named rectangles in relative coordinates (0.01.0) on the captured image",
"kc.rect.name": "Name",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "W",
"kc.rect.height": "H",
"kc.rect.add": "Add Rectangle",
"kc.rect.remove": "Remove",
"kc.rect.empty": "No rectangles defined. Add at least one rectangle to extract colors.",
"kc.created": "Key colors target created successfully",
"kc.updated": "Key colors target updated successfully",
"kc.deleted": "Key colors target deleted successfully",
"kc.delete.confirm": "Are you sure you want to delete this key colors target?",
"kc.error.no_rectangles": "Please add at least one rectangle",
"kc.error.required": "Please fill in all required fields",
"kc.colors.none": "No colors extracted yet"
}

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Факт. FPS",
"targets.metrics.target_fps": "Целев. FPS",
"targets.metrics.frames": "Кадры",
"targets.metrics.errors": "Ошибки"
"targets.metrics.errors": "Ошибки",
"targets.subtab.key_colors": "Ключевые Цвета",
"targets.section.key_colors": "🎨 Цели Ключевых Цветов",
"kc.add": "Добавить Цель Ключевых Цветов",
"kc.edit": "Редактировать Цель Ключевых Цветов",
"kc.name": "Имя Цели:",
"kc.name.placeholder": "Моя Цель Ключевых Цветов",
"kc.source": "Источник:",
"kc.source.hint": "Из какого источника извлекать цвета",
"kc.source.none": "-- Источник не назначен --",
"kc.fps": "FPS Извлечения:",
"kc.fps.hint": "Сколько раз в секунду извлекать цвета (1-60)",
"kc.interpolation": "Режим Цвета:",
"kc.interpolation.hint": "Как вычислять ключевой цвет из пикселей в каждом прямоугольнике",
"kc.interpolation.average": "Среднее",
"kc.interpolation.median": "Медиана",
"kc.interpolation.dominant": "Доминантный",
"kc.smoothing": "Сглаживание:",
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
"kc.rectangles": "Цветовые Прямоугольники",
"kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.01.0) на захваченном изображении",
"kc.rect.name": "Имя",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "Ш",
"kc.rect.height": "В",
"kc.rect.add": "Добавить Прямоугольник",
"kc.rect.remove": "Удалить",
"kc.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один для извлечения цветов.",
"kc.created": "Цель ключевых цветов успешно создана",
"kc.updated": "Цель ключевых цветов успешно обновлена",
"kc.deleted": "Цель ключевых цветов успешно удалена",
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
"kc.error.no_rectangles": "Пожалуйста, добавьте хотя бы один прямоугольник",
"kc.error.required": "Пожалуйста, заполните все обязательные поля",
"kc.colors.none": "Цвета пока не извлечены"
}

View File

@@ -2583,3 +2583,126 @@ input:-webkit-autofill:focus {
font-size: 0.7rem;
}
/* Key Colors target styles */
.kc-rect-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.kc-rect-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.kc-rect-row input[type="text"] {
flex: 2;
min-width: 0;
}
.kc-rect-row input[type="number"] {
flex: 1;
min-width: 0;
width: 60px;
}
.kc-rect-row .kc-rect-remove-btn {
background: none;
border: none;
color: #777;
font-size: 1rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.2s, background 0.2s;
}
.kc-rect-row .kc-rect-remove-btn:hover {
color: var(--danger-color);
background: rgba(244, 67, 54, 0.1);
}
.kc-rect-labels {
display: flex;
gap: 6px;
padding: 0 8px;
margin-bottom: 4px;
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 600;
}
.kc-rect-labels span:first-child {
flex: 2;
}
.kc-rect-labels span {
flex: 1;
text-align: center;
}
.kc-rect-labels span:last-child {
width: 28px;
flex: 0 0 28px;
}
.kc-add-rect-btn {
width: 100%;
font-size: 0.85rem;
padding: 6px 12px;
}
.kc-rect-empty {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
padding: 12px;
font-style: italic;
}
.kc-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.kc-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.kc-swatch-color {
width: 32px;
height: 32px;
border-radius: 6px;
border: 2px solid var(--border-color);
transition: background-color 0.3s;
}
.kc-swatch-label {
font-size: 0.6rem;
color: var(--text-secondary);
max-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.kc-no-colors {
color: var(--text-secondary);
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}