2c0ee8c9e8
Поверх 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>
641 lines
29 KiB
HTML
641 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>cuda-grid control</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<script src="/ui/lib/hls.js"></script>
|
||
<style>
|
||
:root { --bg:#1a1a1f; --fg:#e6e6e6; --accent:#4af; --muted:#888; --good:#4f8; --warn:#fa4; --err:#f44; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; font-family:system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
||
header { padding:8px 16px; background:#0f0f12; border-bottom:1px solid #2a2a2f; display:flex; justify-content:space-between; align-items:center; }
|
||
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||
header .status { font-size:12px; color:var(--muted); }
|
||
.layout { display:grid; grid-template-columns:1fr 360px; gap:12px; padding:12px; height:calc(100vh - 44px); }
|
||
@media (max-width:900px) { .layout { grid-template-columns:1fr; height:auto; } }
|
||
.video-pane { background:#000; border-radius:6px; overflow:hidden; position:relative; min-height:300px; }
|
||
.video-pane video { width:100%; height:100%; object-fit:contain; display:block; }
|
||
.controls { overflow-y:auto; }
|
||
.card { background:#22232a; border-radius:6px; padding:12px; margin-bottom:10px; }
|
||
.card h2 { margin:0 0 8px; font-size:13px; font-weight:600; text-transform:uppercase; color:var(--accent); letter-spacing:.5px; }
|
||
.row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
|
||
button { background:#333540; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px; }
|
||
button:hover { background:#3d404a; }
|
||
button.primary { background:var(--accent); color:#000; border-color:var(--accent); }
|
||
button.danger { background:var(--err); border-color:var(--err); }
|
||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||
input,select,textarea { background:#1a1b22; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 8px; font-size:13px; width:100%; }
|
||
label { font-size:11px; color:var(--muted); display:block; margin-bottom:2px; }
|
||
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:6px; }
|
||
.toast { position:fixed; bottom:16px; right:16px; padding:8px 16px; border-radius:4px; font-size:13px; transition:opacity .3s; opacity:0; pointer-events:none; }
|
||
.toast.show { opacity:1; }
|
||
.toast.ok { background:var(--good); color:#000; }
|
||
.toast.err { background:var(--err); color:#fff; }
|
||
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>
|
||
<header>
|
||
<h1>cuda-grid control</h1>
|
||
<div class="status" id="status">connecting…</div>
|
||
</header>
|
||
|
||
<div class="layout">
|
||
<div class="video-pane">
|
||
<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">
|
||
<!-- Layout -->
|
||
<div class="card">
|
||
<h2>Layout <span style="float:right; font-size:11px"><label><input id="auto-layout-tog" type="checkbox" onchange="toggleAuto()"> auto (по motion + priority)</label></span></h2>
|
||
<div class="row" id="layout-buttons"></div>
|
||
</div>
|
||
|
||
<!-- Audio -->
|
||
<div class="card">
|
||
<h2>Audio source <span style="float:right; font-size:11px"><label><input id="audio-out-tog" type="checkbox" onchange="toggleAudioOut()"> вывод аудио в стрим</label></span></h2>
|
||
<div class="row" id="audio-buttons"></div>
|
||
</div>
|
||
|
||
<!-- Intercom -->
|
||
<div class="card">
|
||
<h2>Intercom (ducking)</h2>
|
||
<div class="row">
|
||
<button onclick="api('POST','/intercom/tv_grid/start',null,'intercom ON')">Start (music↓)</button>
|
||
<button onclick="api('POST','/intercom/tv_grid/end',null,'intercom OFF')">End</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Snapshot -->
|
||
<div class="card">
|
||
<h2>Snapshot</h2>
|
||
<div class="row">
|
||
<button class="primary" onclick="snap()">Take now</button>
|
||
<button onclick="loadHistory()">Reload history</button>
|
||
</div>
|
||
<div id="history" style="display:grid; grid-template-columns:repeat(3,1fr); gap:4px; max-height:200px; overflow-y:auto; margin-top:4px"></div>
|
||
</div>
|
||
|
||
<!-- Manual overlay -->
|
||
<div class="card">
|
||
<h2>Manual overlay</h2>
|
||
<div class="form-grid">
|
||
<div><label>type</label>
|
||
<select id="ov-type" onchange="ovTypeChanged()">
|
||
<option value="rect">rect</option>
|
||
<option value="text">text</option>
|
||
<option value="dim">dim</option>
|
||
<option value="icon">icon</option>
|
||
</select></div>
|
||
<div><label>cell (0..3 / empty=absolute)</label>
|
||
<input id="ov-cell" type="number" placeholder=""></div>
|
||
<div><label>x</label><input id="ov-x" type="number" step="0.01" value="0.1"></div>
|
||
<div><label>y</label><input id="ov-y" type="number" step="0.01" value="0.1"></div>
|
||
<div><label>w</label><input id="ov-w" type="number" step="0.01" value="0.3"></div>
|
||
<div><label>h</label><input id="ov-h" type="number" step="0.01" value="0.2"></div>
|
||
<div><label>color (hex)</label><input id="ov-color" type="color" value="#ff8800"></div>
|
||
<div><label>opacity (0..1)</label><input id="ov-opacity" type="number" step="0.05" value="1.0" min="0" max="1"></div>
|
||
</div>
|
||
<div id="ov-extra" class="form-grid"></div>
|
||
<div class="row">
|
||
<button class="primary" onclick="addOverlay()">Add</button>
|
||
<button class="danger" onclick="api('DELETE','/overlay/tv_grid',null,'cleared all')">Clear all</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat publish -->
|
||
<div class="card">
|
||
<h2>Chat message</h2>
|
||
<div class="row">
|
||
<input id="chat-msg" type="text" placeholder="message" style="flex:1">
|
||
<button onclick="pubChat()">Send</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- State -->
|
||
<div class="card">
|
||
<h2>State</h2>
|
||
<div class="kv" id="state-kv">…</div>
|
||
<details><summary style="cursor:pointer; font-size:12px; color:var(--muted); margin-top:6px">overlays raw</summary>
|
||
<pre id="overlays-raw">…</pre></details>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const INSTANCE = 'tv_grid';
|
||
const HLS_URL = `http://${location.hostname}:8888/live/index.m3u8`;
|
||
|
||
// ── 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 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 ───────────────────────────────────────
|
||
function setStatus(msg, err=false) {
|
||
const s = document.getElementById('status');
|
||
s.textContent = msg; s.style.color = err ? 'var(--err)' : 'var(--muted)';
|
||
}
|
||
function toast(msg, ok=true) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg; t.className = 'toast show ' + (ok?'ok':'err');
|
||
setTimeout(()=>t.className='toast', 1800);
|
||
}
|
||
async function api(method, path, body=null, okMsg='ok') {
|
||
try {
|
||
const opts = { method, headers:{} };
|
||
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
||
const r = await fetch(path, opts);
|
||
const j = r.headers.get('content-type')?.includes('json') ? await r.json() : await r.text();
|
||
if (r.ok) { toast(okMsg, true); return j; }
|
||
toast(`${r.status}: ${JSON.stringify(j).slice(0,80)}`, false);
|
||
} catch (e) { toast('fail: '+e.message, false); }
|
||
}
|
||
|
||
// ── Auto-layout toggle ────────────────────────────────
|
||
async function loadAutoLayout() {
|
||
const r = await fetch(`/auto-layout/${INSTANCE}`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
document.getElementById('auto-layout-tog').checked = d.enabled;
|
||
}
|
||
async function toggleAuto() {
|
||
const enabled = document.getElementById('auto-layout-tog').checked;
|
||
await api('POST', `/auto-layout/${INSTANCE}`, {enabled}, 'auto ' + (enabled?'ON':'OFF'));
|
||
}
|
||
|
||
// ── Audio output toggle (Phase 7 volume@output_audio) ─
|
||
async function loadAudioOut() {
|
||
const r = await fetch(`/audio-output/${INSTANCE}`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
document.getElementById('audio-out-tog').checked = d.enabled;
|
||
}
|
||
async function toggleAudioOut() {
|
||
const enabled = document.getElementById('audio-out-tog').checked;
|
||
await api('POST', `/audio-output/${INSTANCE}`, {enabled}, 'audio out ' + (enabled?'ON':'OFF'));
|
||
}
|
||
|
||
// ── Layout buttons ────────────────────────────────────
|
||
async function loadLayouts() {
|
||
const r = await fetch(`/layouts/${INSTANCE}`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
const box = document.getElementById('layout-buttons');
|
||
box.innerHTML = '';
|
||
d.layouts.forEach(name => {
|
||
const b = document.createElement('button');
|
||
b.textContent = name;
|
||
b.onclick = () => api('POST', `/layout/${INSTANCE}/set`, {layout:name}, '→ '+name);
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// ── Audio buttons (build dynamically) ─────────────────
|
||
async function loadAudio() {
|
||
const r = await fetch(`/audio/${INSTANCE}`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
const box = document.getElementById('audio-buttons');
|
||
box.innerHTML = '';
|
||
d.sources.forEach(s => {
|
||
const b = document.createElement('button');
|
||
b.textContent = s.label; b.dataset.name = s.name;
|
||
b.onclick = () => api('POST', `/audio/${INSTANCE}/set`, {source:s.name}, '→ '+s.label);
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// ── Snapshot ──────────────────────────────────────────
|
||
async function snap() {
|
||
const r = await fetch(`/snapshot/${INSTANCE}`, {method:'POST'});
|
||
if (!r.ok) { toast('snapshot fail', false); return; }
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
window.open(url, '_blank');
|
||
}
|
||
async function loadHistory() {
|
||
const r = await fetch(`/snapshots/${INSTANCE}?limit=24`);
|
||
if (!r.ok) { document.getElementById('history').innerHTML = '<span style="color:var(--muted); font-size:11px">disabled</span>'; return; }
|
||
const d = await r.json();
|
||
const box = document.getElementById('history');
|
||
box.innerHTML = '';
|
||
d.items.forEach(it => {
|
||
const a = document.createElement('a');
|
||
a.href = it.url; a.target = '_blank';
|
||
a.title = new Date(it.timestamp*1000).toLocaleString();
|
||
const img = document.createElement('img');
|
||
img.src = it.url; img.style.cssText = 'width:100%; border-radius:2px; cursor:pointer';
|
||
a.appendChild(img);
|
||
box.appendChild(a);
|
||
});
|
||
}
|
||
|
||
// ── Manual overlay ────────────────────────────────────
|
||
function ovTypeChanged() {
|
||
const t = document.getElementById('ov-type').value;
|
||
const extra = document.getElementById('ov-extra');
|
||
extra.innerHTML = '';
|
||
const wh = document.querySelectorAll('#ov-w,#ov-h');
|
||
wh.forEach(e => e.parentElement.style.display = (t==='rect'||t==='dim') ? '' : 'none');
|
||
if (t === 'text') {
|
||
extra.innerHTML = `<div style="grid-column:1/-1"><label>text</label><input id="ov-text" type="text" value="Hello"></div>
|
||
<div><label>font_size</label><input id="ov-font" type="number" value="32" min="8" max="256"></div>`;
|
||
} else if (t === 'icon') {
|
||
extra.innerHTML = `<div style="grid-column:1/-1"><label>icon name</label><input id="ov-icon" type="text" value="domofon"></div>`;
|
||
} else if (t === 'rect') {
|
||
extra.innerHTML = `<div><label>border only</label><input id="ov-border" type="checkbox"></div>
|
||
<div><label>border width</label><input id="ov-bw" type="number" value="2" min="1" max="32"></div>`;
|
||
}
|
||
}
|
||
function rndId() { return 'ui_'+Math.random().toString(36).slice(2,8); }
|
||
async function addOverlay() {
|
||
const t = document.getElementById('ov-type').value;
|
||
const body = {
|
||
id: rndId(),
|
||
type: t,
|
||
x: +document.getElementById('ov-x').value,
|
||
y: +document.getElementById('ov-y').value,
|
||
opacity: +document.getElementById('ov-opacity').value,
|
||
};
|
||
const cell = document.getElementById('ov-cell').value;
|
||
if (cell !== '') body.cell = +cell;
|
||
if (t==='rect' || t==='dim' || t==='image') {
|
||
body.w = +document.getElementById('ov-w').value;
|
||
body.h = +document.getElementById('ov-h').value;
|
||
}
|
||
if (t==='rect' || t==='text' || t==='dim') body.color = document.getElementById('ov-color').value;
|
||
if (t==='text') {
|
||
body.text = document.getElementById('ov-text').value;
|
||
body.font_size = +document.getElementById('ov-font').value;
|
||
}
|
||
if (t==='icon') body.name = document.getElementById('ov-icon').value;
|
||
if (t==='rect') {
|
||
body.border_only = document.getElementById('ov-border').checked;
|
||
body.border_width = +document.getElementById('ov-bw').value;
|
||
}
|
||
await api('POST', `/overlay/${INSTANCE}/add`, body, '+overlay '+body.id);
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────
|
||
async function pubChat() {
|
||
const msg = document.getElementById('chat-msg').value.trim();
|
||
if (!msg) return;
|
||
// No direct MQTT publish endpoint in controller — use manual text overlay в качестве workaround
|
||
// (controller's chat subscribes к MQTT — нужен external mosquitto_pub).
|
||
// Здесь делаем quick — add text overlay временно.
|
||
toast('Chat = MQTT topic cuda_grid/chat/alerts (use mosquitto_pub externally)', false);
|
||
}
|
||
|
||
// ── State refresh ─────────────────────────────────────
|
||
async function refreshState() {
|
||
try {
|
||
const r = await fetch('/state');
|
||
if (!r.ok) { setStatus('controller offline', true); return; }
|
||
const d = await r.json();
|
||
const inst = d.instances?.[INSTANCE];
|
||
if (!inst) return;
|
||
document.getElementById('state-kv').innerHTML =
|
||
`<span class="k">layout:</span><span>${inst.active_layout||'—'}</span>` +
|
||
`<span class="k">overlays:</span><span>${inst.overlays_count}</span>` +
|
||
`<span class="k">zmq:</span><span style="font-size:11px">${inst.zmq_endpoint}</span>`;
|
||
const r2 = await fetch(`/overlay/${INSTANCE}`);
|
||
if (r2.ok) {
|
||
const d2 = await r2.json();
|
||
document.getElementById('overlays-raw').textContent =
|
||
d2.overlays.map(o => `${o.id} ${o.type} cell=${o.cell ?? '-'}`).join('\n');
|
||
}
|
||
setStatus(`controller OK · ${inst.overlays_count} overlays`);
|
||
} 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); }
|
||
try { loadLayouts(); } catch(e) { console.error('loadLayouts', e); }
|
||
try { loadAutoLayout(); } catch(e) { console.error('loadAutoLayout', e); }
|
||
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>
|