diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 9492744..ceb6e6a 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -274,6 +274,71 @@ display: block; } +/* Key Colors test view โ€” frame with region overlays */ +.css-test-kc-wrap { + position: relative; + width: 100%; + border-radius: 4px; + overflow: hidden; + background: #000; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + +.css-test-kc-canvas { + display: block; + width: 100%; + height: auto; + max-height: 70vh; + object-fit: contain; +} + +.css-test-kc-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px 12px; + padding: 8px 0 2px; + font-size: 0.8rem; + color: var(--text-color-muted, #aaa); +} + +.css-test-kc-swatch { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 6px 2px 2px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-color, #333); + border-radius: 999px; + font-size: 0.78rem; + color: var(--text-color, #e0e0e0); +} + +.css-test-kc-swatch-chip { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); +} + +.css-test-kc-swatch-hex { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + opacity: 0.65; +} + +.css-test-kc-mode { + opacity: 0.7; + font-variant: small-caps; + letter-spacing: 0.04em; +} + .css-test-status { text-align: center; padding: 8px 0; @@ -1246,58 +1311,6 @@ background: rgba(255, 255, 255, 0.3); } -.lightbox-refresh-btn { - position: absolute; - top: 16px; - right: 64px; - background: rgba(255, 255, 255, 0.15); - border: none; - color: white; - font-size: 1.2rem; - width: 40px; - height: 40px; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.2s; - z-index: 1; -} - -.lightbox-refresh-btn:hover { - background: rgba(255, 255, 255, 0.3); -} - -.lightbox-refresh-btn.active { - background: var(--primary-color); -} - -.lightbox-fps-select { - position: absolute; - top: 16px; - right: 116px; - background: rgba(0, 0, 0, 0.65); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 6px; - padding: 4px 6px; - font-size: 0.8rem; - cursor: pointer; - z-index: 1; - appearance: none; - -webkit-appearance: none; - text-align: center; -} - -.lightbox-fps-select:hover { - background: rgba(255, 255, 255, 0.15); -} - -.lightbox-fps-select:focus { - outline: 1px solid var(--primary-color); -} - .lightbox-stats { position: absolute; bottom: 8px; diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index f92dd60..e31c193 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -22,7 +22,7 @@ import { toggleHint, lockBody, unlockBody, closeLightbox, showToast, showUndoToast, showConfirm, closeConfirmModal, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, - setFieldError, clearFieldError, setupBlurValidation, initLightbox, + setFieldError, clearFieldError, setupBlurValidation, } from './core/ui.ts'; // Layer 3: displays, tutorials @@ -754,9 +754,6 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize command palette initCommandPalette(); - // Enhance lightbox FPS with an IconSelect. Idempotent. */ -export function initLightbox(): void { - if (_lightboxFpsIconSelect) return; - const sel = document.getElementById('lightbox-fps-select') as HTMLSelectElement | null; - if (!sel) return; - _lightboxFpsIconSelect = new IconSelect({ - target: sel, - items: [ - { value: '1', icon: '1', label: '1 fps' }, - { value: '2', icon: '2', label: '2 fps' }, - { value: '3', icon: '3', label: '3 fps' }, - { value: '5', icon: '5', label: '5 fps' }, - ], - columns: 2, - onChange: (val: string) => { - const fn = (window as any).onLightboxFpsChange; - if (typeof fn === 'function') fn(val); - }, - }); -} /** Returns true on touch devices where auto-focus would pop up the virtual keyboard */ export function isTouchDevice() { diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts index f23b79a..c8184ea 100644 --- a/server/src/ledgrab/static/js/features/color-strips/test.ts +++ b/server/src/ledgrab/static/js/features/color-strips/test.ts @@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../../core/api.ts'; import { logError } from '../../core/log.ts'; import { colorStripSourcesCache } from '../../core/state.ts'; import { t } from '../../core/i18n.ts'; -import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts'; +import { showToast } from '../../core/ui.ts'; import { createFpsSparkline } from '../../core/chart-utils.ts'; import { getColorStripIcon, @@ -97,6 +97,10 @@ let _cssTestTransientConfig: any = null; const _CSS_TEST_LED_KEY = 'css_test_led_count'; const _CSS_TEST_FPS_KEY = 'css_test_fps'; +const _CSS_TEST_KC_FPS_KEY = 'css_test_kc_fps'; +const _CSS_TEST_KC_FPS_DEFAULT = 5; +const _CSS_TEST_KC_FPS_MIN = 1; +const _CSS_TEST_KC_FPS_MAX = 30; let _cssTestWs: WebSocket | null = null; let _cssTestRaf: number | null = null; let _cssTestLatestRgb: Uint8Array | null = null; @@ -109,6 +113,7 @@ let _cssTestNotificationIds: string[] = []; // notification source IDs to fire ( let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode let _cssTestIsApiInput: boolean = false; +let _cssTestIsKeyColors: boolean = false; let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline let _cssTestFpsChart: any = null; @@ -125,6 +130,11 @@ function _getCssTestFps() { return (stored >= 1 && stored <= 60) ? stored : 20; } +function _getKCTestFps() { + const stored = parseInt(localStorage.getItem(_CSS_TEST_KC_FPS_KEY) ?? '', 10); + return (stored >= _CSS_TEST_KC_FPS_MIN && stored <= _CSS_TEST_KC_FPS_MAX) ? stored : _CSS_TEST_KC_FPS_DEFAULT; +} + function _populateCssTestSourceSelector(preselectId: any) { const sources = (colorStripSourcesCache.data || []) as any[]; const nonProcessed = sources.filter(s => s.source_type !== 'processed'); @@ -162,81 +172,139 @@ export function testColorStrip(sourceId: string) { } let _kcTestWs: WebSocket | null = null; -const _kcTestCanvas = document.createElement('canvas'); const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff']; function _testKeyColorsSource(sourceId: string) { - // Show lightbox with spinner - const lightbox = document.getElementById('image-lightbox')!; - const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; - const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null; - if (content) content.style.width = '90vw'; // Fill viewport for KC preview - const img = document.getElementById('lightbox-image') as HTMLImageElement; - img.src = ''; - img.style.display = 'none'; // Hide until first frame arrives - if (spinner) spinner.style.display = ''; - document.getElementById('lightbox-stats')!.style.display = 'none'; - lightbox.classList.add('active'); + _cssTestCSPTMode = false; + _cssTestCSPTId = null; + _cssTestIsApiInput = false; + _cssTestIsKeyColors = true; + _cssTestSourceId = sourceId; - // Close any previous WS + // Close any previous sessions + if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } + if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } + _cssTestLatestRgb = null; + _cssTestMeta = null; + _cssTestLayerData = null; + + const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; + if (!modal) return; + modal.style.display = 'flex'; + modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); }; + + // Show only the KC view; hide all others + (document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none'; + (document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none'; + (document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none'; + (document.getElementById('css-test-kc-view') as HTMLElement).style.display = ''; + (document.getElementById('css-test-fps-chart-group') as HTMLElement).style.display = 'none'; + + // CSPT input selector is not relevant for KC + const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null; + if (csptGroup) csptGroup.style.display = 'none'; + + // LED count doesn't apply to KC โ€” hide LED group; keep FPS input visible + (document.getElementById('css-test-led-fps-group') as HTMLElement).style.display = ''; + (document.getElementById('css-test-led-group') as HTMLElement).style.display = 'none'; + + const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null; + if (fpsInput) { + fpsInput.min = String(_CSS_TEST_KC_FPS_MIN); + fpsInput.max = String(_CSS_TEST_KC_FPS_MAX); + fpsInput.value = String(_getKCTestFps()); + fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; + } + + // Widen modal to give the frame room to breathe + const modalContent = modal.querySelector('.modal-content') as HTMLElement | null; + if (modalContent) modalContent.style.maxWidth = '900px'; + + // Clear any stale KC state + const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + } + const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null; + if (metaEl) metaEl.innerHTML = ''; + + const statusEl = document.getElementById('css-test-status') as HTMLElement; + statusEl.textContent = t('color_strip.test.connecting'); + statusEl.style.display = ''; + + _kcTestConnect(sourceId, _getKCTestFps()); +} + +function _kcTestConnect(sourceId: string, fps: number) { if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } - // Build WS URL + const gen = ++_cssTestGeneration; const loc = window.location; const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=5&preview_width=960`; + const clamped = Math.max(_CSS_TEST_KC_FPS_MIN, Math.min(_CSS_TEST_KC_FPS_MAX, fps)); + const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=${clamped}&preview_width=960`; openAuthedWs(wsUrl).then((ws) => { + if (gen !== _cssTestGeneration) { ws.close(); return; } _kcTestWs = ws; ws.onmessage = (ev) => { + if (gen !== _cssTestGeneration) return; try { const data = JSON.parse(ev.data); if (data.type === 'frame') { _renderKCTestFrame(data); + const statusEl = document.getElementById('css-test-status') as HTMLElement | null; + if (statusEl) statusEl.style.display = 'none'; } } catch (err) { logError('color-strips.test.kcWs.message', err); } }; ws.onerror = () => { - showToast('Key Colors test connection failed', 'error'); - closeLightbox(); + if (gen !== _cssTestGeneration) return; + const statusEl = document.getElementById('css-test-status') as HTMLElement; + statusEl.textContent = t('color_strip.test.error'); + statusEl.style.display = ''; }; - ws.onclose = () => { + ws.onclose = (ev) => { + if (gen !== _cssTestGeneration) return; _kcTestWs = null; - }; - - // Stop WS when lightbox closes - const origClose = (window as any).closeLightbox; - lightbox.onclick = (e) => { - if ((e.target as HTMLElement).closest('.lightbox-content')) return; - if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } - closeLightbox(); + if (ev.reason) { + const statusEl = document.getElementById('css-test-status') as HTMLElement; + statusEl.textContent = ev.reason; + statusEl.style.display = ''; + } }; }).catch(() => { - showToast('Key Colors test connection failed', 'error'); - closeLightbox(); + if (gen !== _cssTestGeneration) return; + const statusEl = document.getElementById('css-test-status') as HTMLElement; + statusEl.textContent = t('color_strip.test.error'); + statusEl.style.display = ''; }); } function _renderKCTestFrame(data: any) { const rects = data.rectangles || []; const mode = data.interpolation_mode || 'average'; + const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null; + const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null; + if (!canvas) return; - // Draw frame + rectangles onto offscreen canvas const tmpImg = new Image(); tmpImg.onload = () => { - _kcTestCanvas.width = tmpImg.naturalWidth; - _kcTestCanvas.height = tmpImg.naturalHeight; - const ctx = _kcTestCanvas.getContext('2d')!; + canvas.width = tmpImg.naturalWidth; + canvas.height = tmpImg.naturalHeight; + const ctx = canvas.getContext('2d')!; ctx.drawImage(tmpImg, 0, 0); rects.forEach((r: any, i: number) => { - const x = r.x * _kcTestCanvas.width; - const y = r.y * _kcTestCanvas.height; - const w = r.width * _kcTestCanvas.width; - const h = r.height * _kcTestCanvas.height; + const x = r.x * canvas.width; + const y = r.y * canvas.height; + const w = r.width * canvas.width; + const h = r.height * canvas.height; const borderColor = BORDER_COLORS[i % BORDER_COLORS.length]; ctx.fillStyle = r.color.hex + '33'; @@ -258,36 +326,19 @@ function _renderKCTestFrame(data: any) { ctx.lineWidth = 1; ctx.strokeRect(x + w - 24, y + 2, 22, 22); }); - - // Update lightbox image directly (use data URL for full-size display) - const lbImg = document.getElementById('lightbox-image') as HTMLImageElement; - if (lbImg) { - lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9); - lbImg.style.display = ''; - lbImg.style.maxWidth = '100%'; - lbImg.style.width = '100%'; - } - - // Hide spinner after first frame - const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null; - if (spinner) spinner.style.display = 'none'; - - // Update swatches - const statsEl = document.getElementById('lightbox-stats')!; - const swatches = rects.map((r: any) => - `
- - ${escapeHtml(r.name)} - ${r.color.hex} -
` - ).join(''); - statsEl.innerHTML = ` -
${swatches}
-
Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}
- `; - statsEl.style.display = ''; }; tmpImg.src = data.image; + + if (metaEl) { + const swatches = rects.map((r: any) => + ` + + ${escapeHtml(r.name)} + ${r.color.hex} + ` + ).join(''); + metaEl.innerHTML = `${swatches}${mode} ยท ${rects.length} region${rects.length !== 1 ? 's' : ''}`; + } } export async function testCSPT(templateId: string) { @@ -310,10 +361,12 @@ export async function testCSPT(templateId: string) { function _openTestModal(sourceId: string) { // Clean up any previous session fully if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestIsComposite = false; + _cssTestIsKeyColors = false; _cssTestLayerData = null; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; @@ -326,6 +379,8 @@ function _openTestModal(sourceId: string) { (document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none'; (document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none'; (document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none'; + const kcView = document.getElementById('css-test-kc-view') as HTMLElement | null; + if (kcView) kcView.style.display = 'none'; // Clear all test canvases to prevent stale frames from previous sessions modal.querySelectorAll('canvas').forEach(c => { const ctx = c.getContext('2d'); @@ -363,8 +418,12 @@ function _openTestModal(sourceId: string) { const fpsVal = _getCssTestFps(); const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null; - fpsInput!.value = fpsVal as any; - fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; + if (fpsInput) { + fpsInput.min = '1'; + fpsInput.max = '60'; + fpsInput.value = String(fpsVal); + fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; + } _cssTestConnect(sourceId, ledCount, fpsVal); } @@ -621,6 +680,18 @@ function _cssTestUpdateBrightness(values: any) { export function applyCssTestSettings() { if (!_cssTestSourceId) return; + // Key Colors test: FPS only โ€” different range and storage key + if (_cssTestIsKeyColors) { + const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null; + let fps = parseInt(fpsInput?.value ?? '', 10); + if (isNaN(fps) || fps < _CSS_TEST_KC_FPS_MIN) fps = _CSS_TEST_KC_FPS_MIN; + if (fps > _CSS_TEST_KC_FPS_MAX) fps = _CSS_TEST_KC_FPS_MAX; + if (fpsInput) fpsInput.value = String(fps); + localStorage.setItem(_CSS_TEST_KC_FPS_KEY, String(fps)); + _kcTestConnect(_cssTestSourceId, fps); + return; + } + const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null; let leds = parseInt(ledInput?.value ?? '', 10); if (isNaN(leds) || leds < 1) leds = 1; @@ -1060,11 +1131,13 @@ function _cssTestStopFpsSampling() { export function closeTestCssSourceModal() { if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestSourceId = null; _cssTestIsComposite = false; + _cssTestIsKeyColors = false; _cssTestLayerData = null; _cssTestNotificationIds = []; _cssTestIsApiInput = false; diff --git a/server/src/ledgrab/templates/modals/test-css-source.html b/server/src/ledgrab/templates/modals/test-css-source.html index 7363260..de6c9d9 100644 --- a/server/src/ledgrab/templates/modals/test-css-source.html +++ b/server/src/ledgrab/templates/modals/test-css-source.html @@ -44,6 +44,14 @@ + + +