Files
Halloween/halloween/index.html
T
2026-10-14 05:07:07 +02:00

345 lines
18 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="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Halloween-Grab · Steuerung</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111718; color: #eef0e9; }
* { box-sizing: border-box; }
body { margin: 0; }
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #182122; border-bottom: 1px solid #3a4644; }
h1 { margin: 0; font-size: 1.5rem; }
header p { margin: .35rem 0 0; color: #c1cbc2; }
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
.camera-wrap { position: relative; aspect-ratio: 4/3; }
.camera-wrap .camera { position: absolute; inset: 0; height: 100%; }
#detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
button, select, a.download { cursor: pointer; }
button:hover, a.download:hover { background: #36504a; }
button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
button.danger:hover { background: #9c352c; }
button:disabled { opacity: .5; cursor: not-allowed; }
a.download { display: inline-block; text-decoration: none; }
.value { font-weight: 650; color: #ffca78; }
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
.muted { color: #bfcbc6; }
#message { min-height: 1.5rem; color: #ffca78; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
dt { color: #adbfba; } dd { margin: 0; }
.events { grid-column: 1 / -1; }
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
#events-list li { margin: .35rem 0; }
#profiles-list li { margin: .35rem 0; }
.event-tools { margin: .3rem 0 .8rem; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>Halloween-Grab</h1>
<p>Lokale Kameraansicht und Steuerung</p>
</header>
<main>
<section>
<h2>Livebild</h2>
<div class="camera-wrap">
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
</div>
<h2 style="margin-top:1rem">Personenerkennung</h2>
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
<div class="row">
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
<button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button>
</div>
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
<p class="notice">Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
</section>
<section>
<h2>Betrieb</h2>
<dl>
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
<dt>Letzte Auslösung</dt><dd id="last-event">–</dd>
</dl>
<label for="mode">Modus wählen</label>
<div class="row" style="margin: .5rem 0 1rem">
<select id="mode">
<option value="voll">Voller Schreck</option>
<option value="sanft">Sanft</option>
<option value="atmosphaere">Atmosphäre</option>
<option value="aus">Aus</option>
</select>
<button id="save-mode">Übernehmen</button>
</div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<div class="row">
<button id="zone-clear">Bereich frei (Test)</button>
<button id="test-trigger">Testauslösung</button>
</div>
<div class="row" style="margin-top: 1rem">
<button class="danger" id="stop">Stopp</button>
<button id="reset">Nach Stopp zurücksetzen</button>
</div>
<p id="message" role="status" aria-live="polite"></p>
</section>
<section class="events">
<h2>Gespeicherte Ereignisse</h2>
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<ul id="events-list"><li>Lädt …</li></ul>
</section>
<section class="events">
<h2>Personenprofile</h2>
<p class="notice">Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.</p>
<div class="row">
<input id="profile-label" type="text" maxlength="80" aria-label="Profilkennung" placeholder="Profilkennung oder Spitzname">
<button id="create-profile">Profil anlegen</button>
</div>
<ul id="profiles-list"><li>Lädt …</li></ul>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
let profiles = [];
async function api(path, body = {}, method = 'POST') {
const response = await fetch(path, {
method, headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Anfrage fehlgeschlagen');
return data;
}
async function refresh() {
try {
const response = await fetch('/api/status', { cache: 'no-store' });
const data = await response.json();
const c = data.controller, cam = data.camera;
$('mode-label').textContent = names[c.mode] || c.mode;
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
$('phase').textContent = c.phase;
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
if (c.config_error) $('message').textContent = c.config_error;
} catch (error) {
$('camera-status').textContent = 'Server nicht erreichbar';
$('message').textContent = error.message;
}
}
async function action(path, body) {
try {
const data = await api(path, body);
$('message').textContent = data.reason || 'Einstellung übernommen';
await refresh();
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
}
async function refreshProfiles() {
const response = await fetch('/api/profiles', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Profile nicht verfügbar');
profiles = data.profiles;
const list = $('profiles-list');
list.replaceChildren();
if (!profiles.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Profile';
list.append(item);
}
for (const profile of profiles) {
const item = document.createElement('li');
const label = document.createElement('span');
label.textContent = '#' + profile.id + ' · ' + profile.label + ' · ' + profile.event_count + ' Ereignisse ';
const remove = document.createElement('button');
remove.textContent = 'Löschen';
remove.onclick = async () => {
if (!confirm('Profil und seine Zuordnungen entfernen? Die Ereignisse bleiben erhalten.')) return;
try {
await api('/api/profiles/' + profile.id, {}, 'DELETE');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
item.append(label, remove);
list.append(item);
}
}
async function refreshEvents() {
try {
const response = await fetch('/api/events', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ereignisse nicht verfügbar');
const list = $('events-list');
list.replaceChildren();
if (!data.events.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Auslösung gespeichert';
list.append(item);
}
for (const event of data.events) {
const item = document.createElement('li');
const summary = document.createElement('span');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
const tools = document.createElement('div');
tools.className = 'row event-tools';
const select = document.createElement('select');
select.setAttribute('aria-label', 'Profil für Ereignis ' + event.id);
select.add(new Option('Ohne Profil', ''));
for (const profile of profiles) select.add(new Option('#' + profile.id + ' · ' + profile.label, String(profile.id)));
select.value = event.profile_id === null ? '' : String(event.profile_id);
const assign = document.createElement('button');
assign.textContent = 'Zuordnen';
assign.onclick = async () => {
try {
await api('/api/events/' + event.id + '/profile', { profile_id: select.value ? Number(select.value) : null }, 'PATCH');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
tools.append(select, assign);
item.append(summary, tools);
list.append(item);
}
} catch (error) { $('events-list').textContent = error.message; }
}
async function refreshDb() {
try {
await refreshProfiles();
await refreshEvents();
} catch (error) {
$('profiles-list').textContent = error.message;
$('events-list').textContent = error.message;
}
}
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
$('zone-clear').onclick = () => action('/api/zone-clear');
$('test-trigger').onclick = () => action('/api/test-trigger');
$('stop').onclick = () => action('/api/stop');
$('reset').onclick = () => action('/api/reset');
$('create-profile').onclick = async () => {
try {
await api('/api/profiles', { label: $('profile-label').value });
$('profile-label').value = '';
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0;
let detectionEpoch = 0;
const overlay = $('detection-overlay'), context = overlay.getContext('2d');
function drawDetection() {
context.clearRect(0, 0, 640, 480);
if (!detectionConfig) return;
const c = detectionConfig, [x1,y1,x2,y2] = c.region;
context.lineWidth = 3; context.strokeStyle = '#78d9a2';
context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480);
const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480;
context.strokeStyle = '#ffb44f'; context.beginPath();
context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke();
const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240;
const nx = -dy/length*c.direction, ny = dx/length*c.direction;
context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke();
for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) {
context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill();
}
if (performance.now()-lastImageAt > 2000) return;
for (const person of people) {
const [l,t,r,bottom] = person.box;
context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6';
context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480);
context.fillStyle = context.strokeStyle; context.font = '16px sans-serif';
context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5));
}
}
function pointer(event) {
const rect = overlay.getBoundingClientRect();
return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),
Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))];
}
overlay.onpointerdown = event => {
if (!detectionConfig) return;
const p = pointer(event), c = detectionConfig;
const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)];
const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480));
const index = distances.indexOf(Math.min(...distances));
if (distances[index] > 30) return;
drag = index; overlay.setPointerCapture(event.pointerId);
};
overlay.onpointermove = event => {
if (drag === null) return;
const p = pointer(event), c = detectionConfig;
if (drag < 2) c.line[drag] = p;
else c.region.splice((drag-2)*2,2,...p);
drawDetection();
};
overlay.onpointerup = overlay.onpointercancel = () => { drag = null; };
$('reverse-direction').onclick = () => {
if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); }
};
$('save-detection').onclick = async () => {
if (!detectionConfig) return;
try {
detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection();
$('message').textContent = 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; }
};
async function refreshDetection() {
const epoch = detectionEpoch, requestedAt = performance.now();
try {
const response = await fetch('/api/detection', {cache:'no-store'});
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
const data = await response.json(), status = data.status;
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('detection-status').textContent = status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') +
' · '+status.fps+' Auswertungen/s';
if (data.image_base64) {
const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64;
await image.decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('live-image').src = image.src;
await $('live-image').decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
processed = true; people = data.people;
// Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten.
lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000;
} else {
people = [];
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
}
drawDetection();
} catch (error) {
people = []; drawDetection();
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
$('detection-status').textContent = error.message;
}
setTimeout(refreshDetection, 200);
}
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
detectionConfig = c; $('detection-enabled').checked = c.enabled; drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; });
refreshDetection();
setInterval(drawDetection, 250);
refresh();
refreshDb();
setInterval(refresh, 1000);
setInterval(refreshDb, 15000);
</script>
</body>
</html>