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); }