Add stop-all buttons to target sections, perf chart color reset, and TODO

- Add stop-all buttons to LED targets and KC targets section headers
  (visible only when targets are running, uses headerExtra on CardSection)
- Add reset ability to performance chart color pickers (removes custom
  color from localStorage and reverts to default)
- Remove CODEBASE_REVIEW.md
- Add prioritized TODO.md with P1/P2/P3 feature roadmap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 01:46:58 +03:00
parent 90acae5207
commit 62b3d44e63
10 changed files with 125 additions and 63 deletions

View File

@@ -1,54 +0,0 @@
# Codebase Review — 2026-02-26
Findings from full codebase review. Items ordered by priority within each category.
## Stability (Critical)
- [x] **Fatal loop exception leaks resources** — Added outer `try/except/finally` with `self._running = False` to all 10 processing loop methods across `live_stream.py`, `color_strip_stream.py`, `effect_stream.py`, `audio_stream.py`, `composite_stream.py`, `mapped_stream.py`. Also added per-iteration `try/except` where missing.
- [x] **`_is_running` flag cleanup** — Fixed via `finally: self._running = False` in all loop methods. *(Race condition via `threading.Event` deferred — current pattern sufficient with the finally block.)*
- [x] **`ColorStripStreamManager` thread safety** — **FALSE POSITIVE**: All access is from the async event loop; methods are synchronous with no `await` points, so no preemption is possible.
- [x] **Audio `stream.stop()` called under lock** — Moved `stream.stop()` outside lock scope in both `release()` and `release_all()` in `audio_capture.py`.
- [x] **WS accept-before-validate****FALSE POSITIVE**: All WS endpoints validate auth and resolve configs BEFORE calling `websocket.accept()`.
- [x] **Capture error no-backoff** — Added consecutive error counter with exponential backoff (`min(1.0, 0.1 * (errors - 5))`) in `ScreenCaptureLiveStream._capture_loop()`.
- [ ] **WGC session close not detected** — Deferred (Windows-specific edge case, low priority).
- [x] **`LiveStreamManager.acquire()` not thread-safe** — **FALSE POSITIVE**: Same as ColorStripStreamManager — all access from async event loop, no await in methods.
## Performance (High Impact)
- [x] **Per-pixel Python loop in `send_pixels()`** — Replaced per-pixel Python loop with `np.array().tobytes()` in `ddp_client.py`. Hot path already uses `send_pixels_numpy()`.
- [ ] **WGC 6MB frame allocation per callback** — Deferred (Windows-specific, requires WGC API changes).
- [x] **Gradient rendering O(LEDs×Stops) Python loop** — Vectorized with NumPy: `np.searchsorted` for stop lookup + vectorized interpolation in `_compute_gradient_colors()`.
- [x] **`PixelateFilter` nested Python loop** — Replaced with `cv2.resize` down (INTER_AREA) + up (INTER_NEAREST) — pure C++ backend.
- [x] **`DownscalerFilter` double allocation** — **FALSE POSITIVE**: Already uses single `cv2.resize()` call (vectorized C++).
- [x] **`SaturationFilter` ~25MB temp arrays** — **FALSE POSITIVE**: Already uses pre-allocated scratch buffer and vectorized in-place numpy.
- [x] **`FrameInterpolationFilter` copies full image** — **FALSE POSITIVE**: Already uses vectorized numpy integer blending with image pool.
- [x] **`datetime.utcnow()` per frame** — **LOW IMPACT**: ~1-2μs per call, negligible at 60fps. Deprecation tracked under Backend Quality.
- [x] **Unbounded diagnostic lists****FALSE POSITIVE**: Lists are cleared every 5 seconds (~300 entries max at 60fps). Trivial memory.
## Frontend Quality
- [x] **`lockBody()`/`unlockBody()` not re-entrant** — Added `_lockCount` reference counter and `_savedScrollY` in `ui.js`. First lock saves scroll, last unlock restores.
- [x] **XSS via unescaped engine config keys****FALSE POSITIVE**: Both capture template and audio template card renderers already use `escapeHtml()` on keys and values.
- [x] **LED preview WS `onclose` not nulled** — Added `ws.onclose = null` before `ws.close()` in `disconnectLedPreviewWS()` in `targets.js`.
- [x] **`fetchWithAuth` retry adds duplicate listeners** — Added `{ once: true }` to abort signal listener in `api.js`.
- [x] **Audio `requestAnimationFrame` loop continues after WS close****FALSE POSITIVE**: Loop already checks `testAudioModal.isOpen` before scheduling next frame, and `_cleanupTest()` cancels the animation frame.
## Backend Quality
- [ ] **No thread-safety in `JsonStore`** — Deferred (low risk — all stores are accessed from async event loop).
- [x] **Auth token prefix logged** — Removed token prefix from log message in `auth.py`. Now logs only "Invalid API key attempt".
- [ ] **Duplicate capture/test code** — Deferred (code duplication, not a bug — refactoring would reduce LOC but doesn't fix a defect).
- [x] **Update methods allow duplicate names** — Added name uniqueness checks to `update_template` in `template_store.py`, `postprocessing_template_store.py`, `audio_template_store.py`, `pattern_template_store.py`, and `update_profile` in `profile_store.py`. Also added missing check to `create_profile`.
- [ ] **Routes access `manager._private` attrs** — Deferred (stylistic, not a bug — would require adding public accessor methods).
- [x] **Non-atomic file writes** — Created `utils/file_ops.py` with `atomic_write_json()` helper (tempfile + `os.replace`). Updated all 10 store files.
- [ ] **444 f-string logger calls** — Deferred (performance impact negligible — Python evaluates f-strings very fast; lazy `%s` formatting only matters at very high call rates).
- [x] **`get_source()` silent bug** — Fixed: `color_strip_sources.py:_resolve_display_index()` called `picture_source_store.get_source()` which doesn't exist (should be `get_stream()`). Was silently returning `0` for display index.
- [ ] **`get_config()` race** — Deferred (low risk — config changes are infrequent user-initiated operations).
- [ ] **`datetime.utcnow()` deprecated** — Deferred (functional, deprecation warning only appears in Python 3.12+).
- [x] **Inconsistent DELETE status codes** — Changed `audio_sources.py` and `value_sources.py` DELETE endpoints from 200 to 204 (matching all other DELETE endpoints).
## Architecture (Observations, no action needed)
**Strengths**: Clean layered design, plugin registries, reference-counted stream sharing, consistent API patterns.
**Weaknesses**: No backpressure (slow consumers buffer frames), thread count grows linearly, config global singleton, reference counting races.

38
TODO.md Normal file
View File

@@ -0,0 +1,38 @@
# Pending Features & Issues
Priority: `P1` quick win · `P2` moderate · `P3` large effort
## Processing Pipeline
- [ ] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
- [ ] `P1` **Color temperature filter** — Warm/cool shift separate from hue shift (circadian/mood)
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
- [ ] `P2` **Palette quantization** — Force output to match a user-defined palette
- [ ] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
## Output Targets
- [ ] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
- [ ] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
## Automation & Integration
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
- [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel
- [ ] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter)
## Multi-Display
- [ ] `P2` **Investigate multimonitor support** — Research and plan support for multi-monitor setups
- [ ] `P3` **Multi-display unification** — Treat 2-3 monitors as single virtual display for seamless ambilight
## Capture Engines
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
- [ ] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
## UX
- [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest

View File

@@ -682,10 +682,22 @@
flex-shrink: 0; flex-shrink: 0;
} }
.cs-collapsed .cs-filter-wrap { .cs-collapsed .cs-filter-wrap,
.cs-collapsed .cs-header-extra {
display: none; display: none;
} }
.cs-header-extra {
display: flex;
align-items: center;
}
.cs-header-extra .btn {
padding: 2px 8px;
font-size: 0.75rem;
line-height: 1;
}
.cs-filter-wrap { .cs-filter-wrap {
position: relative; position: relative;
margin-left: auto; margin-left: auto;

View File

@@ -97,6 +97,7 @@ import {
loadTargetsTab, switchTargetSubTab, loadTargetsTab, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
stopAllLedTargets, stopAllKCTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview, toggleTargetAutoStart, cloneTarget, toggleLedPreview, toggleTargetAutoStart,
expandAllTargetSections, collapseAllTargetSections, expandAllTargetSections, collapseAllTargetSections,
@@ -343,6 +344,8 @@ Object.assign(window, {
saveTargetEditor, saveTargetEditor,
startTargetProcessing, startTargetProcessing,
stopTargetProcessing, stopTargetProcessing,
stopAllLedTargets,
stopAllKCTargets,
startTargetOverlay, startTargetOverlay,
stopTargetOverlay, stopTargetOverlay,
deleteTarget, deleteTarget,

View File

@@ -43,13 +43,15 @@ export class CardSection {
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid' * @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid'
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card * @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id') * @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
*/ */
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr }) { constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra }) {
this.sectionKey = sectionKey; this.sectionKey = sectionKey;
this.titleKey = titleKey; this.titleKey = titleKey;
this.gridClass = gridClass; this.gridClass = gridClass;
this.addCardOnclick = addCardOnclick || ''; this.addCardOnclick = addCardOnclick || '';
this.keyAttr = keyAttr || ''; this.keyAttr = keyAttr || '';
this.headerExtra = headerExtra || '';
this._filterValue = ''; this._filterValue = '';
this._lastItems = null; this._lastItems = null;
this._dragState = null; this._dragState = null;
@@ -86,6 +88,7 @@ export class CardSection {
<span class="cs-chevron"${chevronStyle}>&#9654;</span> <span class="cs-chevron"${chevronStyle}>&#9654;</span>
<span class="cs-title">${t(this.titleKey)}</span> <span class="cs-title">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span> <span class="cs-count">${count}</span>
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
<div class="cs-filter-wrap"> <div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}" <input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
placeholder="${t('section.filter.placeholder')}" autocomplete="off"> placeholder="${t('section.filter.placeholder')}" autocomplete="off">
@@ -107,7 +110,7 @@ export class CardSection {
if (!header || !content) return; if (!header || !content) return;
header.addEventListener('mousedown', (e) => { header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-filter-wrap')) return; if (e.target.closest('.cs-filter-wrap') || e.target.closest('.cs-header-extra')) return;
this._toggleCollapse(header, content); this._toggleCollapse(header, content);
}); });

View File

@@ -23,7 +23,16 @@ function _getColor(key) {
} }
function _onChartColorChange(key, hex) { function _onChartColorChange(key, hex) {
localStorage.setItem(`perfChartColor_${key}`, hex); if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex);
} else {
// Reset: remove saved color, fall back to default
localStorage.removeItem(`perfChartColor_${key}`);
hex = _getColor(key);
// Update swatch to show the actual default color
const swatch = document.getElementById(`cp-swatch-perf-${key}`);
if (swatch) swatch.style.background = hex;
}
const chart = _charts[key]; const chart = _charts[key];
if (chart) { if (chart) {
chart.data.datasets[0].borderColor = hex; chart.data.datasets[0].borderColor = hex;
@@ -42,21 +51,21 @@ export function renderPerfSection() {
return `<div class="perf-charts-grid"> return `<div class="perf-charts-grid">
<div class="perf-chart-card"> <div class="perf-chart-card">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), anchor: 'left' })}</span> <span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span> <span class="perf-chart-value" id="perf-cpu-value">-</span>
</div> </div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div> <div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div> </div>
<div class="perf-chart-card"> <div class="perf-chart-card">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), anchor: 'left' })}</span> <span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span> <span class="perf-chart-value" id="perf-ram-value">-</span>
</div> </div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div> <div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div> </div>
<div class="perf-chart-card" id="perf-gpu-card"> <div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), anchor: 'left' })}</span> <span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span> <span class="perf-chart-value" id="perf-gpu-value">-</span>
</div> </div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div> <div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>

