Files
vf-cuda-grid/controller/cuda_grid_controller/static/index.html
T
gx 2c0ee8c9e8 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>
2026-05-26 17:06:36 +01:00

641 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>