From 2c0ee8c9e8fe7979bf3ec11af6dd6911d49954ca Mon Sep 17 00:00:00 2001 From: gx Date: Tue, 26 May 2026 17:06:36 +0100 Subject: [PATCH] controller UI: visual overlay editor + snapshot preview fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поверх preview (image от /snapshot endpoint) добавлен editor layer с drag-and-drop созданием / перемещением / resize / удалением overlay'ев через HTTP API (POST/PATCH/DELETE /overlay/{instance}[/{id}]). Toolbar: draw mode toggle, type (rect-border / rect-fill / dim / text), color picker, opacity slider. Drag по preview → создание нового overlay с правильными нормализованными координатами. Клик на overlay → выбор + drag перемещение, нижний-правый handle = resize, красный × delete. Cell-bound overlays (Frigate motion borders, dynamic_overlays grafana panel) не отрисовываются в editor — они уже физически в видео output и заполняли весь editor area, делая cursor:not-allowed везде. Только absolute (cell=null) overlays редактируемы. Preview: HLS отключён. mediamtx в LL-HLS режиме отдаёт пустые audio fragments (passthrough-remuxer: "No samples for initPTS at playlist time", "Duration parsed from mp4 should be greater than zero"). hls.js застревает с readyState=1, buffered=0. lowLatencyMode:false на клиенте не помог — mediamtx сам делит на video1_stream.m3u8 + audio2_stream.m3u8 на server side. Diagnosed через Playwright + hls.js debug logs. Заменено на с polling /snapshot/{instance} (~700ms request, де-факто ~5s из-за времени ffmpeg RTSP+PNG, но достаточно как reference для overlay positioning). Co-Authored-By: Claude Opus 4.7 --- .../cuda_grid_controller/static/index.html | 326 +++++++++++++++++- 1 file changed, 313 insertions(+), 13 deletions(-) diff --git a/controller/cuda_grid_controller/static/index.html b/controller/cuda_grid_controller/static/index.html index a719c4c..e886c7e 100644 --- a/controller/cuda_grid_controller/static/index.html +++ b/controller/cuda_grid_controller/static/index.html @@ -35,6 +35,41 @@ label { font-size:11px; color:var(--muted); display:block; margin-bottom:2px; } pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overflow-x:auto; max-height:180px; margin:0; } .kv { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; font-size:12px; } .kv .k { color:var(--muted); } +/* ── Visual overlay editor ─────────────────────────── */ +.editor-layer { + position:absolute; pointer-events:auto; user-select:none; + cursor:crosshair; z-index:15; +} +.editor-layer.dragging { cursor:grabbing; } +.editor-layer.disabled { display:none; } +.ov-box { + position:absolute; box-sizing:border-box; + border:2px solid var(--accent); background:rgba(68,170,255,0.15); + font-size:11px; color:#fff; text-shadow:0 0 3px #000; + pointer-events:auto; cursor:grab; + min-width:8px; min-height:8px; +} +.ov-box.cell-bound { border-style:dashed; border-color:#888; background:rgba(128,128,128,0.08); cursor:not-allowed; } +.ov-box.selected { border-color:var(--warn); background:rgba(255,170,68,0.18); z-index:10; } +.ov-box .ov-label { position:absolute; top:-18px; left:0; padding:1px 4px; background:rgba(0,0,0,0.7); border-radius:2px; white-space:nowrap; } +.ov-box .ov-del { + position:absolute; top:-10px; right:-10px; width:20px; height:20px; + background:var(--err); color:#fff; border:1px solid #000; border-radius:50%; + font-size:14px; line-height:18px; text-align:center; cursor:pointer; + pointer-events:auto; +} +.ov-box .ov-handle { + position:absolute; right:-5px; bottom:-5px; width:12px; height:12px; + background:var(--accent); border:1px solid #000; cursor:nwse-resize; +} +.ov-draft { + position:absolute; border:2px dashed var(--good); background:rgba(68,255,136,0.15); + pointer-events:none; box-sizing:border-box; +} +.video-pane { position:relative; } +.editor-toolbar { position:absolute; top:8px; left:8px; z-index:20; background:rgba(0,0,0,0.7); border-radius:4px; padding:6px 10px; font-size:12px; display:flex; gap:8px; align-items:center; } +.editor-toolbar label { margin:0; color:#fff; display:flex; gap:4px; align-items:center; } +.editor-toolbar select, .editor-toolbar input { width:auto; padding:2px 6px; font-size:11px; } @@ -45,7 +80,24 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
- + + live preview +
snapshot —
+
+ + + + + drag на видео → создать; клик на overlay → выбрать +
+
@@ -132,20 +184,45 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl const INSTANCE = 'tv_grid'; const HLS_URL = `http://${location.hostname}:8888/live/index.m3u8`; -// ── Video ───────────────────────────────────────────── +// ── Video preview — snapshot-loop (~1.5 fps) ─────────── +// HLS отключен: mediamtx LL-HLS отдаёт пустые audio fragments +// (passthrough-remuxer: "No samples for initPTS, Duration=0") → playback stuck. +// Для overlay editor использовать /snapshot/{instance} polling — latency +// ~700 ms но достаточно для drag/resize reference. function initVideo() { - const v = document.getElementById('video'); - if (Hls.isSupported()) { - const hls = new Hls({ liveDurationInfinity:true, lowLatencyMode:true, maxBufferLength:6 }); - hls.loadSource(HLS_URL); - hls.attachMedia(v); - hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(()=>{})); - hls.on(Hls.Events.ERROR, (_,d) => { if (d.fatal) setStatus('HLS error: '+d.type, true); }); - } else if (v.canPlayType('application/vnd.apple.mpegurl')) { - v.src = HLS_URL; v.play().catch(()=>{}); - } else { - setStatus('HLS не supported in this browser', true); + const img = document.getElementById('snap-preview'); + const stat = document.getElementById('snap-stats'); + let busy = false, count = 0, lastT = Date.now(), nativeW = 0, nativeH = 0; + async function tick() { + if (busy) return; + busy = true; + try { + const t0 = performance.now(); + const r = await fetch(`/snapshot/${INSTANCE}`, {method:'POST', cache:'no-store'}); + if (!r.ok) { setStatus('snapshot fail '+r.status, true); busy=false; return; } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const prev = img.src; + img.onload = () => { + nativeW = img.naturalWidth; nativeH = img.naturalHeight; + syncEditorBounds(); + if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev); + }; + img.src = url; + count++; + const dt = performance.now() - t0; + if (Date.now() - lastT > 2000) { + stat.textContent = `snapshot ${count} · ${dt.toFixed(0)}ms`; + lastT = Date.now(); + } + } catch (e) { setStatus('snap '+e.message, true); } + busy = false; } + tick(); + setInterval(tick, 700); + // expose для syncEditorBounds + window.__previewEl = img; + window.__previewSize = () => ({w: nativeW, h: nativeH}); } // ── API helpers ─────────────────────────────────────── @@ -326,6 +403,228 @@ async function refreshState() { } catch (e) { setStatus('refresh fail: '+e.message, true); } } +// ── Visual editor: drag/draw/delete overlays поверх video ─ +const editor = document.getElementById('editor'); +const videoEl = document.getElementById('video'); +let currentOverlays = []; // последний snapshot от /overlay/{INSTANCE} +let selectedId = null; +let draftEl = null; // div для рисуемого rect +let dragState = null; // {mode:'draw|move|resize', id, startX, startY, orig} + +// Размещаем editor layer ровно поверх видимой area preview element'a (с учётом +// object-fit:contain — есть letterbox bars сверху/снизу или по бокам). +// Preview source — snapshot image (HLS disabled из-за mediamtx LL-HLS bug). +function syncEditorBounds() { + const el = window.__previewEl || videoEl; + const sz = window.__previewSize ? window.__previewSize() : {w: videoEl.videoWidth, h: videoEl.videoHeight}; + const r = el.getBoundingClientRect(); + const pane = el.parentElement.getBoundingClientRect(); + const vw = sz.w || 1920, vh = sz.h || 1080; + const containerRatio = r.width / r.height; + const videoRatio = vw / vh; + let w, h; + if (containerRatio > videoRatio) { // letterbox по бокам + h = r.height; w = h * videoRatio; + } else { // letterbox сверху/снизу + w = r.width; h = w / videoRatio; + } + const left = (r.left - pane.left) + (r.width - w) / 2; + const top = (r.top - pane.top) + (r.height - h) / 2; + editor.style.left = left + 'px'; + editor.style.top = top + 'px'; + editor.style.width = w + 'px'; + editor.style.height = h + 'px'; +} +window.addEventListener('resize', syncEditorBounds); +videoEl.addEventListener('loadedmetadata', syncEditorBounds); +setInterval(syncEditorBounds, 1000); // защита от async layout перестроек + +function renderOverlays() { + const bounds = editor.getBoundingClientRect(); + if (bounds.width < 10) return; // editor ещё не layout'нулся + // remove существующие .ov-box (но не draft) + editor.querySelectorAll('.ov-box').forEach(e => e.remove()); + currentOverlays.forEach(ov => { + const hasGeo = ov.x !== undefined && ov.y !== undefined; + if (!hasGeo) return; + // Cell-bound overlays (Frigate borders, motion indicators, cell-anchored icons) + // уже физически нарисованы фильтром на видео и не редактируются из UI — + // не отрисовываем дубликат в HTML (иначе они покрывают всю площадь cursor=not-allowed). + if (ov.cell !== null && ov.cell !== undefined) return; + const w = ov.w ?? 0.05, h = ov.h ?? 0.05; + const box = document.createElement('div'); + box.className = 'ov-box' + (ov.id === selectedId ? ' selected' : ''); + box.dataset.id = ov.id; + box.style.left = (ov.x * 100) + '%'; + box.style.top = (ov.y * 100) + '%'; + box.style.width = (w * 100) + '%'; + box.style.height = (h * 100) + '%'; + if (ov.color) box.style.borderColor = ov.color; + const lab = document.createElement('div'); + lab.className = 'ov-label'; lab.textContent = `${ov.type}: ${ov.id}`; + box.appendChild(lab); + const del = document.createElement('div'); + del.className = 'ov-del'; del.textContent = '×'; + del.title = 'Удалить overlay'; + del.addEventListener('mousedown', (e) => e.stopPropagation()); + del.addEventListener('click', (e) => { e.stopPropagation(); deleteOverlay(ov.id); }); + box.appendChild(del); + const handle = document.createElement('div'); + handle.className = 'ov-handle'; + handle.addEventListener('mousedown', (e) => startResize(e, ov)); + box.appendChild(handle); + box.addEventListener('mousedown', (e) => startMove(e, ov)); + editor.appendChild(box); + }); +} + +function fmtCoord(v) { return Math.max(0, Math.min(1, v)).toFixed(4); } +function pickColor(ov) { return document.getElementById('edit-color').value; } + +function startDraw(e) { + if (e.target !== editor) return; // если клик на существующий — другой handler + if (!document.getElementById('edit-enable').checked) return; + const bounds = editor.getBoundingClientRect(); + dragState = { + mode: 'draw', + startX: e.clientX - bounds.left, + startY: e.clientY - bounds.top, + }; + draftEl = document.createElement('div'); + draftEl.className = 'ov-draft'; + draftEl.style.left = dragState.startX + 'px'; + draftEl.style.top = dragState.startY + 'px'; + draftEl.style.width = '0'; draftEl.style.height = '0'; + editor.appendChild(draftEl); + editor.classList.add('dragging'); + e.preventDefault(); +} +editor.addEventListener('mousedown', startDraw); + +function startMove(e, ov) { + e.stopPropagation(); + selectedId = ov.id; renderOverlays(); + const bounds = editor.getBoundingClientRect(); + dragState = { + mode: 'move', id: ov.id, + startX: e.clientX, startY: e.clientY, + origX: ov.x, origY: ov.y, orig: ov, + }; + editor.classList.add('dragging'); + e.preventDefault(); +} +function startResize(e, ov) { + e.stopPropagation(); + selectedId = ov.id; renderOverlays(); + const bounds = editor.getBoundingClientRect(); + dragState = { + mode: 'resize', id: ov.id, + startX: e.clientX, startY: e.clientY, + origW: ov.w ?? 0.05, origH: ov.h ?? 0.05, orig: ov, + }; + editor.classList.add('dragging'); + e.preventDefault(); +} + +document.addEventListener('mousemove', (e) => { + if (!dragState) return; + const bounds = editor.getBoundingClientRect(); + if (dragState.mode === 'draw') { + const cx = e.clientX - bounds.left, cy = e.clientY - bounds.top; + const x = Math.min(cx, dragState.startX); + const y = Math.min(cy, dragState.startY); + draftEl.style.left = x + 'px'; + draftEl.style.top = y + 'px'; + draftEl.style.width = Math.abs(cx - dragState.startX) + 'px'; + draftEl.style.height = Math.abs(cy - dragState.startY) + 'px'; + } else if (dragState.mode === 'move') { + const dx = (e.clientX - dragState.startX) / bounds.width; + const dy = (e.clientY - dragState.startY) / bounds.height; + const ov = dragState.orig; + ov.x = Math.max(0, Math.min(1 - (ov.w ?? 0.05), dragState.origX + dx)); + ov.y = Math.max(0, Math.min(1 - (ov.h ?? 0.05), dragState.origY + dy)); + renderOverlays(); + } else if (dragState.mode === 'resize') { + const dw = (e.clientX - dragState.startX) / bounds.width; + const dh = (e.clientY - dragState.startY) / bounds.height; + const ov = dragState.orig; + ov.w = Math.max(0.02, Math.min(1 - ov.x, dragState.origW + dw)); + ov.h = Math.max(0.02, Math.min(1 - ov.y, dragState.origH + dh)); + renderOverlays(); + } +}); + +document.addEventListener('mouseup', async (e) => { + if (!dragState) return; + editor.classList.remove('dragging'); + const st = dragState; dragState = null; + if (st.mode === 'draw' && draftEl) { + const bounds = editor.getBoundingClientRect(); + const cx = e.clientX - bounds.left, cy = e.clientY - bounds.top; + const x0 = Math.min(cx, st.startX), y0 = Math.min(cy, st.startY); + const w = Math.abs(cx - st.startX), h = Math.abs(cy - st.startY); + draftEl.remove(); draftEl = null; + if (w < 8 || h < 8) return; // click без drag + const tSel = document.getElementById('edit-type').value; + const opacity = +document.getElementById('edit-opacity').value; + const color = document.getElementById('edit-color').value; + const body = { + id: rndId(), + x: +fmtCoord(x0 / bounds.width), + y: +fmtCoord(y0 / bounds.height), + opacity: opacity, + }; + if (tSel === 'rect-border' || tSel === 'rect-fill') { + body.type = 'rect'; + body.w = +fmtCoord(w / bounds.width); + body.h = +fmtCoord(h / bounds.height); + body.color = color; + body.border_only = (tSel === 'rect-border'); + body.border_width = 3; + } else if (tSel === 'dim') { + body.type = 'dim'; + body.w = +fmtCoord(w / bounds.width); + body.h = +fmtCoord(h / bounds.height); + body.color = '#000000'; + body.dim_factor = opacity; + } else if (tSel === 'text') { + const txt = prompt('Текст:', 'Hello'); + if (!txt) return; + body.type = 'text'; + body.text = txt; + body.font_size = Math.max(12, Math.round(h * (videoEl.videoHeight || 1080) * 0.8)); + body.color = color; + } + await api('POST', `/overlay/${INSTANCE}/add`, body, '+ '+body.type+' '+body.id); + refreshOverlays(); + } else if (st.mode === 'move' || st.mode === 'resize') { + const ov = st.orig; + // PATCH с новыми координатами + await api('PATCH', `/overlay/${INSTANCE}/${ov.id}`, ov, ov.id+' moved'); + refreshOverlays(); + } +}); + +async function deleteOverlay(id) { + if (!confirm('Удалить overlay ' + id + '?')) return; + await api('DELETE', `/overlay/${INSTANCE}/${id}`, null, 'deleted '+id); + if (selectedId === id) selectedId = null; + refreshOverlays(); +} + +async function refreshOverlays() { + try { + const r = await fetch(`/overlay/${INSTANCE}`); + if (!r.ok) return; + const d = await r.json(); + if (!dragState) { // не overwrite пока user тащит + currentOverlays = d.overlays; + renderOverlays(); + } + } catch (e) {} +} +setInterval(refreshOverlays, 1500); + // ── Init ────────────────────────────────────────────── // Defensive init — async (non-video) FIRST so кнопки работают независимо от HLS. try { ovTypeChanged(); } catch(e) { console.error('ovTypeChanged', e); } @@ -335,6 +634,7 @@ try { loadAudio(); } catch(e) { console.error('loadAudio', e); } try { loadHistory(); } catch(e) { console.error('loadHistory', e); } try { refreshState(); setInterval(refreshState, 2000); } catch(e) { console.error('refreshState', e); } try { initVideo(); } catch(e) { console.error('initVideo', e); setStatus('video init failed (HLS?)', true); } +try { syncEditorBounds(); refreshOverlays(); } catch(e) { console.error('editor init', e); }