View File

@@ -33,8 +33,8 @@ import { updateSubTabHash, updateTabBadge } from './tabs.js';
// ── Card section instances ── // ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' }); const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' }); const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id' }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led">${ICON_STOP}</button>` });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id' }); const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc">${ICON_STOP}</button>` });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' }); const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
// Re-render targets tab when language changes (only if tab is active) // Re-render targets tab when language changes (only if tab is active)
@@ -621,6 +621,14 @@ export async function loadTargetsTab() {
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]); CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
} }
// Show/hide stop-all buttons based on running state
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
const ledStopBtn = container.querySelector('[data-stop-all="led"]');
const kcStopBtn = container.querySelector('[data-stop-all="kc"]');
if (ledStopBtn) ledStopBtn.style.display = ledRunning ? '' : 'none';
if (kcStopBtn) kcStopBtn.style.display = kcRunning ? '' : 'none';
// Patch volatile metrics in-place (avoids full card replacement on polls) // Patch volatile metrics in-place (avoids full card replacement on polls)
for (const tgt of ledTargets) { for (const tgt of ledTargets) {
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt); if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
@@ -983,6 +991,40 @@ export async function stopTargetProcessing(targetId) {
}); });
} }
export async function stopAllLedTargets() {
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
await _stopAllByType('key_colors');
}
async function _stopAllByType(targetType) {
try {
const [targetsResp, statesResp] = await Promise.all([
fetchWithAuth('/picture-targets'),
fetchWithAuth('/picture-targets/batch/states'),
]);
const data = await targetsResp.json();
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
const states = statesData.states || {};
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
const running = (data.targets || []).filter(t => typeMatch(t) && states[t.id]?.processing);
if (!running.length) {
showToast(t('targets.stop_all.none_running'), 'info');
return;
}
await Promise.all(running.map(t =>
fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
));
showToast(t('targets.stop_all.stopped', { count: running.length }), 'success');
loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
showToast(t('targets.stop_all.error'), 'error');
}
}
export async function startTargetOverlay(targetId) { export async function startTargetOverlay(targetId) {
await _targetAction(async () => { await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, {

View File

@@ -1093,6 +1093,9 @@
"target.error.clone_failed": "Failed to clone target", "target.error.clone_failed": "Failed to clone target",
"target.error.autostart_toggle_failed": "Failed to toggle auto-start", "target.error.autostart_toggle_failed": "Failed to toggle auto-start",
"target.error.delete_failed": "Failed to delete target", "target.error.delete_failed": "Failed to delete target",
"targets.stop_all.none_running": "No targets are currently running",
"targets.stop_all.stopped": "Stopped {count} target(s)",
"targets.stop_all.error": "Failed to stop targets",
"audio_source.error.load": "Failed to load audio source", "audio_source.error.load": "Failed to load audio source",
"audio_template.error.clone_failed": "Failed to clone audio template", "audio_template.error.clone_failed": "Failed to clone audio template",
"value_source.error.load": "Failed to load value source", "value_source.error.load": "Failed to load value source",

View File

@@ -1093,6 +1093,9 @@
"target.error.clone_failed": "Не удалось клонировать цель", "target.error.clone_failed": "Не удалось клонировать цель",
"target.error.autostart_toggle_failed": "Не удалось переключить автозапуск", "target.error.autostart_toggle_failed": "Не удалось переключить автозапуск",
"target.error.delete_failed": "Не удалось удалить цель", "target.error.delete_failed": "Не удалось удалить цель",
"targets.stop_all.none_running": "Нет запущенных целей",
"targets.stop_all.stopped": "Остановлено целей: {count}",
"targets.stop_all.error": "Не удалось остановить цели",
"audio_source.error.load": "Не удалось загрузить аудиоисточник", "audio_source.error.load": "Не удалось загрузить аудиоисточник",
"audio_template.error.clone_failed": "Не удалось клонировать аудиошаблон", "audio_template.error.clone_failed": "Не удалось клонировать аудиошаблон",
"value_source.error.load": "Не удалось загрузить источник значений", "value_source.error.load": "Не удалось загрузить источник значений",

View File

@@ -1093,6 +1093,9 @@
"target.error.clone_failed": "克隆目标失败", "target.error.clone_failed": "克隆目标失败",
"target.error.autostart_toggle_failed": "切换自动启动失败", "target.error.autostart_toggle_failed": "切换自动启动失败",
"target.error.delete_failed": "删除目标失败", "target.error.delete_failed": "删除目标失败",
"targets.stop_all.none_running": "当前没有运行中的目标",
"targets.stop_all.stopped": "已停止 {count} 个目标",
"targets.stop_all.error": "停止目标失败",
"audio_source.error.load": "加载音频源失败", "audio_source.error.load": "加载音频源失败",
"audio_template.error.clone_failed": "克隆音频模板失败", "audio_template.error.clone_failed": "克隆音频模板失败",
"value_source.error.load": "加载数值源失败", "value_source.error.load": "加载数值源失败",