Files
vf-cuda-grid/controller/cuda_grid_controller/static/index.html
T
gx d0e34c9d31 controller: persistent ffmpeg snapshot keeper — /snapshot latency 5s → 4ms
SnapshotKeeper class — single long-running ffmpeg subprocess на каждый
instance, читает output_rtsp_url непрерывно и dumps latest frame в файл
(/tmp/snapshot-keeper/<instance>.png) каждые 500 ms через ffmpeg fps=2
-update 1. /snapshot endpoint просто serves файл — disk read ~4 ms (было
~5 sec на cold ffmpeg start + RTSP negotiate + keyframe wait).

Auto-restart с exponential backoff при exit (RTSP source перезапустился,
network glitch). Cold ffmpeg fallback остаётся в endpoint — если keeper
ещё не успел dump первый PNG (первые ~1-2 sec после controller start).

UI: snapshot poll interval 700 → 250 ms (4 fps preview, было ~1.4 fps).
Keeper dump rate сейчас 2 fps — practical limit. При желании поднять
до 4 fps — fps=4 в snapshot_keeper.py (~5% доп CPU на ffmpeg PNG encode).

Применение: основной user-facing path = visual overlay editor http://controller:8083/.
Раньше polling 700 ms показывал тот же frame несколько раз пока ffmpeg
запускался. Сейчас preview почти real-time, drag-and-drop reference точный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:16:45 +01:00

678 lines
31 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.ov-marker {
/* Точечный маркер для icon/text overlays — реальный размер UI не знает.
* Position = top-left корнер где filter blit'ит. */
border-radius:50%; background:rgba(68,170,255,0.6); border-width:1px;
}
.ov-box.ov-marker.selected { background:rgba(255,170,68,0.85); }
.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();
// 250 ms = 4 fps preview. Keeper dumps PNG @ 2 fps в файл, /snapshot reads
// disk за ~4 ms — practical limit это keeper update rate (можно поднять
// до fps=4 в snapshot_keeper.py если нужно ещё быстрее).
setInterval(tick, 250);
// 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;
// Geometry: rect/dim/image имеют w+h; text — без size; icon рендерится в filter
// как native PNG dimensions (frontend не знает w_px/h_px из chat config → не
// показываем размерный box, чтобы не вводить в заблуждение). Icon = точечный
// marker (12×12 px) у top-left корнера для move handle.
const isMarker = (ov.type === 'icon' || ov.type === 'text');
const w = ov.w ?? 0.05;
const h = ov.h ?? 0.05;
const box = document.createElement('div');
box.className = 'ov-box' + (ov.id === selectedId ? ' selected' : '')
+ (isMarker ? ' ov-marker' : '');
box.dataset.id = ov.id;
box.style.left = (ov.x * 100) + '%';
box.style.top = (ov.y * 100) + '%';
if (isMarker) {
// 12x12 px маркер у top-left корнера overlay (фактический размер
// unknown UI — filter использует native PNG dimensions или font metrics).
box.style.width = '14px';
box.style.height = '14px';
} else {
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);
// Resize handle: icon (native PNG dimensions) и text (font_size based) не
// resizeable геометрически через editor — handle скрыт. Move работает.
if (!isMarker) {
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();
dragState = {
mode: 'resize', id: ov.id,
startX: e.clientX, startY: e.clientY,
origW: ov.w ?? ov.size ?? 0.05,
origH: ov.h ?? ov.size ?? 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;
const ovW = ov.w ?? ov.size ?? 0.05;
const ovH = ov.h ?? ov.size ?? 0.05;
ov.x = Math.max(0, Math.min(1 - ovW, dragState.origX + dx));
ov.y = Math.max(0, Math.min(1 - ovH, 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;
// Icon — единственное поле size (квадратный); используем max dw/dh для uniform scaling.
if (ov.size !== undefined && ov.w === undefined) {
const ds = Math.max(dw, dh);
ov.size = Math.max(0.02, Math.min(Math.min(1 - ov.x, 1 - ov.y),
dragState.origW + ds));
} else {
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>