controller UI: visual overlay editor + snapshot preview fallback

Поверх 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.

Заменено на <img> с polling /snapshot/{instance} (~700ms request, де-факто
~5s из-за времени ffmpeg RTSP+PNG, но достаточно как reference для
overlay positioning).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-26 17:06:36 +01:00
parent 543b7c9508
commit 2c0ee8c9e8
+313 -13
View File
@@ -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; }
</style>
</head>
<body>
@@ -45,7 +80,24 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
<div class="layout">
<div class="video-pane">
<video id="video" controls autoplay muted playsinline></video>
<video id="video" autoplay muted playsinline style="pointer-events:none; display:none"></video>
<img id="snap-preview" alt="live preview" style="width:100%; height:100%; object-fit:contain; display:block; background:#000; pointer-events:none">
<div id="snap-stats" style="position:absolute; top:8px; right:8px; z-index:20; background:rgba(0,0,0,0.6); color:#fff; font-size:11px; padding:2px 6px; border-radius:3px">snapshot —</div>
<div class="editor-toolbar">
<label><input id="edit-enable" type="checkbox" checked> draw mode</label>
<label>type:
<select id="edit-type">
<option value="rect-border">rect — border</option>
<option value="rect-fill">rect — fill</option>
<option value="dim">dim (privacy)</option>
<option value="text">text</option>
</select>
</label>
<label>color: <input id="edit-color" type="color" value="#ff8800"></label>
<label>opacity: <input id="edit-opacity" type="range" min="0.1" max="1" step="0.05" value="0.4" style="width:80px"></label>
<span id="edit-hint" style="color:var(--muted)">drag на видео → создать; клик на overlay → выбрать</span>
</div>
<div class="editor-layer" id="editor"></div>
</div>
<div class="controls">
@@ -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); }
</script>
</body>
</html>